-
Notifications
You must be signed in to change notification settings - Fork 5.7k
Add Microsoft Graph (OAuth2) as an outbound email provider #9772
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
Open
Suvrakar
wants to merge
2
commits into
makeplane:preview
Choose a base branch
from
Suvrakar:feature/microsoft-graph-email-provider
base: preview
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", [])], | ||
| }, | ||
| "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() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.