-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add a Microsoft 365 Graph Email Backend #14314
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| # -*- coding: utf-8 -*- | ||
| ######################################################################### | ||
| # | ||
| # Copyright (C) 2026 Open Source Geospatial Foundation - all rights reserved | ||
| # | ||
| # This program is free software: you can redistribute it and/or modify | ||
| # it under the terms of the GNU General Public License as published by | ||
| # the Free Software Foundation, either version 3 of the License, or | ||
| # (at your option) any later version. | ||
| # | ||
| # This program is distributed in the hope that it will be useful, | ||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| # GNU General Public License for more details. | ||
| # | ||
| # You should have received a copy of the GNU General Public License | ||
| # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
| # | ||
| ######################################################################### | ||
| """Django email backend that sends mail through Microsoft 365. | ||
|
|
||
| Uses the Microsoft Graph ``sendMail`` API over the OAuth2 client-credentials | ||
| flow (application permissions) against an Azure AD app registration that holds | ||
| the ``Mail.Send`` application permission. The mailbox named by | ||
| ``MICROSOFT_GRAPH_API_CREDENTIALS['mail_from']`` is always used as the sender; | ||
| the display name shown to recipients comes from that mailbox's Azure AD | ||
| configuration. | ||
|
|
||
| Known limitations: ``EmailMultiAlternatives`` (HTML + plain-text bodies) is sent | ||
| as a single body using ``content_subtype``; attachments and ``extra_headers`` | ||
| are not forwarded to Graph. | ||
| """ | ||
|
|
||
| import logging | ||
|
|
||
| import msal | ||
| import requests | ||
| from django.conf import settings | ||
| from django.core.exceptions import ImproperlyConfigured | ||
| from django.core.mail.backends.base import BaseEmailBackend | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| GRAPH_TOKEN_SCOPE = ["https://graph.microsoft.com/.default"] | ||
| GRAPH_SENDMAIL_ENDPOINT = "https://graph.microsoft.com/v1.0/users/{mailbox}/sendMail" | ||
| GRAPH_REQUEST_TIMEOUT = 30 # seconds; requests.post would otherwise wait forever | ||
|
|
||
|
|
||
| class MicrosoftGraphEmailBackend(BaseEmailBackend): | ||
| """ | ||
| Send Django EmailMessages through Microsoft 365 via the Graph API. | ||
| """ | ||
|
|
||
| def __init__(self, fail_silently=False, **kwargs): | ||
| super().__init__(fail_silently=fail_silently, **kwargs) | ||
| self._msal_app = None | ||
|
|
||
| def _get_access_token(self): | ||
| """ | ||
| Return ``(creds, token)`` or raise. Validates settings and acquires a Graph token. | ||
| """ | ||
| creds = getattr(settings, "MICROSOFT_GRAPH_API_CREDENTIALS", {}) or {} | ||
| missing = [k for k in ("tenant_id", "client_id", "client_secret", "mail_from") if not creds.get(k)] | ||
| if missing: | ||
| raise ImproperlyConfigured(f"MICROSOFT_GRAPH_API_CREDENTIALS is missing required keys: {missing}") | ||
|
|
||
| if self._msal_app is None: | ||
| self._msal_app = msal.ConfidentialClientApplication( | ||
| client_id=creds["client_id"], | ||
| client_credential=creds["client_secret"], | ||
| authority=f"https://login.microsoftonline.com/{creds['tenant_id']}", | ||
| token_cache=msal.SerializableTokenCache(), | ||
| ) | ||
|
|
||
| result = self._msal_app.acquire_token_silent( | ||
| GRAPH_TOKEN_SCOPE, account=None | ||
| ) or self._msal_app.acquire_token_for_client(scopes=GRAPH_TOKEN_SCOPE) | ||
| token = (result or {}).get("access_token") | ||
| if not token: | ||
| error = (result or {}).get("error_description", "no result from MSAL") | ||
| raise RuntimeError(f"Microsoft Graph token acquisition failed: {error}") | ||
| return creds, token | ||
|
|
||
| def send_messages(self, email_messages): | ||
| """ | ||
| Send multiple email messages using Microsoft Graph API. | ||
| """ | ||
| if not email_messages: | ||
| return 0 | ||
| try: | ||
| creds, token = self._get_access_token() | ||
| except Exception: | ||
| if not self.fail_silently: | ||
| raise | ||
| logger.exception("Microsoft Graph token acquisition failed") | ||
| return 0 | ||
|
|
||
| sent = 0 | ||
| for message in email_messages: | ||
| try: | ||
| self._send(message, token, creds) | ||
| sent += 1 | ||
| except Exception: | ||
| if not self.fail_silently: | ||
| raise | ||
| logger.exception("Microsoft Graph sendMail failed for %s", message.to) | ||
| return sent | ||
|
Comment on lines
+84
to
+107
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When sending multiple emails in a batch, establishing a new TCP and TLS connection for each request introduces significant latency. We can use a def send_messages(self, email_messages):
"""
Send multiple email messages using Microsoft Graph API.
"""
if not email_messages:
return 0
try:
creds, token = self._get_access_token()
except Exception:
if not self.fail_silently:
raise
logger.exception("Microsoft Graph token acquisition failed")
return 0
sent = 0
with requests.Session() as session:
for message in email_messages:
try:
self._send(message, token, creds, session=session)
sent += 1
except Exception:
if not self.fail_silently:
raise
logger.exception("Microsoft Graph sendMail failed for %s", message.to)
return sent |
||
|
|
||
| def _send(self, message, token, creds): | ||
| """ | ||
| Send a single email using Microsoft Graph API. | ||
| """ | ||
| content_type = "HTML" if getattr(message, "content_subtype", "plain") == "html" else "Text" | ||
| payload = { | ||
| "message": { | ||
| "subject": message.subject, | ||
| "body": {"contentType": content_type, "content": message.body}, | ||
| "toRecipients": [{"emailAddress": {"address": addr}} for addr in message.to], | ||
| "ccRecipients": [{"emailAddress": {"address": addr}} for addr in message.cc], | ||
| "bccRecipients": [{"emailAddress": {"address": addr}} for addr in message.bcc], | ||
| }, | ||
| "saveToSentItems": "true", | ||
| } | ||
| if message.reply_to: | ||
| payload["message"]["replyTo"] = [{"emailAddress": {"address": addr}} for addr in message.reply_to] | ||
|
|
||
| response = requests.post( | ||
| GRAPH_SENDMAIL_ENDPOINT.format(mailbox=creds["mail_from"]), | ||
| headers={"Authorization": f"Bearer {token}"}, | ||
| json=payload, | ||
| timeout=GRAPH_REQUEST_TIMEOUT, | ||
| ) | ||
| if not response.ok: | ||
| raise RuntimeError(f"Microsoft Graph sendMail returned HTTP {response.status_code}: {response.text}") | ||
| logger.info("Microsoft Graph email sent to %s", message.to) | ||
|
Comment on lines
+109
to
+135
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This suggestion enhances the
def _send(self, message, token, creds, session=None):
"""
Send a single email using Microsoft Graph API.
"""
content_type = "HTML" if getattr(message, "content_subtype", "plain") == "html" else "Text"
body_content = message.body
# Handle EmailMultiAlternatives by looking for an HTML alternative
if content_type == "Text" and hasattr(message, "alternatives"):
for alternative in message.alternatives:
if len(alternative) >= 2 and alternative[1] == "text/html":
body_content = alternative[0]
content_type = "HTML"
break
payload = {
"message": {
"subject": message.subject,
"body": {"contentType": content_type, "content": body_content},
"toRecipients": [{"emailAddress": {"address": addr}} for addr in message.to],
"ccRecipients": [{"emailAddress": {"address": addr}} for addr in message.cc],
"bccRecipients": [{"emailAddress": {"address": addr}} for addr in message.bcc],
},
"saveToSentItems": True,
}
if message.reply_to:
payload["message"]["replyTo"] = [{"emailAddress": {"address": addr}} for addr in message.reply_to]
# Handle extra headers
if getattr(message, "extra_headers", None):
payload["message"]["internetMessageHeaders"] = [
{"name": k, "value": str(v)} for k, v in message.extra_headers.items()
]
# Handle attachments
attachments = []
for attachment in getattr(message, "attachments", []):
if isinstance(attachment, tuple):
filename, content, mimetype = attachment
else:
filename = attachment.get_filename() or "attachment"
content = attachment.get_payload(decode=True)
mimetype = attachment.get_content_type()
if content is None:
continue
if isinstance(content, str):
content = content.encode("utf-8")
import base64
content_bytes = base64.b64encode(content).decode("utf-8")
attachments.append({
"@odata.type": "#microsoft.graph.fileAttachment",
"name": filename,
"contentType": mimetype,
"contentBytes": content_bytes,
})
if attachments:
payload["message"]["attachments"] = attachments
post_func = session.post if session else requests.post
response = post_func(
GRAPH_SENDMAIL_ENDPOINT.format(mailbox=creds["mail_from"]),
headers={"Authorization": f |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| # -*- coding: utf-8 -*- | ||
| ######################################################################### | ||
| # | ||
| # Copyright (C) 2026 Open Source Geospatial Foundation - all rights reserved | ||
| # | ||
| # This program is free software: you can redistribute it and/or modify | ||
| # it under the terms of the GNU General Public License as published by | ||
| # the Free Software Foundation, either version 3 of the License, or | ||
| # (at your option) any later version. | ||
| # | ||
| # This program is distributed in the hope that it will be useful, | ||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| # GNU General Public License for more details. | ||
| # | ||
| # You should have received a copy of the GNU General Public License | ||
| # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
| # | ||
| ######################################################################### | ||
|
|
||
| from unittest import mock | ||
|
|
||
| import requests | ||
| from django.core.exceptions import ImproperlyConfigured | ||
| from django.core.mail import EmailMessage | ||
| from django.test import SimpleTestCase | ||
|
|
||
| from geonode.email_backends.ms_graph_backend import ( | ||
| GRAPH_SENDMAIL_ENDPOINT, | ||
| MicrosoftGraphEmailBackend, | ||
| ) | ||
|
|
||
| VALID_CREDS = { | ||
| "tenant_id": "tenant-xyz", | ||
| "client_id": "client-xyz", | ||
| "client_secret": "secret-xyz", | ||
| "mail_from": "noreply@example.com", | ||
| } | ||
|
|
||
|
|
||
| def _ok_response(): | ||
| resp = mock.Mock(spec=requests.Response) | ||
| resp.ok = True | ||
| resp.status_code = 202 | ||
| resp.text = "" | ||
| return resp | ||
|
|
||
|
|
||
| class MicrosoftGraphEmailBackendTests(SimpleTestCase): | ||
| def setUp(self): | ||
| self.msal_patch = mock.patch("geonode.email_backends.ms_graph_backend.msal") | ||
| self.requests_patch = mock.patch("geonode.email_backends.ms_graph_backend.requests") | ||
| self.mock_msal = self.msal_patch.start() | ||
| self.mock_requests = self.requests_patch.start() | ||
| self.addCleanup(self.msal_patch.stop) | ||
| self.addCleanup(self.requests_patch.stop) | ||
|
|
||
| self.mock_app = mock.Mock() | ||
| self.mock_msal.ConfidentialClientApplication.return_value = self.mock_app | ||
| self.mock_app.acquire_token_silent.return_value = None | ||
| self.mock_app.acquire_token_for_client.return_value = {"access_token": "tok"} | ||
| self.mock_requests.post.return_value = _ok_response() | ||
|
|
||
| def _backend(self, fail_silently=False): | ||
| return MicrosoftGraphEmailBackend(fail_silently=fail_silently) | ||
|
|
||
| def _last_payload(self): | ||
| return self.mock_requests.post.call_args.kwargs["json"] | ||
|
|
||
| def _last_url(self): | ||
| return self.mock_requests.post.call_args.args[0] | ||
|
|
||
| def test_html_body_marked_as_html(self): | ||
| msg = EmailMessage("subj", "<p>hi</p>", to=["a@example.com"]) | ||
| msg.content_subtype = "html" | ||
| with self.settings(MICROSOFT_GRAPH_API_CREDENTIALS=VALID_CREDS): | ||
| self.assertEqual(self._backend().send_messages([msg]), 1) | ||
| payload = self._last_payload() | ||
| self.assertEqual(payload["message"]["body"]["contentType"], "HTML") | ||
| self.assertEqual(payload["message"]["body"]["content"], "<p>hi</p>") | ||
|
|
||
| def test_plain_body_marked_as_text(self): | ||
| msg = EmailMessage("subj", "line1\nline2", to=["a@example.com"]) | ||
| with self.settings(MICROSOFT_GRAPH_API_CREDENTIALS=VALID_CREDS): | ||
| self.assertEqual(self._backend().send_messages([msg]), 1) | ||
| self.assertEqual(self._last_payload()["message"]["body"]["contentType"], "Text") | ||
|
|
||
| def test_to_cc_bcc_forwarded(self): | ||
| msg = EmailMessage( | ||
| "subj", | ||
| "body", | ||
| to=["a@example.com", "b@example.com"], | ||
| cc=["c@example.com"], | ||
| bcc=["d@example.com"], | ||
| ) | ||
| with self.settings(MICROSOFT_GRAPH_API_CREDENTIALS=VALID_CREDS): | ||
| self.assertEqual(self._backend().send_messages([msg]), 1) | ||
| payload = self._last_payload()["message"] | ||
| self.assertEqual( | ||
| [r["emailAddress"]["address"] for r in payload["toRecipients"]], | ||
| ["a@example.com", "b@example.com"], | ||
| ) | ||
| self.assertEqual([r["emailAddress"]["address"] for r in payload["ccRecipients"]], ["c@example.com"]) | ||
| self.assertEqual([r["emailAddress"]["address"] for r in payload["bccRecipients"]], ["d@example.com"]) | ||
|
|
||
| def test_reply_to_forwarded(self): | ||
| msg = EmailMessage("subj", "body", to=["a@example.com"], reply_to=["replies@example.com"]) | ||
| with self.settings(MICROSOFT_GRAPH_API_CREDENTIALS=VALID_CREDS): | ||
| self._backend().send_messages([msg]) | ||
| self.assertEqual( | ||
| self._last_payload()["message"]["replyTo"], | ||
| [{"emailAddress": {"address": "replies@example.com"}}], | ||
| ) | ||
|
|
||
| def test_url_uses_configured_mail_from(self): | ||
| msg = EmailMessage("subj", "body", from_email="other@example.com", to=["a@example.com"]) | ||
| with self.settings(MICROSOFT_GRAPH_API_CREDENTIALS=VALID_CREDS): | ||
| self._backend().send_messages([msg]) | ||
| self.assertEqual(self._last_url(), GRAPH_SENDMAIL_ENDPOINT.format(mailbox=VALID_CREDS["mail_from"])) | ||
|
|
||
| def test_token_acquired_once_per_send_messages_call(self): | ||
| msgs = [EmailMessage("s", "b", to=["a@example.com"]) for _ in range(3)] | ||
| with self.settings(MICROSOFT_GRAPH_API_CREDENTIALS=VALID_CREDS): | ||
| self.assertEqual(self._backend().send_messages(msgs), 3) | ||
| # MSAL client constructed once; token acquired once across 3 sends. | ||
| self.assertEqual(self.mock_msal.ConfidentialClientApplication.call_count, 1) | ||
| self.assertEqual(self.mock_app.acquire_token_for_client.call_count, 1) | ||
| self.assertEqual(self.mock_requests.post.call_count, 3) | ||
|
|
||
| def test_missing_credentials_non_silent_raises(self): | ||
| msg = EmailMessage("subj", "body", to=["a@example.com"]) | ||
| with self.settings(MICROSOFT_GRAPH_API_CREDENTIALS={}): | ||
| with self.assertRaises(ImproperlyConfigured): | ||
| self._backend(fail_silently=False).send_messages([msg]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
EMAIL_MS_GRAPH_FROMis not set, it falls back toDEFAULT_FROM_EMAIL. In GeoNode,DEFAULT_FROM_EMAILoften contains a display name (e.g.,'GeoNode <no-reply@geonode.org>'). However, the Microsoft Graph API{mailbox}URL parameter requires a bare email address. Passing a display name formatted email address will result in an invalid URL and API failure. We should parse the bare email address usingemail.utils.parseaddrto prevent this.