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.") 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..5f507032 --- /dev/null +++ b/docs/source/usage/subscriptions.rst @@ -0,0 +1,218 @@ +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 +^^^^^^^^^^^^^^^^^^^^^ + +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 + + RESOURCE = "/me/mailFolders('inbox')/messages" + DEFAULT_EXPIRATION_MINUTES = 10069 # Maximum expiration is 10,070 in the future for Outlook message. + + app = Flask(__name__) + + @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(request.args.get("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.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(): + """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): + """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: + + ``http:///subscriptions/548355f8-c2c0-47ae-aac7-3ad02b2dfdb1/delete`` + +HTTP status 204 should be returned. + +Webhook +^^^^^^^ + +With a subscription as described above and an email sent to the inbox, a webhook will be received as below: + +.. code-block:: python + + { + '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]