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
7 changes: 7 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ DJANGO_EMAIL_USE_TLS=False
DJANGO_EMAIL_USE_SSL=False
DEFAULT_FROM_EMAIL='{email}' # eg Company <no-reply@company.org>

# 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"
Expand Down
7 changes: 7 additions & 0 deletions .env_dev
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ DJANGO_EMAIL_USE_TLS=False
DJANGO_EMAIL_USE_SSL=False
DEFAULT_FROM_EMAIL='GeoNode <no-reply@geonode.org>'

# 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"
Expand Down
7 changes: 7 additions & 0 deletions .env_local
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ DJANGO_EMAIL_USE_TLS=False
DJANGO_EMAIL_USE_SSL=False
DEFAULT_FROM_EMAIL='GeoNode <no-reply@geonode.org>'

# 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"
Expand Down
7 changes: 7 additions & 0 deletions .env_test
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ DJANGO_EMAIL_USE_TLS=False
DJANGO_EMAIL_USE_SSL=False
DEFAULT_FROM_EMAIL='GeoNode <no-reply@geonode.org>'

# 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"
Expand Down
14 changes: 14 additions & 0 deletions docs/src/setup/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,20 @@ Options:
- EMAIL_USE_SSL - Default: ``False``
- DEFAULT_FROM_EMAIL - Default: ``GeoNode <no-reply@geonode.org>``

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**


Expand Down
Empty file.
135 changes: 135 additions & 0 deletions geonode/email_backends/ms_graph_backend.py
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
Comment on lines +58 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If EMAIL_MS_GRAPH_FROM is not set, it falls back to DEFAULT_FROM_EMAIL. In GeoNode, DEFAULT_FROM_EMAIL often 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 using email.utils.parseaddr to prevent this.

    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}")

        # Extract bare email address in case it contains a display name (e.g. "Name <email@domain.com>")
        from email.utils import parseaddr
        mail_from = parseaddr(creds["mail_from"])[1]
        if not mail_from:
            raise ImproperlyConfigured(f"MICROSOFT_GRAPH_API_CREDENTIALS['mail_from'] is not a valid email address: {creds['mail_from']}")

        creds = creds.copy()
        creds["mail_from"] = mail_from

        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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When sending multiple emails in a batch, establishing a new TCP and TLS connection for each request introduces significant latency. We can use a requests.Session to reuse the underlying connection across requests, improving performance.

    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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This suggestion enhances the _send method to:

  1. Support requests.Session for connection reuse.
  2. Support EmailMultiAlternatives by automatically extracting the HTML alternative if the main body is plain text.
  3. Support email attachments by encoding them to base64 and forwarding them to the Graph API.
  4. Support custom email headers via internetMessageHeaders.
  5. Fix saveToSentItems to be a proper JSON boolean (True) instead of a string ('true').
    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

11 changes: 11 additions & 0 deletions geonode/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
134 changes: 134 additions & 0 deletions geonode/tests/test_ms_graph_backend.py
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])
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down