Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SubscribeToAllFolders support to subscriptions #1244

Merged
merged 2 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions exchangelib/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
ToDoSearch,
VoiceMail,
)
from .folders.collections import PullSubscription, PushSubscription, StreamingSubscription
from .items import ALL_OCCURRENCES, AUTO_RESOLVE, HARD_DELETE, ID_ONLY, SAVE_ONLY, SEND_TO_NONE
from .properties import EWSElement, Mailbox, SendingAs
from .protocol import Protocol
Expand All @@ -73,6 +74,10 @@
MoveItem,
SendItem,
SetUserOofSettings,
SubscribeToPull,
SubscribeToPush,
SubscribeToStreaming,
Unsubscribe,
UpdateItem,
UploadItems,
)
Expand Down Expand Up @@ -742,6 +747,73 @@ def delegates(self):
"""Return a list of DelegateUser objects representing the delegates that are set on this account."""
return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True))

def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
"""Create a pull subscription.

:param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES
:param watermark: An event bookmark as returned by some sync services
:param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a
GetEvents request for this subscription.
:return: The subscription ID and a watermark
"""
if event_types is None:
event_types = SubscribeToPull.EVENT_TYPES
return SubscribeToPull(account=self).get(
folders=None,
event_types=event_types,
watermark=watermark,
timeout=timeout,
)

def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
"""Create a push subscription.

:param callback_url: A client-defined URL that the server will call
:param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
:param watermark: An event bookmark as returned by some sync services
:param status_frequency: The frequency, in minutes, that the callback URL will be called with.
:return: The subscription ID and a watermark
"""
if event_types is None:
event_types = SubscribeToPush.EVENT_TYPES
return SubscribeToPush(account=self).get(
folders=None,
event_types=event_types,
watermark=watermark,
status_frequency=status_frequency,
url=callback_url,
)

def subscribe_to_streaming(self, event_types=None):
"""Create a streaming subscription.

:param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
:return: The subscription ID
"""
if event_types is None:
event_types = SubscribeToStreaming.EVENT_TYPES
return SubscribeToStreaming(account=self).get(folders=None, event_types=event_types)

def pull_subscription(self, **kwargs):
return PullSubscription(target=self, **kwargs)

def push_subscription(self, **kwargs):
return PushSubscription(target=self, **kwargs)

def streaming_subscription(self, **kwargs):
return StreamingSubscription(target=self, **kwargs)

def unsubscribe(self, subscription_id):
"""Unsubscribe. Only applies to pull and streaming notifications.

:param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]()
:return: True

This method doesn't need the current collection instance, but it makes sense to keep the method along the other
sync methods.
"""
return Unsubscribe(account=self).get(subscription_id=subscription_id)

def __str__(self):
if self.fullname:
return f"{self.primary_smtp_address} ({self.fullname})"
Expand Down
6 changes: 3 additions & 3 deletions exchangelib/folders/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,15 +631,15 @@ def subscribe_to_streaming(self, event_types=None):

@require_id
def pull_subscription(self, **kwargs):
return PullSubscription(folder=self, **kwargs)
return PullSubscription(target=self, **kwargs)

@require_id
def push_subscription(self, **kwargs):
return PushSubscription(folder=self, **kwargs)
return PushSubscription(target=self, **kwargs)

@require_id
def streaming_subscription(self, **kwargs):
return StreamingSubscription(folder=self, **kwargs)
return StreamingSubscription(target=self, **kwargs)

def unsubscribe(self, subscription_id):
"""Unsubscribe. Only applies to pull and streaming notifications.
Expand Down
18 changes: 9 additions & 9 deletions exchangelib/folders/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,13 +448,13 @@ def subscribe_to_streaming(self, event_types=None):
return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types)

def pull_subscription(self, **kwargs):
return PullSubscription(folder=self, **kwargs)
return PullSubscription(target=self, **kwargs)

def push_subscription(self, **kwargs):
return PushSubscription(folder=self, **kwargs)
return PushSubscription(target=self, **kwargs)

def streaming_subscription(self, **kwargs):
return StreamingSubscription(folder=self, **kwargs)
return StreamingSubscription(target=self, **kwargs)

def unsubscribe(self, subscription_id):
"""Unsubscribe. Only applies to pull and streaming notifications.
Expand Down Expand Up @@ -540,8 +540,8 @@ def sync_hierarchy(self, sync_state=None, only_fields=None):


class BaseSubscription(metaclass=abc.ABCMeta):
def __init__(self, folder, **subscription_kwargs):
self.folder = folder
def __init__(self, target, **subscription_kwargs):
self.target = target
self.subscription_kwargs = subscription_kwargs
self.subscription_id = None

Expand All @@ -550,19 +550,19 @@ def __enter__(self):
"""Create the subscription"""

def __exit__(self, *args, **kwargs):
self.folder.unsubscribe(subscription_id=self.subscription_id)
self.target.unsubscribe(subscription_id=self.subscription_id)
self.subscription_id = None


class PullSubscription(BaseSubscription):
def __enter__(self):
self.subscription_id, watermark = self.folder.subscribe_to_pull(**self.subscription_kwargs)
self.subscription_id, watermark = self.target.subscribe_to_pull(**self.subscription_kwargs)
return self.subscription_id, watermark


class PushSubscription(BaseSubscription):
def __enter__(self):
self.subscription_id, watermark = self.folder.subscribe_to_push(**self.subscription_kwargs)
self.subscription_id, watermark = self.target.subscribe_to_push(**self.subscription_kwargs)
return self.subscription_id, watermark

def __exit__(self, *args, **kwargs):
Expand All @@ -572,5 +572,5 @@ def __exit__(self, *args, **kwargs):

class StreamingSubscription(BaseSubscription):
def __enter__(self):
self.subscription_id = self.folder.subscribe_to_streaming(**self.subscription_kwargs)
self.subscription_id = self.target.subscribe_to_streaming(**self.subscription_kwargs)
return self.subscription_id
10 changes: 7 additions & 3 deletions exchangelib/services/subscribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,13 @@ def _get_elements_in_container(cls, container):
return [(container.find(f"{{{MNS}}}SubscriptionId"), container.find(f"{{{MNS}}}Watermark"))]

def _partial_payload(self, folders, event_types):
request_elem = create_element(self.subscription_request_elem_tag)
folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds")
request_elem.append(folder_ids)
if folders is None:
# Interpret this as "all folders"
request_elem = create_element(self.subscription_request_elem_tag, attrs=dict(SubscribeToAllFolders=True))
else:
request_elem = create_element(self.subscription_request_elem_tag)
folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds")
request_elem.append(folder_ids)
event_types_elem = create_element("t:EventTypes")
for event_type in event_types:
add_xml_child(event_types_elem, "t:EventType", event_type)
Expand Down
63 changes: 63 additions & 0 deletions tests/test_items/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ def test_pull_subscribe(self):
self.account.root.tois.children.unsubscribe(subscription_id)
# Affinity cookie is not always sent by the server for pull subscriptions

def test_pull_subscribe_from_account(self):
self.account.affinity_cookie = None
with self.account.pull_subscription() as (subscription_id, watermark):
self.assertIsNotNone(subscription_id)
self.assertIsNotNone(watermark)
# Test with watermark
with self.account.pull_subscription(watermark=watermark) as (subscription_id, watermark):
self.assertIsNotNone(subscription_id)
self.assertIsNotNone(watermark)
# Context manager already unsubscribed us
with self.assertRaises(ErrorSubscriptionNotFound):
self.account.unsubscribe(subscription_id)
# Test without watermark
with self.account.pull_subscription() as (subscription_id, watermark):
self.assertIsNotNone(subscription_id)
self.assertIsNotNone(watermark)
with self.assertRaises(ErrorSubscriptionNotFound):
self.account.unsubscribe(subscription_id)
# Affinity cookie is not always sent by the server for pull subscriptions

def test_push_subscribe(self):
with self.account.inbox.push_subscription(callback_url="https://example.com/foo") as (
subscription_id,
Expand Down Expand Up @@ -81,6 +101,33 @@ def test_push_subscribe(self):
with self.assertRaises(ErrorInvalidSubscription):
self.account.root.tois.children.unsubscribe(subscription_id)

def test_push_subscribe_from_account(self):
with self.account.push_subscription(callback_url="https://example.com/foo") as (
subscription_id,
watermark,
):
self.assertIsNotNone(subscription_id)
self.assertIsNotNone(watermark)
# Test with watermark
with self.account.push_subscription(
callback_url="https://example.com/foo",
watermark=watermark,
) as (subscription_id, watermark):
self.assertIsNotNone(subscription_id)
self.assertIsNotNone(watermark)
# Cannot unsubscribe. Must be done as response to callback URL request
with self.assertRaises(ErrorInvalidSubscription):
self.account.unsubscribe(subscription_id)
# Test via folder collection
with self.account.push_subscription(callback_url="https://example.com/foo") as (
subscription_id,
watermark,
):
self.assertIsNotNone(subscription_id)
self.assertIsNotNone(watermark)
with self.assertRaises(ErrorInvalidSubscription):
self.account.unsubscribe(subscription_id)

def test_empty_folder_collection(self):
self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_pull(), None)
self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_push("http://example.com"), None)
Expand All @@ -102,6 +149,22 @@ def test_streaming_subscribe(self):
# Test affinity cookie
self.assertIsNotNone(self.account.affinity_cookie)

def test_streaming_subscribe_from_account(self):
self.account.affinity_cookie = None
with self.account.streaming_subscription() as subscription_id:
self.assertIsNotNone(subscription_id)
# Context manager already unsubscribed us
with self.assertRaises(ErrorSubscriptionNotFound):
self.account.unsubscribe(subscription_id)
# Test via folder collection
with self.account.streaming_subscription() as subscription_id:
self.assertIsNotNone(subscription_id)
with self.assertRaises(ErrorSubscriptionNotFound):
self.account.unsubscribe(subscription_id)

# Test affinity cookie
self.assertIsNotNone(self.account.affinity_cookie)

def test_sync_folder_hierarchy(self):
test_folder = self.get_test_folder().save()

Expand Down
Loading