Skip to content

Commit 78d279b

Browse files
committed
Added subscriptions to Account and example
1 parent b14e9c6 commit 78d279b

3 files changed

Lines changed: 314 additions & 7 deletions

File tree

O365/account.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22
from typing import Callable, List, Optional, Tuple, Type
33

44
from .connection import Connection, MSGraphProtocol, Protocol
5+
from .subscriptions import Subscriptions
56
from .utils import ME_RESOURCE, consent_input_token
67

78

8-
class Account:
9-
connection_constructor: Type = Connection #: :meta private:
10-
11-
def __init__(self, credentials: Tuple[str, str], *,
12-
username: Optional[str] = None,
13-
protocol: Optional[Protocol] = None,
14-
main_resource: Optional[str] = None, **kwargs):
9+
class Account:
10+
connection_constructor: Type = Connection #: :meta private:
11+
subscriptions_constructor: Type = Subscriptions
12+
13+
def __init__(self, credentials: Tuple[str, str], *,
14+
username: Optional[str] = None,
15+
protocol: Optional[Protocol] = None,
16+
main_resource: Optional[str] = None, **kwargs):
1517
""" Creates an object which is used to access resources related to the specified credentials.
1618
1719
:param credentials: a tuple containing the client_id and client_secret
@@ -60,6 +62,7 @@ def __init__(self, credentials: Tuple[str, str], *,
6062
self.con = self.connection_constructor(credentials, **kwargs)
6163
#: The resource in use for the account. |br| **Type:** str
6264
self.main_resource: str = main_resource or self.protocol.default_resource
65+
self.subscriptions = self.subscriptions_constructor(parent=self)
6366

6467
def __repr__(self):
6568
if self.con.auth:

O365/subscriptions.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import datetime as dt
2+
from typing import Iterable, Mapping, Optional, Union
3+
4+
from .utils import ApiComponent
5+
6+
7+
class Subscriptions(ApiComponent):
8+
"""Subscription operations for Microsoft Graph webhooks."""
9+
10+
_endpoints = {
11+
"subscriptions": "/subscriptions",
12+
}
13+
14+
def __init__(self, *, parent=None, con=None, **kwargs):
15+
if parent and con:
16+
raise ValueError("Need a parent or a connection but not both")
17+
self.con = parent.con if parent else con
18+
19+
main_resource = kwargs.pop("main_resource", None) or (
20+
getattr(parent, "main_resource", None) if parent else None
21+
)
22+
23+
super().__init__(
24+
protocol=parent.protocol if parent else kwargs.get("protocol"),
25+
main_resource=main_resource,
26+
)
27+
28+
def _build_subscription_url(self, subscription_id: Optional[str] = None) -> str:
29+
"""Build the Microsoft Graph subscriptions endpoint."""
30+
endpoint = self._endpoints.get("subscriptions")
31+
if endpoint is None:
32+
raise ValueError("Subscriptions endpoint is not configured.")
33+
base_url = self.protocol.service_url.rstrip("/")
34+
if subscription_id:
35+
return f"{base_url}{endpoint}/{subscription_id}"
36+
return f"{base_url}{endpoint}"
37+
38+
@staticmethod
39+
def _format_subscription_expiration(
40+
expiration_datetime: Optional[dt.datetime] = None,
41+
expiration_minutes: Optional[int] = None,
42+
) -> str:
43+
"""Return an ISO 8601 UTC expiration string as required by Graph webhooks."""
44+
if expiration_datetime and expiration_minutes is not None:
45+
raise ValueError(
46+
"Provide either expiration_datetime or expiration_minutes, not both."
47+
)
48+
if expiration_datetime is None:
49+
minutes = expiration_minutes if expiration_minutes is not None else 60
50+
if minutes <= 0:
51+
raise ValueError("expiration_minutes must be a positive integer.")
52+
expiration_datetime = dt.datetime.now(dt.timezone.utc) + dt.timedelta(
53+
minutes=minutes
54+
)
55+
else:
56+
if expiration_datetime.tzinfo is None:
57+
expiration_datetime = expiration_datetime.replace(tzinfo=dt.timezone.utc)
58+
else:
59+
expiration_datetime = expiration_datetime.astimezone(dt.timezone.utc)
60+
return expiration_datetime.isoformat(timespec="microseconds").replace("+00:00", "Z")
61+
62+
@staticmethod
63+
def _stringify_change_type(change_type: Union[str, Iterable[str]]) -> str:
64+
"""Normalize changeType into the comma-separated string Graph expects."""
65+
if isinstance(change_type, str):
66+
value = change_type.strip()
67+
else:
68+
try:
69+
parts = [str(part).strip() for part in change_type]
70+
except TypeError as exc:
71+
raise ValueError(
72+
"change_type must be a string or an iterable of strings."
73+
) from exc
74+
value = ",".join(part for part in parts if part)
75+
if not value:
76+
raise ValueError("change_type must contain at least one value.")
77+
return value
78+
79+
def create_subscription(
80+
self,
81+
notification_url: str,
82+
resource: Optional[str] = None,
83+
change_type: Union[str, Iterable[str]] = "created",
84+
*,
85+
expiration_datetime: Optional[dt.datetime] = None,
86+
expiration_minutes: Optional[int] = None,
87+
client_state: Optional[str] = None,
88+
include_resource_data: Optional[bool] = None,
89+
encryption_certificate: Optional[str] = None,
90+
encryption_certificate_id: Optional[str] = None,
91+
lifecycle_notification_url: Optional[str] = None,
92+
latest_supported_tls_version: Optional[str] = None,
93+
additional_data: Optional[Mapping[str, object]] = None,
94+
**request_kwargs,
95+
) -> Optional[dict]:
96+
"""Create a Microsoft Graph webhook subscription.
97+
98+
See Documentation.md for webhook setup requirements.
99+
"""
100+
if not notification_url:
101+
raise ValueError("notification_url must be provided.")
102+
103+
resource = resource or self.main_resource
104+
if not resource:
105+
raise ValueError("resource must be provided.")
106+
if not resource.startswith("/"):
107+
resource = f"/{resource}"
108+
109+
expiration_value = self._format_subscription_expiration(
110+
expiration_datetime=expiration_datetime,
111+
expiration_minutes=expiration_minutes,
112+
)
113+
change_type_value = self._stringify_change_type(change_type)
114+
115+
payload = {
116+
self._cc("change_type"): change_type_value,
117+
self._cc("notification_url"): notification_url,
118+
self._cc("resource"): resource,
119+
self._cc("expiration_date_time"): expiration_value,
120+
}
121+
122+
if client_state is not None:
123+
payload[self._cc("client_state")] = client_state
124+
if include_resource_data is not None:
125+
payload[self._cc("include_resource_data")] = include_resource_data
126+
if encryption_certificate is not None:
127+
payload[self._cc("encryption_certificate")] = encryption_certificate
128+
if encryption_certificate_id is not None:
129+
payload[self._cc("encryption_certificate_id")] = encryption_certificate_id
130+
if lifecycle_notification_url is not None:
131+
payload[self._cc("lifecycle_notification_url")] = lifecycle_notification_url
132+
if latest_supported_tls_version is not None:
133+
payload[
134+
self._cc("latest_supported_tls_version")
135+
] = latest_supported_tls_version
136+
if additional_data:
137+
if not isinstance(additional_data, Mapping):
138+
raise ValueError("additional_data must be a mapping if provided.")
139+
payload.update({str(key): value for key, value in additional_data.items()})
140+
141+
url = self._build_subscription_url()
142+
response = self.con.post(url, data=payload, **request_kwargs)
143+
144+
if not response:
145+
return None
146+
147+
return response.json()
148+
149+
def renew_subscription(
150+
self,
151+
subscription_id: str,
152+
*,
153+
expiration_datetime: Optional[dt.datetime] = None,
154+
expiration_minutes: Optional[int] = None,
155+
**request_kwargs,
156+
) -> Optional[dict]:
157+
"""Renew an existing webhook subscription."""
158+
if not subscription_id:
159+
raise ValueError("subscription_id must be provided.")
160+
161+
expiration_value = self._format_subscription_expiration(
162+
expiration_datetime=expiration_datetime,
163+
expiration_minutes=expiration_minutes,
164+
)
165+
166+
payload = {
167+
self._cc("expiration_date_time"): expiration_value,
168+
}
169+
170+
url = self._build_subscription_url(subscription_id)
171+
response = self.con.patch(url, data=payload, **request_kwargs)
172+
173+
if not response:
174+
return None
175+
176+
return response.json()
177+
178+
def delete_subscription(
179+
self,
180+
subscription_id: str,
181+
**request_kwargs,
182+
) -> bool:
183+
"""Delete an existing webhook subscription."""
184+
if not subscription_id:
185+
raise ValueError("subscription_id must be provided.")
186+
187+
url = self._build_subscription_url(subscription_id)
188+
response = self.con.delete(url, **request_kwargs)
189+
190+
return bool(response)

examples/subscriptions_example.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+

2+
""" Example on how to use and setup webhooks
3+
4+
Quickstart for this example:
5+
1) Run Flask locally withg the following command:
6+
- flask --app examples/subscription_account_webhook.py run --debug
7+
2) Expose HTTPS via a tunnel to your localhost:5000:
8+
- Free: pinggy (https://pinggy.io/) to get https://<subdomain>.pinggy.link -> http://localhost:5000
9+
- Paid/free-tier: ngrok (https://ngrok.com/): ngrok http 5000, note the https URL.
10+
3) Use the tunnel HTTPS URL as notification_url pointing to /webhook, URL-encoded.
11+
4) To create a subscription, follow the example request below:
12+
- https://<your-tunnel-host>/subscriptions?notification_url=https%3A%2F%2F<your-tunnel-host>%2Fwebhook&client_state=abc123
13+
4) To renew a subscription, follow the example request below:
14+
- http://<your-tunnel-host>/subscriptions/<subscription_id>/renew?expiration_minutes=55
15+
5) To delete a subscription, follow the example request below:
16+
- http://<your-tunnel-host>/subscriptions/<subscription_id>/delete
17+
Graph will call https://<your-tunnel-host>/webhook; this app echoes validationToken and returns 202 for notifications.
18+
"""
19+
20+
from flask import Flask, abort, jsonify, request
21+
from O365 import Account
22+
23+
CLIENT_ID = "YOUR CLIENT ID"
24+
CLIENT_SECRET = "YOUR CLIENT SECRET"
25+
credentials = (CLIENT_ID, CLIENT_SECRET)
26+
27+
account = Account(credentials)
28+
# Pick the scopes that are relevant to you here
29+
account.authenticate(
30+
scopes=[
31+
"https://graph.microsoft.com/Mail.ReadWrite",
32+
"https://graph.microsoft.com/Mail.Send",
33+
"https://graph.microsoft.com/Calendars.ReadWrite",
34+
"https://graph.microsoft.com/MailboxSettings.ReadWrite",
35+
"https://graph.microsoft.com/User.Read",
36+
"https://graph.microsoft.com/User.ReadBasic.All",
37+
'offline_access'
38+
])
39+
40+
RESOURCE = "/me/mailFolders('inbox')/messages"
41+
DEFAULT_EXPIRATION_MINUTES = 55 # Graph requires renewals before the limit
42+
43+
app = Flask(__name__)
44+
45+
46+
def _int_arg(name: str, default: int) -> int:
47+
raw = request.args.get(name)
48+
if raw is None:
49+
return default
50+
try:
51+
return int(raw)
52+
except ValueError:
53+
abort(400, description=f"{name} must be an integer")
54+
55+
56+
@app.get("/subscriptions")
57+
def create_subscription():
58+
notification_url = request.args.get("notification_url")
59+
if not notification_url:
60+
abort(400, description="notification_url is required")
61+
62+
expiration_minutes = _int_arg("expiration_minutes", DEFAULT_EXPIRATION_MINUTES)
63+
client_state = request.args.get("client_state")
64+
resource = request.args.get("resource", RESOURCE)
65+
66+
subscription = account.subscriptions.create_subscription(
67+
notification_url=notification_url,
68+
resource=resource,
69+
change_type="created",
70+
expiration_minutes=expiration_minutes,
71+
client_state=client_state,
72+
)
73+
return jsonify(subscription), 201
74+
75+
76+
@app.get("/subscriptions/<subscription_id>/renew")
77+
def renew_subscription(subscription_id: str):
78+
expiration_minutes = _int_arg("expiration_minutes", DEFAULT_EXPIRATION_MINUTES)
79+
updated = account.subscriptions.renew_subscription(
80+
subscription_id,
81+
expiration_minutes=expiration_minutes,
82+
)
83+
return jsonify(updated), 200
84+
85+
86+
@app.get("/subscriptions/<subscription_id>/delete")
87+
def delete_subscription(subscription_id: str):
88+
deleted = account.subscriptions.delete_subscription(subscription_id)
89+
if not deleted:
90+
abort(404, description="Subscription not found")
91+
return ("", 204)
92+
93+
94+
@app.post("/webhook")
95+
def webhook_handler():
96+
"""Handle Microsoft Graph webhook calls.
97+
98+
- During subscription validation, Graph sends POST with ?validationToken=... .
99+
We must echo the token as plain text within 10 seconds.
100+
- For change notifications, Graph posts JSON; we just log/ack.
101+
"""
102+
validation_token = request.args.get("validationToken")
103+
if validation_token:
104+
# Echo back token exactly as plain text with HTTP 200.
105+
return validation_token, 200, {"Content-Type": "text/plain"}
106+
107+
# Change notifications: inspect or log as needed.
108+
payload = request.get_json(silent=True) or {}
109+
print("Received notification payload:", payload)
110+
return ("", 202)
111+
112+
113+
if __name__ == "__main__":
114+
app.run(debug=True, ssl_context=("examples/cert.pem", "examples/key.pem"))

0 commit comments

Comments
 (0)