Skip to content

Commit e054388

Browse files
authored
Add SubscribeToAllFolders support to subscriptions (#1244)
* feat: add SubscribeToAllFolders support to subscriptions * feat: Add context managers
1 parent 71d9871 commit e054388

File tree

5 files changed

+154
-15
lines changed

5 files changed

+154
-15
lines changed

exchangelib/account.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
ToDoSearch,
5555
VoiceMail,
5656
)
57+
from .folders.collections import PullSubscription, PushSubscription, StreamingSubscription
5758
from .items import ALL_OCCURRENCES, AUTO_RESOLVE, HARD_DELETE, ID_ONLY, SAVE_ONLY, SEND_TO_NONE
5859
from .properties import EWSElement, Mailbox, SendingAs
5960
from .protocol import Protocol
@@ -73,6 +74,10 @@
7374
MoveItem,
7475
SendItem,
7576
SetUserOofSettings,
77+
SubscribeToPull,
78+
SubscribeToPush,
79+
SubscribeToStreaming,
80+
Unsubscribe,
7681
UpdateItem,
7782
UploadItems,
7883
)
@@ -742,6 +747,73 @@ def delegates(self):
742747
"""Return a list of DelegateUser objects representing the delegates that are set on this account."""
743748
return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True))
744749

750+
def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
751+
"""Create a pull subscription.
752+
753+
:param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES
754+
:param watermark: An event bookmark as returned by some sync services
755+
:param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a
756+
GetEvents request for this subscription.
757+
:return: The subscription ID and a watermark
758+
"""
759+
if event_types is None:
760+
event_types = SubscribeToPull.EVENT_TYPES
761+
return SubscribeToPull(account=self).get(
762+
folders=None,
763+
event_types=event_types,
764+
watermark=watermark,
765+
timeout=timeout,
766+
)
767+
768+
def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
769+
"""Create a push subscription.
770+
771+
:param callback_url: A client-defined URL that the server will call
772+
:param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
773+
:param watermark: An event bookmark as returned by some sync services
774+
:param status_frequency: The frequency, in minutes, that the callback URL will be called with.
775+
:return: The subscription ID and a watermark
776+
"""
777+
if event_types is None:
778+
event_types = SubscribeToPush.EVENT_TYPES
779+
return SubscribeToPush(account=self).get(
780+
folders=None,
781+
event_types=event_types,
782+
watermark=watermark,
783+
status_frequency=status_frequency,
784+
url=callback_url,
785+
)
786+
787+
def subscribe_to_streaming(self, event_types=None):
788+
"""Create a streaming subscription.
789+
790+
:param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
791+
:return: The subscription ID
792+
"""
793+
if event_types is None:
794+
event_types = SubscribeToStreaming.EVENT_TYPES
795+
return SubscribeToStreaming(account=self).get(folders=None, event_types=event_types)
796+
797+
def pull_subscription(self, **kwargs):
798+
return PullSubscription(target=self, **kwargs)
799+
800+
def push_subscription(self, **kwargs):
801+
return PushSubscription(target=self, **kwargs)
802+
803+
def streaming_subscription(self, **kwargs):
804+
return StreamingSubscription(target=self, **kwargs)
805+
806+
def unsubscribe(self, subscription_id):
807+
"""Unsubscribe. Only applies to pull and streaming notifications.
808+
809+
:param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]()
810+
:return: True
811+
812+
This method doesn't need the current collection instance, but it makes sense to keep the method along the other
813+
sync methods.
814+
"""
815+
return Unsubscribe(account=self).get(subscription_id=subscription_id)
816+
745817
def __str__(self):
746818
if self.fullname:
747819
return f"{self.primary_smtp_address} ({self.fullname})"

exchangelib/folders/base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -631,15 +631,15 @@ def subscribe_to_streaming(self, event_types=None):
631631

632632
@require_id
633633
def pull_subscription(self, **kwargs):
634-
return PullSubscription(folder=self, **kwargs)
634+
return PullSubscription(target=self, **kwargs)
635635

636636
@require_id
637637
def push_subscription(self, **kwargs):
638-
return PushSubscription(folder=self, **kwargs)
638+
return PushSubscription(target=self, **kwargs)
639639

640640
@require_id
641641
def streaming_subscription(self, **kwargs):
642-
return StreamingSubscription(folder=self, **kwargs)
642+
return StreamingSubscription(target=self, **kwargs)
643643

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

exchangelib/folders/collections.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -448,13 +448,13 @@ def subscribe_to_streaming(self, event_types=None):
448448
return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types)
449449

450450
def pull_subscription(self, **kwargs):
451-
return PullSubscription(folder=self, **kwargs)
451+
return PullSubscription(target=self, **kwargs)
452452

453453
def push_subscription(self, **kwargs):
454-
return PushSubscription(folder=self, **kwargs)
454+
return PushSubscription(target=self, **kwargs)
455455

456456
def streaming_subscription(self, **kwargs):
457-
return StreamingSubscription(folder=self, **kwargs)
457+
return StreamingSubscription(target=self, **kwargs)
458458

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

541541

542542
class BaseSubscription(metaclass=abc.ABCMeta):
543-
def __init__(self, folder, **subscription_kwargs):
544-
self.folder = folder
543+
def __init__(self, target, **subscription_kwargs):
544+
self.target = target
545545
self.subscription_kwargs = subscription_kwargs
546546
self.subscription_id = None
547547

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

552552
def __exit__(self, *args, **kwargs):
553-
self.folder.unsubscribe(subscription_id=self.subscription_id)
553+
self.target.unsubscribe(subscription_id=self.subscription_id)
554554
self.subscription_id = None
555555

556556

557557
class PullSubscription(BaseSubscription):
558558
def __enter__(self):
559-
self.subscription_id, watermark = self.folder.subscribe_to_pull(**self.subscription_kwargs)
559+
self.subscription_id, watermark = self.target.subscribe_to_pull(**self.subscription_kwargs)
560560
return self.subscription_id, watermark
561561

562562

563563
class PushSubscription(BaseSubscription):
564564
def __enter__(self):
565-
self.subscription_id, watermark = self.folder.subscribe_to_push(**self.subscription_kwargs)
565+
self.subscription_id, watermark = self.target.subscribe_to_push(**self.subscription_kwargs)
566566
return self.subscription_id, watermark
567567

568568
def __exit__(self, *args, **kwargs):
@@ -572,5 +572,5 @@ def __exit__(self, *args, **kwargs):
572572

573573
class StreamingSubscription(BaseSubscription):
574574
def __enter__(self):
575-
self.subscription_id = self.folder.subscribe_to_streaming(**self.subscription_kwargs)
575+
self.subscription_id = self.target.subscribe_to_streaming(**self.subscription_kwargs)
576576
return self.subscription_id

exchangelib/services/subscribe.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,13 @@ def _get_elements_in_container(cls, container):
4040
return [(container.find(f"{{{MNS}}}SubscriptionId"), container.find(f"{{{MNS}}}Watermark"))]
4141

4242
def _partial_payload(self, folders, event_types):
43-
request_elem = create_element(self.subscription_request_elem_tag)
44-
folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds")
45-
request_elem.append(folder_ids)
43+
if folders is None:
44+
# Interpret this as "all folders"
45+
request_elem = create_element(self.subscription_request_elem_tag, attrs=dict(SubscribeToAllFolders=True))
46+
else:
47+
request_elem = create_element(self.subscription_request_elem_tag)
48+
folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds")
49+
request_elem.append(folder_ids)
4650
event_types_elem = create_element("t:EventTypes")
4751
for event_type in event_types:
4852
add_xml_child(event_types_elem, "t:EventType", event_type)

tests/test_items/test_sync.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@ def test_pull_subscribe(self):
5454
self.account.root.tois.children.unsubscribe(subscription_id)
5555
# Affinity cookie is not always sent by the server for pull subscriptions
5656

57+
def test_pull_subscribe_from_account(self):
58+
self.account.affinity_cookie = None
59+
with self.account.pull_subscription() as (subscription_id, watermark):
60+
self.assertIsNotNone(subscription_id)
61+
self.assertIsNotNone(watermark)
62+
# Test with watermark
63+
with self.account.pull_subscription(watermark=watermark) as (subscription_id, watermark):
64+
self.assertIsNotNone(subscription_id)
65+
self.assertIsNotNone(watermark)
66+
# Context manager already unsubscribed us
67+
with self.assertRaises(ErrorSubscriptionNotFound):
68+
self.account.unsubscribe(subscription_id)
69+
# Test without watermark
70+
with self.account.pull_subscription() as (subscription_id, watermark):
71+
self.assertIsNotNone(subscription_id)
72+
self.assertIsNotNone(watermark)
73+
with self.assertRaises(ErrorSubscriptionNotFound):
74+
self.account.unsubscribe(subscription_id)
75+
# Affinity cookie is not always sent by the server for pull subscriptions
76+
5777
def test_push_subscribe(self):
5878
with self.account.inbox.push_subscription(callback_url="https://example.com/foo") as (
5979
subscription_id,
@@ -81,6 +101,33 @@ def test_push_subscribe(self):
81101
with self.assertRaises(ErrorInvalidSubscription):
82102
self.account.root.tois.children.unsubscribe(subscription_id)
83103

104+
def test_push_subscribe_from_account(self):
105+
with self.account.push_subscription(callback_url="https://example.com/foo") as (
106+
subscription_id,
107+
watermark,
108+
):
109+
self.assertIsNotNone(subscription_id)
110+
self.assertIsNotNone(watermark)
111+
# Test with watermark
112+
with self.account.push_subscription(
113+
callback_url="https://example.com/foo",
114+
watermark=watermark,
115+
) as (subscription_id, watermark):
116+
self.assertIsNotNone(subscription_id)
117+
self.assertIsNotNone(watermark)
118+
# Cannot unsubscribe. Must be done as response to callback URL request
119+
with self.assertRaises(ErrorInvalidSubscription):
120+
self.account.unsubscribe(subscription_id)
121+
# Test via folder collection
122+
with self.account.push_subscription(callback_url="https://example.com/foo") as (
123+
subscription_id,
124+
watermark,
125+
):
126+
self.assertIsNotNone(subscription_id)
127+
self.assertIsNotNone(watermark)
128+
with self.assertRaises(ErrorInvalidSubscription):
129+
self.account.unsubscribe(subscription_id)
130+
84131
def test_empty_folder_collection(self):
85132
self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_pull(), None)
86133
self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_push("http://example.com"), None)
@@ -102,6 +149,22 @@ def test_streaming_subscribe(self):
102149
# Test affinity cookie
103150
self.assertIsNotNone(self.account.affinity_cookie)
104151

152+
def test_streaming_subscribe_from_account(self):
153+
self.account.affinity_cookie = None
154+
with self.account.streaming_subscription() as subscription_id:
155+
self.assertIsNotNone(subscription_id)
156+
# Context manager already unsubscribed us
157+
with self.assertRaises(ErrorSubscriptionNotFound):
158+
self.account.unsubscribe(subscription_id)
159+
# Test via folder collection
160+
with self.account.streaming_subscription() as subscription_id:
161+
self.assertIsNotNone(subscription_id)
162+
with self.assertRaises(ErrorSubscriptionNotFound):
163+
self.account.unsubscribe(subscription_id)
164+
165+
# Test affinity cookie
166+
self.assertIsNotNone(self.account.affinity_cookie)
167+
105168
def test_sync_folder_hierarchy(self):
106169
test_folder = self.get_test_folder().save()
107170

0 commit comments

Comments
 (0)