diff --git a/README.md b/README.md index e86848c..efaefde 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ MSGRAPH_TENANT_ID = "..." MSGRAPH_CLIENT_ID = "..." MSGRAPH_CLIENT_SECRET = "..." MSGRAPH_USER_ID = "..." # Optional +MSGRAPH_USE_JSON_API = "..." # Optional ``` The `MSGRAPH_USER_ID` is optional and not needed if you follow the instructions in section [Microsoft Entra](#microsoft-entra) @@ -98,6 +99,7 @@ The *Microsoft Graph Backend for Django* requires the following settings: | MSGRAPH_CLIENT_ID | Yes | The [application (client) ID](https://learn.microsoft.com/en-us/graph/auth-register-app-v2) of your Microsoft Entra app. | | MSGRAPH_CLIENT_SECRET | Yes | The secret to your Microsoft Entra application. | | MSGRAPH_USER_ID | No | If you grant your application the `User.Read.All` application permission, this setting is not required. | +| MSGRAPH_USE_JSON_API | No | Enables mapping the original MIME EmailMessage to Microsoft Graph-typical JSON format. It works better if EmailMultiAlternatives is used as basis for email message sent using the EmailBackend | @@ -170,4 +172,5 @@ This mode requires setting `MSGRAPH_USER_ID` to the user id of your selected mai ## Notes -The *Microsoft Graph Backend for Django* sends email not in the Microsoft Graph-typical JSON, but in the MIME format. This is due to how `django.core.mail.message.EmailMessage` internally works. Its `message()` method returns the email in MIME format. Rather than writing a custom converter from MIME to JSON, that could introduce additional bugs, the format is left unchanged. The Microsoft's own [Graph SDK for Python](https://github.com/microsoftgraph/msgraph-sdk-python) does not support sending emails in MIME format. \ No newline at end of file +The *Microsoft Graph Backend for Django* as default sends email not in the Microsoft Graph-typical JSON, but in the MIME format. This is due to how `django.core.mail.message.EmailMessage` internally works. Its `message()` method returns the email in MIME format. The Microsoft's own [Graph SDK for Python](https://github.com/microsoftgraph/msgraph-sdk-python) does not support sending emails in MIME format. +You can choose to use the custom converter from MIME to JSON, which could introduce additional bugs, by setting MSGRAPH_USE_JSON_API to True. \ No newline at end of file diff --git a/src/msgraphbackend/__init__.py b/src/msgraphbackend/__init__.py index 53e8aac..2455f93 100644 --- a/src/msgraphbackend/__init__.py +++ b/src/msgraphbackend/__init__.py @@ -4,6 +4,7 @@ import json import time import typing +import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass @@ -11,6 +12,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.core.mail.backends.base import BaseEmailBackend +from django.core.mail.message import EmailMultiAlternatives if typing.TYPE_CHECKING: from django.core.mail.message import EmailMessage @@ -45,6 +47,7 @@ def __init__( client_id=None, client_secret=None, user_id=None, + use_json_api=False, fail_silently=False, **kwargs, ) -> None: @@ -59,6 +62,7 @@ def __init__( self.client_id = client_id or settings.MSGRAPH_CLIENT_ID self.client_secret = client_secret or settings.MSGRAPH_CLIENT_SECRET self.user_id = getattr(settings, "MSGRAPH_USER_ID", user_id) + self.use_json_api = getattr(settings, "MSGRAPH_USE_JSON_API", use_json_api) self._token: None | MSGraphToken = None self.open() @@ -106,30 +110,119 @@ def _send(self, email_message: EmailMessage) -> bool: return False user_id = self.user_id or self._get_user(email_message.from_email) url = f"https://graph.microsoft.com/v1.0/users/{user_id}/sendMail" - message = base64.b64encode(email_message.message().as_bytes()) - headers = { - "Content-Type": "text/plain", - "Authorization": self._token.authorization_value, - } - request = urllib.request.Request(url, data=message, headers=headers) + headers = self._prepare_headers() + data = self._prepare_request_payload(email_message) + request = urllib.request.Request(url, data=data, headers=headers) try: urllib.request.urlopen(request) except urllib.error.HTTPError as err: if self.fail_silently: return False - error_details = json.load(err) - code = error_details["error"]["code"] - message = error_details["error"]["message"] - err.add_note(f"{code}: {message}") - raise + # Error handling for Graph API responses + response_body = err.read().decode('utf-8') + try: + error_details = json.loads(response_body) + code = error_details.get("error", {}).get("code", "UNKNOWN_CODE") + message = error_details.get("error", {}).get("message", "UNKNOWN_MESSAGE") + err.add_note(f"Graph API Error: {code}: {message}") + except json.JSONDecodeError: + err.add_note(f"Graph API HTTP Error (Non-JSON Response): {response_body}") + raise err + return True + def _prepare_headers(self) -> dict: + """Prepare the headers for the request.""" + if not self._token: + raise ValueError("The Microsoft Graph token is not set.") + headers = { + "Authorization": self._token.authorization_value, + } + if self.use_json_api: + headers["Content-Type"] = "application/json" + else: + headers["Content-Type"] = "text/plain" + return headers + + def _prepare_request_payload(self, email_message: EmailMessage) -> bytes: + """ + Prepare the payload for the sendMail request. + If use_json_api is True, it will convert the EmailMessage to a JSON format + suitable for Microsoft Graph API. Otherwise, it will return the raw MIME + message as a byte string. + """ + if not self.use_json_api: + # If not using JSON API, return the raw MIME message + return base64.b64encode(email_message.message().as_bytes()) + + # Build the message payload for Graph API + message_payload = { + "subject": email_message.subject, + "toRecipients": [{"emailAddress": {"address": recipient}} for recipient in email_message.to], + "from": {"emailAddress": {"address": email_message.from_email}}, + "body": {}, + "attachments": [], + } + + # Handle body content (plain text and HTML) + # EmailMultiAlternatives stores the plain text in .body and HTML in .alternatives + # Graph API prefers HTML if both are present + html_content = None + if isinstance(email_message, EmailMultiAlternatives): + for alt_content, alt_mimetype in email_message.alternatives: + if alt_mimetype == 'text/html': + html_content = alt_content + break + + if html_content: + message_payload["body"] = { + "contentType": "html", + "content": html_content + } + else: + message_payload["body"] = { + "contentType": "text", + "content": email_message.body + } + + # Handle CC recipients + if email_message.cc: + message_payload["ccRecipients"] = [{"emailAddress": {"address": cc}} for cc in email_message.cc] + + # Handle BCC recipients + if email_message.bcc: + message_payload["bccRecipients"] = [{"emailAddress": {"address": bcc}} for bcc in email_message.bcc] + + # Handle attachments + for attachment in email_message.attachments: + if isinstance(attachment, tuple): + filename, content, mimetype = attachment + # Graph API expects contentBytes to be base64 encoded + encoded_content = base64.b64encode(content).decode('utf-8') + message_payload["attachments"].append({ + "@odata.type": "#microsoft.graph.fileAttachment", + "name": filename, + "contentType": mimetype, + "contentBytes": encoded_content + }) + # Handle here other attachment types if needed (e.g., Django's File objects) + + # Set the overall payload for the sendMail endpoint + send_mail_payload = { + "message": message_payload, + "saveToSentItems": "true" # Save a copy to the sender's Sent Items folder + } + + return json.dumps(send_mail_payload).encode('utf-8') + def _get_user(self, from_address: str) -> str: """Gets the user id who is assigned the from_address.""" url = ( "https://graph.microsoft.com/v1.0/users" f"?$filter=proxyAddresses/any(x:x%20eq%20'smtp:{from_address}')&$select=id" ) + if not self._token: + raise ValueError("The Microsoft Graph token is not set.") headers = { "Authorization": f"{self._token.authorization_value}", }