diff --git a/apps/api/plane/license/utils/email_backend.py b/apps/api/plane/license/utils/email_backend.py new file mode 100644 index 00000000000..fbaf1a44b66 --- /dev/null +++ b/apps/api/plane/license/utils/email_backend.py @@ -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 diff --git a/apps/api/plane/license/utils/graph_mail.py b/apps/api/plane/license/utils/graph_mail.py new file mode 100644 index 00000000000..c50f93f359a --- /dev/null +++ b/apps/api/plane/license/utils/graph_mail.py @@ -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", [])], + }, + "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() diff --git a/apps/api/plane/license/utils/instance_value.py b/apps/api/plane/license/utils/instance_value.py index 279eb217777..23feb44cfbb 100644 --- a/apps/api/plane/license/utils/instance_value.py +++ b/apps/api/plane/license/utils/instance_value.py @@ -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", "")}, + ] + ) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 65b5d7b9f82..5826fb3066c 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -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 diff --git a/apps/api/plane/tests/unit/utils/test_email_backend.py b/apps/api/plane/tests/unit/utils/test_email_backend.py new file mode 100644 index 00000000000..b2150896ec4 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_email_backend.py @@ -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() diff --git a/apps/api/plane/tests/unit/utils/test_graph_mail.py b/apps/api/plane/tests/unit/utils/test_graph_mail.py new file mode 100644 index 00000000000..01680d647be --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_graph_mail.py @@ -0,0 +1,177 @@ +# 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 the Microsoft Graph email helper. + +Covers: +- OAuth2 client-credentials token acquisition +- building a Graph sendMail payload from a Django EmailMessage, including + attachments (e.g. the analytics CSV export) +- the end-to-end send call wiring the token into the request +""" + +import base64 +from unittest.mock import Mock, patch + +import pytest +from django.core.mail import EmailMultiAlternatives + +from plane.license.utils.graph_mail import ( + build_graph_message, + get_graph_access_token, + send_graph_email, +) + + +@pytest.mark.unit +class TestGetGraphAccessToken: + def test_requests_token_with_client_credentials(self): + response = Mock(status_code=200) + response.json.return_value = {"access_token": "fake-token"} + response.raise_for_status = Mock() + + with patch("plane.license.utils.graph_mail.requests.post", return_value=response) as mock_post: + token = get_graph_access_token("tenant-id", "client-id", "client-secret") + + assert token == "fake-token" + args, kwargs = mock_post.call_args + assert args[0] == "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token" + assert kwargs["data"]["client_id"] == "client-id" + assert kwargs["data"]["client_secret"] == "client-secret" + assert kwargs["data"]["grant_type"] == "client_credentials" + assert kwargs["data"]["scope"] == "https://graph.microsoft.com/.default" + + def test_raises_for_non_2xx_response(self): + response = Mock(status_code=401) + response.raise_for_status = Mock(side_effect=Exception("unauthorized")) + + with patch("plane.license.utils.graph_mail.requests.post", return_value=response): + with pytest.raises(Exception): + get_graph_access_token("tenant-id", "client-id", "bad-secret") + + +@pytest.mark.unit +class TestBuildGraphMessage: + def test_plain_text_message(self): + message = EmailMultiAlternatives( + subject="Hello", + body="Plain text body", + from_email="Sender ", + to=["Receiver "], + cc=["cc@example.com"], + bcc=["bcc@example.com"], + ) + + payload = build_graph_message(message) + + assert payload["message"]["subject"] == "Hello" + assert payload["message"]["body"] == { + "contentType": "Text", + "content": "Plain text body", + } + assert payload["message"]["toRecipients"] == [{"emailAddress": {"address": "receiver@example.com"}}] + assert payload["message"]["ccRecipients"] == [{"emailAddress": {"address": "cc@example.com"}}] + assert payload["message"]["bccRecipients"] == [{"emailAddress": {"address": "bcc@example.com"}}] + assert payload["message"]["attachments"] == [] + assert payload["saveToSentItems"] is False + + def test_prefers_html_alternative_when_present(self): + message = EmailMultiAlternatives( + subject="Hello", + body="Plain fallback", + from_email="sender@example.com", + to=["receiver@example.com"], + ) + message.attach_alternative("

Rich body

", "text/html") + + payload = build_graph_message(message) + + assert payload["message"]["body"] == { + "contentType": "HTML", + "content": "

Rich body

", + } + + def test_serializes_attachment_as_base64_file_attachment(self): + message = EmailMultiAlternatives( + subject="Analytics export", + body="See attached CSV", + from_email="sender@example.com", + to=["receiver@example.com"], + ) + message.attach("plane-analytics.csv", "col1,col2\n1,2\n", "text/csv") + + payload = build_graph_message(message) + + [attachment] = payload["message"]["attachments"] + assert attachment["@odata.type"] == "#microsoft.graph.fileAttachment" + assert attachment["name"] == "plane-analytics.csv" + assert attachment["contentType"] == "text/csv" + assert base64.b64decode(attachment["contentBytes"]) == b"col1,col2\n1,2\n" + + def test_guesses_mimetype_when_not_provided(self): + message = EmailMultiAlternatives( + subject="Hi", + body="body", + from_email="sender@example.com", + to=["receiver@example.com"], + ) + message.attach("notes.txt", "hello") + + payload = build_graph_message(message) + + [attachment] = payload["message"]["attachments"] + assert attachment["contentType"] == "text/plain" + + def test_rejects_unsupported_attachment_content(self): + message = EmailMultiAlternatives( + subject="Hi", + body="body", + from_email="sender@example.com", + to=["receiver@example.com"], + ) + message.attachments.append(("bad.bin", object(), "application/octet-stream")) + + with pytest.raises(ValueError): + build_graph_message(message) + + +@pytest.mark.unit +class TestSendGraphEmail: + def test_sends_to_correct_mailbox_with_bearer_token(self): + message = EmailMultiAlternatives( + subject="Hi", + body="body", + from_email="sender@example.com", + to=["receiver@example.com"], + ) + send_response = Mock(status_code=202) + send_response.raise_for_status = Mock() + + with patch( + "plane.license.utils.graph_mail.get_graph_access_token", return_value="fake-token" + ) as mock_token, patch("plane.license.utils.graph_mail.requests.post", return_value=send_response) as mock_post: + send_graph_email("tenant-id", "client-id", "client-secret", "noreply@example.com", message) + + mock_token.assert_called_once_with("tenant-id", "client-id", "client-secret") + args, kwargs = mock_post.call_args + assert args[0] == "https://graph.microsoft.com/v1.0/users/noreply@example.com/sendMail" + assert kwargs["headers"]["Authorization"] == "Bearer fake-token" + send_response.raise_for_status.assert_called_once() + + def test_raises_when_graph_rejects_the_send(self): + message = EmailMultiAlternatives( + subject="Hi", + body="body", + from_email="sender@example.com", + to=["receiver@example.com"], + ) + send_response = Mock(status_code=403) + send_response.raise_for_status = Mock(side_effect=Exception("forbidden")) + + with patch("plane.license.utils.graph_mail.get_graph_access_token", return_value="fake-token"), patch( + "plane.license.utils.graph_mail.requests.post", return_value=send_response + ): + with pytest.raises(Exception): + send_graph_email("tenant-id", "client-id", "client-secret", "noreply@example.com", message) diff --git a/apps/api/plane/utils/instance_config_variables/core.py b/apps/api/plane/utils/instance_config_variables/core.py index 6eebf0b3adb..a250c4954ad 100644 --- a/apps/api/plane/utils/instance_config_variables/core.py +++ b/apps/api/plane/utils/instance_config_variables/core.py @@ -193,6 +193,30 @@ "category": "SMTP", "is_encrypted": False, }, + { + "key": "EMAIL_PROVIDER", + "value": os.environ.get("EMAIL_PROVIDER", "SMTP"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_GRAPH_TENANT_ID", + "value": os.environ.get("EMAIL_GRAPH_TENANT_ID", ""), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_GRAPH_CLIENT_ID", + "value": os.environ.get("EMAIL_GRAPH_CLIENT_ID", ""), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_GRAPH_CLIENT_SECRET", + "value": os.environ.get("EMAIL_GRAPH_CLIENT_SECRET", ""), + "category": "SMTP", + "is_encrypted": True, + }, ] llm_config_variables = [