Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |



Expand Down Expand Up @@ -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.
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.
115 changes: 104 additions & 11 deletions src/msgraphbackend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import json
import time
import typing
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass

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
Expand Down Expand Up @@ -45,6 +47,7 @@ def __init__(
client_id=None,
client_secret=None,
user_id=None,
use_json_api=False,
fail_silently=False,
**kwargs,
) -> None:
Expand All @@ -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()

Expand Down Expand Up @@ -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}",
}
Expand Down