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
58 changes: 58 additions & 0 deletions apps/api/plane/license/utils/email_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

# Django imports
from django.core.exceptions import ImproperlyConfigured
from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend

# Module imports
from plane.license.utils.graph_mail import send_graph_email
from plane.license.utils.instance_value import get_graph_email_configuration


class PlaneEmailBackend(BaseEmailBackend):
"""Routes outgoing mail through Microsoft Graph when EMAIL_PROVIDER is set
to MICROSOFT_GRAPH, otherwise delegates to Django's SMTP backend using the
same connection kwargs Plane already builds via get_email_configuration().
"""

def __init__(self, fail_silently=False, **kwargs):
super().__init__(fail_silently=fail_silently)
self._smtp_kwargs = kwargs

def send_messages(self, email_messages):
if not email_messages:
return 0

(
email_provider,
tenant_id,
client_id,
client_secret,
sender,
) = get_graph_email_configuration()

if email_provider != "MICROSOFT_GRAPH":
smtp_backend = SMTPEmailBackend(fail_silently=self.fail_silently, **self._smtp_kwargs)
return smtp_backend.send_messages(email_messages)

if not all([tenant_id, client_id, client_secret, sender]):
if self.fail_silently:
return 0
raise ImproperlyConfigured(
"EMAIL_PROVIDER is set to MICROSOFT_GRAPH but EMAIL_GRAPH_TENANT_ID, "
"EMAIL_GRAPH_CLIENT_ID, EMAIL_GRAPH_CLIENT_SECRET, or EMAIL_HOST_USER "
"is not configured."
)

sent = 0
for message in email_messages:
try:
send_graph_email(tenant_id, client_id, client_secret, sender, message)
sent += 1
except Exception:
if not self.fail_silently:
raise
return sent
100 changes: 100 additions & 0 deletions apps/api/plane/license/utils/graph_mail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

# Python imports
import base64
import mimetypes
from email.utils import parseaddr

# Third party imports
import requests

GRAPH_TOKEN_URL = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
GRAPH_SEND_MAIL_URL = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail"


def get_graph_access_token(tenant_id, client_id, client_secret):
response = requests.post(
GRAPH_TOKEN_URL.format(tenant_id=tenant_id),
data={
"client_id": client_id,
"client_secret": client_secret,
"scope": "https://graph.microsoft.com/.default",
"grant_type": "client_credentials",
},
timeout=30,
)
response.raise_for_status()
return response.json()["access_token"]


def _address_only(value):
return parseaddr(value)[1]


def _build_graph_attachment(attachment):
"""Serialize a Django EmailMessage attachment into a Graph fileAttachment.

Django's `EmailMessage.attach()` appends (filename, content, mimetype)
tuples to `message.attachments`; MIMEBase attachments aren't produced by
any call site in this codebase, so we reject them explicitly rather than
silently dropping their content.
"""
if not isinstance(attachment, tuple):
raise ValueError(f"Cannot serialize attachment of type {type(attachment)!r} for Microsoft Graph")

filename, content, mimetype = (list(attachment) + [None, None, None])[:3]

if isinstance(content, str):
content_bytes = content.encode("utf-8")
elif isinstance(content, (bytes, bytearray)):
content_bytes = bytes(content)
else:
raise ValueError(
f"Cannot serialize attachment '{filename}' for Microsoft Graph: unsupported content type {type(content)!r}"
)

resolved_mimetype = mimetype or mimetypes.guess_type(filename or "")[0] or "application/octet-stream"

return {
"@odata.type": "#microsoft.graph.fileAttachment",
"name": filename or "attachment",
"contentType": resolved_mimetype,
"contentBytes": base64.b64encode(content_bytes).decode("ascii"),
}


def build_graph_message(message):
html_body = next(
(content for content, mimetype in getattr(message, "alternatives", []) if mimetype == "text/html"),
None,
)
return {
"message": {
"subject": message.subject,
"body": {
"contentType": "HTML" if html_body else "Text",
"content": html_body or message.body,
},
"toRecipients": [{"emailAddress": {"address": _address_only(addr)}} for addr in message.to],
"ccRecipients": [{"emailAddress": {"address": _address_only(addr)}} for addr in message.cc],
"bccRecipients": [{"emailAddress": {"address": _address_only(addr)}} for addr in message.bcc],
"attachments": [_build_graph_attachment(a) for a in getattr(message, "attachments", [])],
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"saveToSentItems": False,
}


def send_graph_email(tenant_id, client_id, client_secret, sender, message):
access_token = get_graph_access_token(tenant_id, client_id, client_secret)
response = requests.post(
GRAPH_SEND_MAIL_URL.format(sender=sender),
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
json=build_graph_message(message),
timeout=30,
)
response.raise_for_status()
15 changes: 15 additions & 0 deletions apps/api/plane/license/utils/instance_value.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,18 @@ def get_email_configuration():
},
]
)


def get_graph_email_configuration():
return get_configuration_value(
[
{"key": "EMAIL_PROVIDER", "default": os.environ.get("EMAIL_PROVIDER", "SMTP")},
{"key": "EMAIL_GRAPH_TENANT_ID", "default": os.environ.get("EMAIL_GRAPH_TENANT_ID", "")},
{"key": "EMAIL_GRAPH_CLIENT_ID", "default": os.environ.get("EMAIL_GRAPH_CLIENT_ID", "")},
{
"key": "EMAIL_GRAPH_CLIENT_SECRET",
"default": os.environ.get("EMAIL_GRAPH_CLIENT_SECRET", ""),
},
{"key": "EMAIL_HOST_USER", "default": os.environ.get("EMAIL_HOST_USER", "")},
]
)
2 changes: 1 addition & 1 deletion apps/api/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

# Email settings
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_BACKEND = "plane.license.utils.email_backend.PlaneEmailBackend"

# Storage Settings
# Use Minio settings
Expand Down
133 changes: 133 additions & 0 deletions apps/api/plane/tests/unit/utils/test_email_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

"""
Unit tests for PlaneEmailBackend.

Covers:
- falling back to Django's SMTP backend when EMAIL_PROVIDER is not MICROSOFT_GRAPH
- routing through Microsoft Graph when EMAIL_PROVIDER is MICROSOFT_GRAPH
- fail_silently swallowing Graph send errors instead of raising
- raising a clear configuration error (or returning 0 when fail_silently) when
MICROSOFT_GRAPH is selected but tenant/client/secret/sender is incomplete
"""

from unittest.mock import Mock, patch

import pytest
from django.core.exceptions import ImproperlyConfigured
from django.core.mail import EmailMultiAlternatives

from plane.license.utils.email_backend import PlaneEmailBackend


GRAPH_CONFIG = (
"MICROSOFT_GRAPH",
"tenant-id",
"client-id",
"client-secret",
"noreply@example.com",
)

INCOMPLETE_GRAPH_CONFIG = ("MICROSOFT_GRAPH", "tenant-id", "", "", "noreply@example.com")

SMTP_CONFIG = ("SMTP", "", "", "", "noreply@example.com")


def _make_message():
return EmailMultiAlternatives(
subject="Hi",
body="body",
from_email="sender@example.com",
to=["receiver@example.com"],
)


@pytest.mark.unit
class TestPlaneEmailBackend:
def test_empty_message_list_sends_nothing(self):
backend = PlaneEmailBackend()
assert backend.send_messages([]) == 0

def test_falls_back_to_smtp_backend_by_default(self):
backend = PlaneEmailBackend(host="smtp.example.com", port=587, username="u", password="p")
message = _make_message()

with patch(
"plane.license.utils.email_backend.get_graph_email_configuration",
return_value=SMTP_CONFIG,
), patch("plane.license.utils.email_backend.SMTPEmailBackend") as mock_smtp_cls:
mock_smtp_instance = Mock()
mock_smtp_instance.send_messages.return_value = 1
mock_smtp_cls.return_value = mock_smtp_instance

sent = backend.send_messages([message])

mock_smtp_cls.assert_called_once_with(
fail_silently=False, host="smtp.example.com", port=587, username="u", password="p"
)
mock_smtp_instance.send_messages.assert_called_once_with([message])
assert sent == 1

def test_routes_through_graph_when_provider_is_microsoft_graph(self):
backend = PlaneEmailBackend()
message = _make_message()

with patch(
"plane.license.utils.email_backend.get_graph_email_configuration",
return_value=GRAPH_CONFIG,
), patch("plane.license.utils.email_backend.send_graph_email") as mock_send:
sent = backend.send_messages([message])

mock_send.assert_called_once_with("tenant-id", "client-id", "client-secret", "noreply@example.com", message)
assert sent == 1

def test_fail_silently_swallows_graph_errors(self):
backend = PlaneEmailBackend(fail_silently=True)
message = _make_message()

with patch(
"plane.license.utils.email_backend.get_graph_email_configuration",
return_value=GRAPH_CONFIG,
), patch("plane.license.utils.email_backend.send_graph_email", side_effect=Exception("boom")):
sent = backend.send_messages([message])

assert sent == 0

def test_raises_graph_errors_when_not_fail_silently(self):
backend = PlaneEmailBackend(fail_silently=False)
message = _make_message()

with patch(
"plane.license.utils.email_backend.get_graph_email_configuration",
return_value=GRAPH_CONFIG,
), patch("plane.license.utils.email_backend.send_graph_email", side_effect=Exception("boom")):
with pytest.raises(Exception):
backend.send_messages([message])

def test_incomplete_graph_config_raises_improperly_configured(self):
backend = PlaneEmailBackend(fail_silently=False)
message = _make_message()

with patch(
"plane.license.utils.email_backend.get_graph_email_configuration",
return_value=INCOMPLETE_GRAPH_CONFIG,
), patch("plane.license.utils.email_backend.send_graph_email") as mock_send:
with pytest.raises(ImproperlyConfigured):
backend.send_messages([message])

mock_send.assert_not_called()

def test_incomplete_graph_config_returns_zero_when_fail_silently(self):
backend = PlaneEmailBackend(fail_silently=True)
message = _make_message()

with patch(
"plane.license.utils.email_backend.get_graph_email_configuration",
return_value=INCOMPLETE_GRAPH_CONFIG,
), patch("plane.license.utils.email_backend.send_graph_email") as mock_send:
sent = backend.send_messages([message])

assert sent == 0
mock_send.assert_not_called()
Loading