From 104f61691f23cb97434cdf25b21f6f042aaa3aed Mon Sep 17 00:00:00 2001 From: RogerSelwyn Date: Mon, 29 Dec 2025 16:18:47 +0000 Subject: [PATCH 1/3] Basic docs for subscriptions --- docs/source/api.rst | 1 + docs/source/api/subscriptions.rst | 10 ++ docs/source/conf.py | 2 +- docs/source/usage.rst | 1 + docs/source/usage/subscriptions.rst | 154 ++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 docs/source/api/subscriptions.rst create mode 100644 docs/source/usage/subscriptions.rst diff --git a/docs/source/api.rst b/docs/source/api.rst index 2c04d950..d558897d 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -19,6 +19,7 @@ O365 API api/onedrive api/planner api/sharepoint + api/subscriptions api/tasks api/teams api/utils diff --git a/docs/source/api/subscriptions.rst b/docs/source/api/subscriptions.rst new file mode 100644 index 00000000..c5e6eb73 --- /dev/null +++ b/docs/source/api/subscriptions.rst @@ -0,0 +1,10 @@ +Subscriptions +------------- + +.. include:: global.rst + +.. automodule:: O365.subscriptions + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 43803583..cc650469 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -55,7 +55,7 @@ # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = ".rst" +source_suffix = {".rst": "restructuredtext"} # The master toctree document. master_doc = "index" diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 2471c2f2..aaba6b9d 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -17,6 +17,7 @@ Detailed Usage usage/onedrive usage/planner usage/sharepoint + usage/subscriptions usage/tasks usage/teams usage/utils diff --git a/docs/source/usage/subscriptions.rst b/docs/source/usage/subscriptions.rst new file mode 100644 index 00000000..53bd19be --- /dev/null +++ b/docs/source/usage/subscriptions.rst @@ -0,0 +1,154 @@ +Subscriptions +============= + +Subscriptions provides the ability to create and manage webhook subscriptions against Microsoft Graph. Read here for more details on MS Graph subscriptions + +- https://learn.microsoft.com/en-us/graph/api/resources/subscription?view=graph-rest-1.0 +- https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks?tabs=http + + +**Example on how to use and setup webhooks** + +Quickstart for this example: + +#. Run Flask locally with the following command: + + - ``flask --app examples/subscriptions_example.py run --debug`` + +#. Expose HTTPS via a tunnel to your localhost:5000: + + - Free: `pinggy `_ to get ``https://.pinggy.link`` -> http://localhost:5000 + - Paid/free-tier: `ngrok `_: ngrok http 5000, note the https URL. + +#. Use the tunnel HTTPS URL as notification_url pointing to /webhook, URL-encoded. +#. To create a subscription, follow the example request below: + + - ``https:///subscriptions?notification_url=https%3A%2F%2F%2Fwebhook&client_state=abc123`` + +#. To list subscriptions, follow the example request below: + + - ``http:///subscriptions/list`` + +#. To renew a subscription, follow the example request below: + + - ``http:///subscriptions//renew?expiration_minutes=55`` + +#. 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. + +.. code-block:: python + + 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 = 10069 # Maximum expiration is 10,070 in the future. + + 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/list") + def list_subscriptions(): + limit_raw = request.args.get("limit") + limit = None + if limit_raw is not None: + try: + limit = int(limit_raw) + except ValueError: + abort(400, description="limit must be an integer") + if limit <= 0: + abort(400, description="limit must be a positive integer") + + subscriptions = account.subscriptions().list_subscriptions(limit=limit) + return jsonify(list(subscriptions)), 200 + + + @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 69182e40d52ce47f7dc7a42425650e2c7aafb16d Mon Sep 17 00:00:00 2001 From: RogerSelwyn Date: Mon, 29 Dec 2025 16:30:27 +0000 Subject: [PATCH 2/3] Change to reference usage documents since Documentation.md does not exist --- O365/subscriptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/O365/subscriptions.py b/O365/subscriptions.py index 9afd493f..d587bcbf 100644 --- a/O365/subscriptions.py +++ b/O365/subscriptions.py @@ -116,7 +116,7 @@ def create_subscription( ) -> Optional[dict]: """Create a Microsoft Graph webhook subscription. - See Documentation.md for webhook setup requirements. + See subscriptions usage documentation for webhook setup requirements. """ if not notification_url: raise ValueError("notification_url must be provided.") From bbdcc9c18561536f5df455c2d0741c0df67d7224 Mon Sep 17 00:00:00 2001 From: RogerSelwyn Date: Wed, 31 Dec 2025 12:25:33 +0000 Subject: [PATCH 3/3] Rewritten to match the style of other docs pages --- docs/source/usage/subscriptions.rst | 246 ++++++++++++++++++---------- pyproject.toml | 1 + 2 files changed, 156 insertions(+), 91 deletions(-) diff --git a/docs/source/usage/subscriptions.rst b/docs/source/usage/subscriptions.rst index 53bd19be..5f507032 100644 --- a/docs/source/usage/subscriptions.rst +++ b/docs/source/usage/subscriptions.rst @@ -1,89 +1,33 @@ Subscriptions ============= -Subscriptions provides the ability to create and manage webhook subscriptions against Microsoft Graph. Read here for more details on MS Graph subscriptions +Subscriptions provides the ability to create and manage webhook subscriptions for change notifications against Microsoft Graph. Read here for more details on MS Graph subscriptions - https://learn.microsoft.com/en-us/graph/api/resources/subscription?view=graph-rest-1.0 - https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks?tabs=http +Create a Subscription +^^^^^^^^^^^^^^^^^^^^^ -**Example on how to use and setup webhooks** - -Quickstart for this example: - -#. Run Flask locally with the following command: - - - ``flask --app examples/subscriptions_example.py run --debug`` - -#. Expose HTTPS via a tunnel to your localhost:5000: - - - Free: `pinggy `_ to get ``https://.pinggy.link`` -> http://localhost:5000 - - Paid/free-tier: `ngrok `_: ngrok http 5000, note the https URL. - -#. Use the tunnel HTTPS URL as notification_url pointing to /webhook, URL-encoded. -#. To create a subscription, follow the example request below: - - - ``https:///subscriptions?notification_url=https%3A%2F%2F%2Fwebhook&client_state=abc123`` - -#. To list subscriptions, follow the example request below: - - - ``http:///subscriptions/list`` - -#. To renew a subscription, follow the example request below: - - - ``http:///subscriptions//renew?expiration_minutes=55`` - -#. 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. +Assuming a web host (example uses `flask`) and an authenticated account, create a subscription to be notified about new emails. .. code-block:: python 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 = 10069 # Maximum expiration is 10,070 in the future. + DEFAULT_EXPIRATION_MINUTES = 10069 # Maximum expiration is 10,070 in the future for Outlook message. 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(): + """Create a 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) + expiration_minutes = int(request.args.get("expiration_minutes", DEFAULT_EXPIRATION_MINUTES)) client_state = request.args.get("client_state") resource = request.args.get("resource", RESOURCE) @@ -96,59 +40,179 @@ Graph will call ``https:///webhook``; this app echoes validati ) return jsonify(subscription), 201 + @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) + +Use this url: + + ``https:///subscriptions?notification_url=https%3A%2F%2F%2Fwebhook&client_state=abc123`` + +HTTP status 201 and the following should be returned: + +.. code-block:: JSON + + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity", + "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8", + "changeType": "created", + "clientState": "abc123", + "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66", + "encryptionCertificate": null, + "encryptionCertificateId": null, + "expirationDateTime": "2026-01-07T11:20:42.305776Z", + "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1", + "includeResourceData": null, + "latestSupportedTlsVersion": "v1_2", + "lifecycleNotificationUrl": null, + "notificationQueryOptions": null, + "notificationUrl": "https:///webhook", + "notificationUrlAppId": null, + "resource": "/me/mailFolders('inbox')/messages" + } + +List Subscriptions +^^^^^^^^^^^^^^^^^^ + +.. code-block:: python @app.get("/subscriptions/list") def list_subscriptions(): - limit_raw = request.args.get("limit") - limit = None - if limit_raw is not None: - try: - limit = int(limit_raw) - except ValueError: - abort(400, description="limit must be an integer") - if limit <= 0: - abort(400, description="limit must be a positive integer") - + """List all subscriptions.""" + limit = int(request.args.get("limit")) subscriptions = account.subscriptions().list_subscriptions(limit=limit) return jsonify(list(subscriptions)), 200 +Use this url: + + ``https:///subscriptions/list`` + +HTTP status 200 and the following should be returned: + +.. code-block:: JSON + + [ + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity", + "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8", + "changeType": "created", + "clientState": "abc123", + "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66", + "encryptionCertificate": null, + "encryptionCertificateId": null, + "expirationDateTime": "2026-01-07T11:20:42.305776Z", + "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1", + "includeResourceData": null, + "latestSupportedTlsVersion": "v1_2", + "lifecycleNotificationUrl": null, + "notificationQueryOptions": null, + "notificationUrl": "https:///webhook", + "notificationUrlAppId": null, + "resource": "/me/mailFolders('inbox')/messages" + } + ] + +Renew a Subscription +^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python @app.get("/subscriptions//renew") def renew_subscription(subscription_id: str): - expiration_minutes = _int_arg("expiration_minutes", DEFAULT_EXPIRATION_MINUTES) + """Renew a subscription.""" + expiration_minutes = int(request.args.get("expiration_minutes", DEFAULT_EXPIRATION_MINUTES)) updated = account.subscriptions().renew_subscription( subscription_id, expiration_minutes=expiration_minutes, ) return jsonify(updated), 200 +Use this url: + + ``http:///subscriptions/548355f8-c2c0-47ae-aac7-3ad02b2dfdb1/renew?expiration_minutes=10069`` + +HTTP status 200 and the following should be returned: + +.. code-block:: JSON + + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity", + "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8", + "changeType": "created", + "clientState": "abc123", + "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66", + "encryptionCertificate": null, + "encryptionCertificateId": null, + "expirationDateTime": "2026-01-07T11:35:40.301594Z", + "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1", + "includeResourceData": null, + "latestSupportedTlsVersion": "v1_2", + "lifecycleNotificationUrl": null, + "notificationQueryOptions": null, + "notificationUrl": "https:///webhook", + "notificationUrlAppId": null, + "resource": "/me/mailFolders('inbox')/messages" + } + +Delete a Subscription +^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python @app.get("/subscriptions//delete") def delete_subscription(subscription_id: str): + """Delete a subscription.""" deleted = account.subscriptions().delete_subscription(subscription_id) if not deleted: abort(404, description="Subscription not found") return ("", 204) +Use this url: - @app.post("/webhook") - def webhook_handler(): - """Handle Microsoft Graph webhook calls. + ``http:///subscriptions/548355f8-c2c0-47ae-aac7-3ad02b2dfdb1/delete`` - - 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"} +HTTP status 204 should be returned. - # Change notifications: inspect or log as needed. - payload = request.get_json(silent=True) or {} - print("Received notification payload:", payload) - return ("", 202) +Webhook +^^^^^^^ +With a subscription as described above and an email sent to the inbox, a webhook will be received as below: + +.. code-block:: python - if __name__ == "__main__": - app.run(debug=True, ssl_context=("examples/cert.pem", "examples/key.pem")) + { + 'value': [ + { + 'subscriptionId': '548355f8-c2c0-47ae-aac7-3ad02b2dfdb12', + 'subscriptionExpirationDateTime': '2026-01-07T11:35:40.301594+00:00', + 'changeType': 'created', + 'resource': 'Users/12345678-a5c7-46da-8107-b25090a1ed66/Messages/=', + 'resourceData': { + '@odata.type': '#Microsoft.Graph.Message', + '@odata.id': 'Users/12345678-a5c7-46da-8107-b25090a1ed66/Messages/=', + '@odata.etag': 'W/"CQAAABYACCCoiRErLbiNRJDCFyMjq4khBBnH4N7A"', + 'id': '=' + }, + 'clientState': 'abc123', + 'tenantId': '12345678-abcd-1234-abcd-1234567890ab' + } + ] + } + +The client state should be validated for accuracy and if correct, the message can be acted upon as approriate for the type of subscription. + +An example application can be found in the examples directory here - https://github.com/O365/python-o365/blob/master/examples/subscriptions_example.py \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0e21b437..4fe49990 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dev = [ "pytest>=8.3.4", "sphinx>=7.4.7", "sphinx-rtd-theme>=3.0.2", + "flask" ] [build-system]