From 67af091794ed8246254321b1689db39a3af1e0df Mon Sep 17 00:00:00 2001 From: Santiago Delgado Date: Fri, 28 Feb 2025 14:54:17 -0500 Subject: [PATCH 1/4] Initial architecture pseudocode --- O365/mailbox.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/O365/mailbox.py b/O365/mailbox.py index 4199462a..dfd7a30e 100644 --- a/O365/mailbox.py +++ b/O365/mailbox.py @@ -253,6 +253,7 @@ class Folder(ApiComponent): "copy_folder": "/mailFolders/{id}/copy", "move_folder": "/mailFolders/{id}/move", "message": "/messages/{id}", + "subscriptions": "/subscriptions", } message_constructor = Message @@ -1045,3 +1046,61 @@ def get_settings(self): parent=self, **{self._cloud_data_key: data} ) + ''' + def set_email_subscription(self, notificationurl, minutes): + """Set a webhook subscription to send post request every + time an email is received + + :return: response from the connection + :rtype: connection + """ + + # Get the current UTC time + now_utc = dr.datetime.utcnow() + + # Calculate the future time by adding 4200 minutes + future_utc = now_utc + dt.timedelta(minutes=minutes) + + # Format with 7 decimal places and append 'Z' to indicate UTC + # '%f' gives 6 digits for microseconds; we add an extra '0' for 7 decimal places. + expiration_str = future_utc.strftime("%Y-%m-%dT%H:%M:%S.%f0Z") + + url = "https://graph.microsoft.com/v1.0/subscriptions" + + params = { + "changeType": "created,updated", + "notificationUrl": notificationurl, + "resource": "/me/mailfolders('inbox')/messages", + "expirationDateTime": expiration_str, + } + + response = self.con.get(url, params=my_params) + + + def renew_email_subscription(self, id, minutes): + """Renew a webhook subscription to send post request every + time an email is received + + :return: response from the connection + :rtype: connection + """ + + # Get the current UTC time + now_utc = dr.datetime.utcnow() + + # Calculate the future time by adding 4200 minutes + future_utc = now_utc + dt.timedelta(minutes=minutes) + + # Format with 7 decimal places and append 'Z' to indicate UTC + # '%f' gives 6 digits for microseconds; we add an extra '0' for 7 decimal places. + expiration_str = future_utc.strftime("%Y-%m-%dT%H:%M:%S.%f0Z") + + url = f'https://graph.microsoft.com/v1.0/subscriptions/{id}' + + params = { + "expirationDateTime": expiration_str, + } + + response = self.con.get(url, params=my_params) + + ''' From 78d279b2d10cd14c541dac8dfa8fc2455961bc9b Mon Sep 17 00:00:00 2001 From: Santiago Delgado Date: Thu, 20 Nov 2025 15:57:31 -0500 Subject: [PATCH 2/4] Added subscriptions to Account and example --- O365/account.py | 17 +-- O365/subscriptions.py | 190 ++++++++++++++++++++++++++++++ examples/subscriptions_example.py | 114 ++++++++++++++++++ 3 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 O365/subscriptions.py create mode 100644 examples/subscriptions_example.py diff --git a/O365/account.py b/O365/account.py index aaacf18d..27840f3c 100644 --- a/O365/account.py +++ b/O365/account.py @@ -2,16 +2,18 @@ from typing import Callable, List, Optional, Tuple, Type from .connection import Connection, MSGraphProtocol, Protocol +from .subscriptions import Subscriptions from .utils import ME_RESOURCE, consent_input_token -class Account: - connection_constructor: Type = Connection #: :meta private: - - def __init__(self, credentials: Tuple[str, str], *, - username: Optional[str] = None, - protocol: Optional[Protocol] = None, - main_resource: Optional[str] = None, **kwargs): +class Account: + connection_constructor: Type = Connection #: :meta private: + subscriptions_constructor: Type = Subscriptions + + def __init__(self, credentials: Tuple[str, str], *, + username: Optional[str] = None, + protocol: Optional[Protocol] = None, + main_resource: Optional[str] = None, **kwargs): """ Creates an object which is used to access resources related to the specified credentials. :param credentials: a tuple containing the client_id and client_secret @@ -60,6 +62,7 @@ def __init__(self, credentials: Tuple[str, str], *, self.con = self.connection_constructor(credentials, **kwargs) #: The resource in use for the account. |br| **Type:** str self.main_resource: str = main_resource or self.protocol.default_resource + self.subscriptions = self.subscriptions_constructor(parent=self) def __repr__(self): if self.con.auth: diff --git a/O365/subscriptions.py b/O365/subscriptions.py new file mode 100644 index 00000000..8976207a --- /dev/null +++ b/O365/subscriptions.py @@ -0,0 +1,190 @@ +import datetime as dt +from typing import Iterable, Mapping, Optional, Union + +from .utils import ApiComponent + + +class Subscriptions(ApiComponent): + """Subscription operations for Microsoft Graph webhooks.""" + + _endpoints = { + "subscriptions": "/subscriptions", + } + + def __init__(self, *, parent=None, con=None, **kwargs): + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + def _build_subscription_url(self, subscription_id: Optional[str] = None) -> str: + """Build the Microsoft Graph subscriptions endpoint.""" + endpoint = self._endpoints.get("subscriptions") + if endpoint is None: + raise ValueError("Subscriptions endpoint is not configured.") + base_url = self.protocol.service_url.rstrip("/") + if subscription_id: + return f"{base_url}{endpoint}/{subscription_id}" + return f"{base_url}{endpoint}" + + @staticmethod + def _format_subscription_expiration( + expiration_datetime: Optional[dt.datetime] = None, + expiration_minutes: Optional[int] = None, + ) -> str: + """Return an ISO 8601 UTC expiration string as required by Graph webhooks.""" + if expiration_datetime and expiration_minutes is not None: + raise ValueError( + "Provide either expiration_datetime or expiration_minutes, not both." + ) + if expiration_datetime is None: + minutes = expiration_minutes if expiration_minutes is not None else 60 + if minutes <= 0: + raise ValueError("expiration_minutes must be a positive integer.") + expiration_datetime = dt.datetime.now(dt.timezone.utc) + dt.timedelta( + minutes=minutes + ) + else: + if expiration_datetime.tzinfo is None: + expiration_datetime = expiration_datetime.replace(tzinfo=dt.timezone.utc) + else: + expiration_datetime = expiration_datetime.astimezone(dt.timezone.utc) + return expiration_datetime.isoformat(timespec="microseconds").replace("+00:00", "Z") + + @staticmethod + def _stringify_change_type(change_type: Union[str, Iterable[str]]) -> str: + """Normalize changeType into the comma-separated string Graph expects.""" + if isinstance(change_type, str): + value = change_type.strip() + else: + try: + parts = [str(part).strip() for part in change_type] + except TypeError as exc: + raise ValueError( + "change_type must be a string or an iterable of strings." + ) from exc + value = ",".join(part for part in parts if part) + if not value: + raise ValueError("change_type must contain at least one value.") + return value + + def create_subscription( + self, + notification_url: str, + resource: Optional[str] = None, + change_type: Union[str, Iterable[str]] = "created", + *, + expiration_datetime: Optional[dt.datetime] = None, + expiration_minutes: Optional[int] = None, + client_state: Optional[str] = None, + include_resource_data: Optional[bool] = None, + encryption_certificate: Optional[str] = None, + encryption_certificate_id: Optional[str] = None, + lifecycle_notification_url: Optional[str] = None, + latest_supported_tls_version: Optional[str] = None, + additional_data: Optional[Mapping[str, object]] = None, + **request_kwargs, + ) -> Optional[dict]: + """Create a Microsoft Graph webhook subscription. + + See Documentation.md for webhook setup requirements. + """ + if not notification_url: + raise ValueError("notification_url must be provided.") + + resource = resource or self.main_resource + if not resource: + raise ValueError("resource must be provided.") + if not resource.startswith("/"): + resource = f"/{resource}" + + expiration_value = self._format_subscription_expiration( + expiration_datetime=expiration_datetime, + expiration_minutes=expiration_minutes, + ) + change_type_value = self._stringify_change_type(change_type) + + payload = { + self._cc("change_type"): change_type_value, + self._cc("notification_url"): notification_url, + self._cc("resource"): resource, + self._cc("expiration_date_time"): expiration_value, + } + + if client_state is not None: + payload[self._cc("client_state")] = client_state + if include_resource_data is not None: + payload[self._cc("include_resource_data")] = include_resource_data + if encryption_certificate is not None: + payload[self._cc("encryption_certificate")] = encryption_certificate + if encryption_certificate_id is not None: + payload[self._cc("encryption_certificate_id")] = encryption_certificate_id + if lifecycle_notification_url is not None: + payload[self._cc("lifecycle_notification_url")] = lifecycle_notification_url + if latest_supported_tls_version is not None: + payload[ + self._cc("latest_supported_tls_version") + ] = latest_supported_tls_version + if additional_data: + if not isinstance(additional_data, Mapping): + raise ValueError("additional_data must be a mapping if provided.") + payload.update({str(key): value for key, value in additional_data.items()}) + + url = self._build_subscription_url() + response = self.con.post(url, data=payload, **request_kwargs) + + if not response: + return None + + return response.json() + + def renew_subscription( + self, + subscription_id: str, + *, + expiration_datetime: Optional[dt.datetime] = None, + expiration_minutes: Optional[int] = None, + **request_kwargs, + ) -> Optional[dict]: + """Renew an existing webhook subscription.""" + if not subscription_id: + raise ValueError("subscription_id must be provided.") + + expiration_value = self._format_subscription_expiration( + expiration_datetime=expiration_datetime, + expiration_minutes=expiration_minutes, + ) + + payload = { + self._cc("expiration_date_time"): expiration_value, + } + + url = self._build_subscription_url(subscription_id) + response = self.con.patch(url, data=payload, **request_kwargs) + + if not response: + return None + + return response.json() + + def delete_subscription( + self, + subscription_id: str, + **request_kwargs, + ) -> bool: + """Delete an existing webhook subscription.""" + if not subscription_id: + raise ValueError("subscription_id must be provided.") + + url = self._build_subscription_url(subscription_id) + response = self.con.delete(url, **request_kwargs) + + return bool(response) diff --git a/examples/subscriptions_example.py b/examples/subscriptions_example.py new file mode 100644 index 00000000..43380ac6 --- /dev/null +++ b/examples/subscriptions_example.py @@ -0,0 +1,114 @@ + +""" Example on how to use and setup webhooks + +Quickstart for this example: +1) Run Flask locally withg the following command: + - flask --app examples/subscription_account_webhook.py run --debug +2) Expose HTTPS via a tunnel to your localhost:5000: + - Free: pinggy (https://pinggy.io/) to get https://.pinggy.link -> http://localhost:5000 + - Paid/free-tier: ngrok (https://ngrok.com/): ngrok http 5000, note the https URL. +3) Use the tunnel HTTPS URL as notification_url pointing to /webhook, URL-encoded. +4) To create a subscription, follow the example request below: + - https:///subscriptions?notification_url=https%3A%2F%2F%2Fwebhook&client_state=abc123 +4) To renew a subscription, follow the example request below: + - http:///subscriptions//renew?expiration_minutes=55 +5) To delete a subscription, follow the example request below: + - http:///subscriptions//delete +Graph will call https:///webhook; this app echoes validationToken and returns 202 for notifications. +""" + +from flask import Flask, abort, jsonify, request +from O365 import Account + +CLIENT_ID = "YOUR CLIENT ID" +CLIENT_SECRET = "YOUR CLIENT SECRET" +credentials = (CLIENT_ID, CLIENT_SECRET) + +account = Account(credentials) +# Pick the scopes that are relevant to you here +account.authenticate( + scopes=[ + "https://graph.microsoft.com/Mail.ReadWrite", + "https://graph.microsoft.com/Mail.Send", + "https://graph.microsoft.com/Calendars.ReadWrite", + "https://graph.microsoft.com/MailboxSettings.ReadWrite", + "https://graph.microsoft.com/User.Read", + "https://graph.microsoft.com/User.ReadBasic.All", + 'offline_access' + ]) + +RESOURCE = "/me/mailFolders('inbox')/messages" +DEFAULT_EXPIRATION_MINUTES = 55 # Graph requires renewals before the limit + +app = Flask(__name__) + + +def _int_arg(name: str, default: int) -> int: + raw = request.args.get(name) + if raw is None: + return default + try: + return int(raw) + except ValueError: + abort(400, description=f"{name} must be an integer") + + +@app.get("/subscriptions") +def create_subscription(): + notification_url = request.args.get("notification_url") + if not notification_url: + abort(400, description="notification_url is required") + + expiration_minutes = _int_arg("expiration_minutes", DEFAULT_EXPIRATION_MINUTES) + client_state = request.args.get("client_state") + resource = request.args.get("resource", RESOURCE) + + subscription = account.subscriptions.create_subscription( + notification_url=notification_url, + resource=resource, + change_type="created", + expiration_minutes=expiration_minutes, + client_state=client_state, + ) + return jsonify(subscription), 201 + + +@app.get("/subscriptions//renew") +def renew_subscription(subscription_id: str): + expiration_minutes = _int_arg("expiration_minutes", DEFAULT_EXPIRATION_MINUTES) + updated = account.subscriptions.renew_subscription( + subscription_id, + expiration_minutes=expiration_minutes, + ) + return jsonify(updated), 200 + + +@app.get("/subscriptions//delete") +def delete_subscription(subscription_id: str): + deleted = account.subscriptions.delete_subscription(subscription_id) + if not deleted: + abort(404, description="Subscription not found") + return ("", 204) + + +@app.post("/webhook") +def webhook_handler(): + """Handle Microsoft Graph webhook calls. + + - During subscription validation, Graph sends POST with ?validationToken=... . + We must echo the token as plain text within 10 seconds. + - For change notifications, Graph posts JSON; we just log/ack. + """ + validation_token = request.args.get("validationToken") + if validation_token: + # Echo back token exactly as plain text with HTTP 200. + return validation_token, 200, {"Content-Type": "text/plain"} + + # Change notifications: inspect or log as needed. + payload = request.get_json(silent=True) or {} + print("Received notification payload:", payload) + return ("", 202) + + +if __name__ == "__main__": + app.run(debug=True, ssl_context=("examples/cert.pem", "examples/key.pem")) From 713479629029e6106a9a5a6e98f03a9ef22bc190 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 20 Nov 2025 20:58:02 +0000 Subject: [PATCH 3/4] Update the docs --- docs/latest/api/account.html | 7 +++++++ docs/latest/genindex.html | 6 ++++-- docs/latest/objects.inv | Bin 10903 -> 10921 bytes docs/latest/searchindex.js | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/latest/api/account.html b/docs/latest/api/account.html index ea13e072..2972f1f4 100644 --- a/docs/latest/api/account.html +++ b/docs/latest/api/account.html @@ -52,6 +52,7 @@
  • O365 API
    • Account
      • Account - +
      • +
      • subscriptions_constructor (O365.account.Account attribute) +
      • summary (O365.teams.ChatMessage attribute)
      • surname (O365.address_book.Contact property) diff --git a/docs/latest/objects.inv b/docs/latest/objects.inv index e89d1321fffed6157daeb7a07e22166cf63be4a0..008f5ac8bbc08ac82d8f7db93e8342d40c7ac0aa 100644 GIT binary patch delta 10567 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 VL1#Dny-@$=TsqHF`(GH-4UX_wWf%Ye delta 10549 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 Date: Thu, 20 Nov 2025 16:01:41 -0500 Subject: [PATCH 4/4] Reverting mailbox.py to current state --- O365/mailbox.py | 62 +------------------------------------------------ 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/O365/mailbox.py b/O365/mailbox.py index 6a94fc56..ed679c5e 100644 --- a/O365/mailbox.py +++ b/O365/mailbox.py @@ -263,7 +263,6 @@ class Folder(ApiComponent): "copy_folder": "/mailFolders/{id}/copy", "move_folder": "/mailFolders/{id}/move", "message": "/messages/{id}", - "subscriptions": "/subscriptions", } message_constructor = Message #: :meta private: @@ -1070,63 +1069,4 @@ def get_settings(self): return self.mailbox_settings_constructor( parent=self, **{self._cloud_data_key: data} - ) - - ''' - def set_email_subscription(self, notificationurl, minutes): - """Set a webhook subscription to send post request every - time an email is received - - :return: response from the connection - :rtype: connection - """ - - # Get the current UTC time - now_utc = dr.datetime.utcnow() - - # Calculate the future time by adding 4200 minutes - future_utc = now_utc + dt.timedelta(minutes=minutes) - - # Format with 7 decimal places and append 'Z' to indicate UTC - # '%f' gives 6 digits for microseconds; we add an extra '0' for 7 decimal places. - expiration_str = future_utc.strftime("%Y-%m-%dT%H:%M:%S.%f0Z") - - url = "https://graph.microsoft.com/v1.0/subscriptions" - - params = { - "changeType": "created,updated", - "notificationUrl": notificationurl, - "resource": "/me/mailfolders('inbox')/messages", - "expirationDateTime": expiration_str, - } - - response = self.con.get(url, params=my_params) - - - def renew_email_subscription(self, id, minutes): - """Renew a webhook subscription to send post request every - time an email is received - - :return: response from the connection - :rtype: connection - """ - - # Get the current UTC time - now_utc = dr.datetime.utcnow() - - # Calculate the future time by adding 4200 minutes - future_utc = now_utc + dt.timedelta(minutes=minutes) - - # Format with 7 decimal places and append 'Z' to indicate UTC - # '%f' gives 6 digits for microseconds; we add an extra '0' for 7 decimal places. - expiration_str = future_utc.strftime("%Y-%m-%dT%H:%M:%S.%f0Z") - - url = f'https://graph.microsoft.com/v1.0/subscriptions/{id}' - - params = { - "expirationDateTime": expiration_str, - } - - response = self.con.get(url, params=my_params) - - ''' + ) \ No newline at end of file