diff --git a/.env.sample b/.env.sample index 21422dc8bd9..2bab72ee8dd 100644 --- a/.env.sample +++ b/.env.sample @@ -121,6 +121,13 @@ DJANGO_EMAIL_USE_TLS=False DJANGO_EMAIL_USE_SSL=False DEFAULT_FROM_EMAIL='{email}' # eg Company +# Microsoft 365 email backend (optional, uses the Microsoft Graph sendMail API) +# To use, also set DJANGO_EMAIL_BACKEND=geonode.email_backends.ms_graph_backend.MicrosoftGraphEmailBackend +EMAIL_MS_GRAPH_TENANT_ID= +EMAIL_MS_GRAPH_CLIENT_ID= +EMAIL_MS_GRAPH_CLIENT_SECRET= +EMAIL_MS_GRAPH_FROM= + # Session/Access Control LOCKDOWN_GEONODE=False X_FRAME_OPTIONS="SAMEORIGIN" diff --git a/.env_dev b/.env_dev index 9c9b7eaa176..1bb447ce58f 100644 --- a/.env_dev +++ b/.env_dev @@ -124,6 +124,13 @@ DJANGO_EMAIL_USE_TLS=False DJANGO_EMAIL_USE_SSL=False DEFAULT_FROM_EMAIL='GeoNode ' +# Microsoft 365 email backend (optional, uses the Microsoft Graph sendMail API) +# To use, also set DJANGO_EMAIL_BACKEND=geonode.email_backends.ms_graph_backend.MicrosoftGraphEmailBackend +EMAIL_MS_GRAPH_TENANT_ID= +EMAIL_MS_GRAPH_CLIENT_ID= +EMAIL_MS_GRAPH_CLIENT_SECRET= +EMAIL_MS_GRAPH_FROM= + # Session/Access Control LOCKDOWN_GEONODE=False X_FRAME_OPTIONS="SAMEORIGIN" diff --git a/.env_local b/.env_local index 61a65461676..5fc125b5aad 100644 --- a/.env_local +++ b/.env_local @@ -124,6 +124,13 @@ DJANGO_EMAIL_USE_TLS=False DJANGO_EMAIL_USE_SSL=False DEFAULT_FROM_EMAIL='GeoNode ' +# Microsoft 365 email backend (optional, uses the Microsoft Graph sendMail API) +# To use, also set DJANGO_EMAIL_BACKEND=geonode.email_backends.ms_graph_backend.MicrosoftGraphEmailBackend +EMAIL_MS_GRAPH_TENANT_ID= +EMAIL_MS_GRAPH_CLIENT_ID= +EMAIL_MS_GRAPH_CLIENT_SECRET= +EMAIL_MS_GRAPH_FROM= + # Session/Access Control LOCKDOWN_GEONODE=False X_FRAME_OPTIONS="SAMEORIGIN" diff --git a/.env_test b/.env_test index ae540ec6956..f7673d70506 100644 --- a/.env_test +++ b/.env_test @@ -132,6 +132,13 @@ DJANGO_EMAIL_USE_TLS=False DJANGO_EMAIL_USE_SSL=False DEFAULT_FROM_EMAIL='GeoNode ' +# Microsoft 365 email backend (optional, uses the Microsoft Graph sendMail API) +# To use, also set DJANGO_EMAIL_BACKEND=geonode.email_backends.ms_graph_backend.MicrosoftGraphEmailBackend +EMAIL_MS_GRAPH_TENANT_ID= +EMAIL_MS_GRAPH_CLIENT_ID= +EMAIL_MS_GRAPH_CLIENT_SECRET= +EMAIL_MS_GRAPH_FROM= + # Session/Access Control LOCKDOWN_GEONODE=False X_FRAME_OPTIONS="SAMEORIGIN" diff --git a/docs/src/setup/configuration/settings.md b/docs/src/setup/configuration/settings.md index 5c245b4b90c..9ac07fb1f36 100644 --- a/docs/src/setup/configuration/settings.md +++ b/docs/src/setup/configuration/settings.md @@ -832,6 +832,20 @@ Options: - EMAIL_USE_SSL - Default: ``False`` - DEFAULT_FROM_EMAIL - Default: ``GeoNode `` +To send email through Microsoft 365 via the Microsoft Graph ``sendMail`` API +instead of SMTP, set ``EMAIL_BACKEND`` to +``geonode.email_backends.ms_graph_backend.MicrosoftGraphEmailBackend`` and +configure: + +- EMAIL_MS_GRAPH_TENANT_ID - Azure AD tenant (directory) ID +- EMAIL_MS_GRAPH_CLIENT_ID - App registration client ID +- EMAIL_MS_GRAPH_CLIENT_SECRET - App registration client secret value +- EMAIL_MS_GRAPH_FROM - Bare email address of the sender mailbox; falls back to ``DEFAULT_FROM_EMAIL`` + +The configured ``EMAIL_MS_GRAPH_FROM`` is always used as the sender +regardless of any per-message ``from_email``. The display name shown to +recipients comes from the mailbox's Azure AD configuration. + **EPSG_CODE_MATCHES** diff --git a/geonode/email_backends/__init__.py b/geonode/email_backends/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/geonode/email_backends/ms_graph_backend.py b/geonode/email_backends/ms_graph_backend.py new file mode 100644 index 00000000000..992b40b64cf --- /dev/null +++ b/geonode/email_backends/ms_graph_backend.py @@ -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 . +# +######################################################################### +"""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 + + 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) diff --git a/geonode/settings.py b/geonode/settings.py index eec5b59ea9a..13e7d78073d 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -82,6 +82,17 @@ # Email for users to contact admins. THEME_ACCOUNT_CONTACT_EMAIL = os.getenv("THEME_ACCOUNT_CONTACT_EMAIL", "admin@example.com") +# Microsoft 365 email backend (optional). Sends mail through the Microsoft Graph +# sendMail API. To use, set DJANGO_EMAIL_BACKEND to +# geonode.email_backends.ms_graph_backend.MicrosoftGraphEmailBackend and populate +# the variables below. +MICROSOFT_GRAPH_API_CREDENTIALS = { + "tenant_id": os.getenv("EMAIL_MS_GRAPH_TENANT_ID", ""), + "client_id": os.getenv("EMAIL_MS_GRAPH_CLIENT_ID", ""), + "client_secret": os.getenv("EMAIL_MS_GRAPH_CLIENT_SECRET", ""), + "mail_from": os.getenv("EMAIL_MS_GRAPH_FROM", os.getenv("DEFAULT_FROM_EMAIL", "")), +} + # Make this unique, and don't share it with anybody. _DEFAULT_SECRET_KEY = "myv-y4#7j-d*p-__@j#*3z@!y24fz8%^z2v6atuy4bo9vqr1_a" SECRET_KEY = os.getenv("SECRET_KEY", _DEFAULT_SECRET_KEY) diff --git a/geonode/tests/test_ms_graph_backend.py b/geonode/tests/test_ms_graph_backend.py new file mode 100644 index 00000000000..5de44efa40a --- /dev/null +++ b/geonode/tests/test_ms_graph_backend.py @@ -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 . +# +######################################################################### + +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", "

hi

", 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"], "

hi

") + + 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]) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 248d67955d3..5c329d36427 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ dependencies = [ "xmltodict<0.13.1", "inflection>=0.4.0", "mock<6.0.0", + "msal==1.37.0", "python-dateutil==2.9.0.post0", "pytz==2024.1", "requests==2.34.2",