diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 9808d601..b34ccc8d 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -44,6 +44,7 @@ jobs: - { tox: django41-py310-mailersend, python: "3.12" } - { tox: django41-py310-mailgun, python: "3.12" } - { tox: django41-py310-mailjet, python: "3.12" } + - { tox: django41-py310-mailtrap, python: "3.12" } - { tox: django41-py310-mandrill, python: "3.12" } - { tox: django41-py310-postal, python: "3.12" } - { tox: django41-py310-postmark, python: "3.12" } diff --git a/README.rst b/README.rst index 8daf0ee0..452bf4bb 100644 --- a/README.rst +++ b/README.rst @@ -31,6 +31,7 @@ Anymail currently supports these ESPs: * **MailerSend** * **Mailgun** (Sinch transactional email) * **Mailjet** (Sinch transactional email) +* **Mailtrap** * **Mandrill** (MailChimp transactional email) * **Postal** (self-hosted ESP) * **Postmark** (ActiveCampaign transactional email) diff --git a/anymail/backends/mailtrap.py b/anymail/backends/mailtrap.py new file mode 100644 index 00000000..ebbbfab8 --- /dev/null +++ b/anymail/backends/mailtrap.py @@ -0,0 +1,272 @@ +import sys +from urllib.parse import quote + +if sys.version_info < (3, 11): + from typing_extensions import Any, Dict, List, Literal, NotRequired, TypedDict +else: + from typing import Any, Dict, List, Literal, NotRequired, TypedDict + +from ..exceptions import AnymailRequestsAPIError +from ..message import AnymailMessage, AnymailRecipientStatus +from ..utils import Attachment, EmailAddress, get_anymail_setting, update_deep +from .base_requests import AnymailRequestsBackend, RequestsPayload + + +class MailtrapAddress(TypedDict): + email: str + name: NotRequired[str] + + +class MailtrapAttachment(TypedDict): + content: str + type: NotRequired[str] + filename: str + disposition: NotRequired[Literal["attachment", "inline"]] + content_id: NotRequired[str] + + +MailtrapData = TypedDict( + "MailtrapData", + { + "from": MailtrapAddress, + "to": NotRequired[List[MailtrapAddress]], + "cc": NotRequired[List[MailtrapAddress]], + "bcc": NotRequired[List[MailtrapAddress]], + "attachments": NotRequired[List[MailtrapAttachment]], + "headers": NotRequired[Dict[str, str]], + "custom_variables": NotRequired[Dict[str, str]], + "subject": str, + "text": str, + "html": NotRequired[str], + "category": NotRequired[str], + "template_id": NotRequired[str], + "template_variables": NotRequired[Dict[str, Any]], + }, +) + + +class MailtrapPayload(RequestsPayload): + def __init__( + self, + message: AnymailMessage, + defaults, + backend: "EmailBackend", + *args, + **kwargs, + ): + http_headers = { + "Api-Token": backend.api_token, + "Content-Type": "application/json", + "Accept": "application/json", + } + # Yes, the parent sets this, but setting it here, too, gives type hints + self.backend = backend + self.metadata = None + + # needed for backend.parse_recipient_status + self.recipients_to: List[str] = [] + self.recipients_cc: List[str] = [] + self.recipients_bcc: List[str] = [] + + super().__init__( + message, defaults, backend, *args, headers=http_headers, **kwargs + ) + + def get_api_endpoint(self): + if self.backend.testing_enabled: + test_inbox_id = quote(self.backend.test_inbox_id, safe="") + return f"send/{test_inbox_id}" + return "send" + + def serialize_data(self): + return self.serialize_json(self.data) + + # + # Payload construction + # + + def init_payload(self): + self.data: MailtrapData = { + "from": { + "email": "", + }, + "subject": "", + "text": "", + } + + @staticmethod + def _mailtrap_email(email: EmailAddress) -> MailtrapAddress: + """Expand an Anymail EmailAddress into Mailtrap's {"email", "name"} dict""" + result = {"email": email.addr_spec} + if email.display_name: + result["name"] = email.display_name + return result + + def set_from_email(self, email: EmailAddress): + self.data["from"] = self._mailtrap_email(email) + + def set_recipients( + self, recipient_type: Literal["to", "cc", "bcc"], emails: List[EmailAddress] + ): + assert recipient_type in ["to", "cc", "bcc"] + if emails: + self.data[recipient_type] = [ + self._mailtrap_email(email) for email in emails + ] + + if recipient_type == "to": + self.recipients_to = [email.addr_spec for email in emails] + elif recipient_type == "cc": + self.recipients_cc = [email.addr_spec for email in emails] + elif recipient_type == "bcc": + self.recipients_bcc = [email.addr_spec for email in emails] + + def set_subject(self, subject): + self.data["subject"] = subject + + def set_reply_to(self, emails: List[EmailAddress]): + self.data.setdefault("headers", {})["Reply-To"] = ", ".join( + email.address for email in emails + ) + + def set_extra_headers(self, headers): + self.data.setdefault("headers", {}).update(headers) + + def set_text_body(self, body): + self.data["text"] = body + + def set_html_body(self, body): + if "html" in self.data: + # second html body could show up through multiple alternatives, + # or html body + alternative + self.unsupported_feature("multiple html parts") + self.data["html"] = body + + def add_attachment(self, attachment: Attachment): + att: MailtrapAttachment = { + "filename": attachment.name or "", + "content": attachment.b64content, + } + if attachment.mimetype: + att["type"] = attachment.mimetype + if attachment.inline: + att["disposition"] = "inline" + att["content_id"] = attachment.cid + self.data.setdefault("attachments", []).append(att) + + def set_tags(self, tags: List[str]): + if len(tags) > 1: + self.unsupported_feature("multiple tags") + if len(tags) > 0: + self.data["category"] = tags[0] + + def set_track_clicks(self, *args, **kwargs): + """Do nothing. Mailtrap supports this, but it is not configured in the send request.""" + pass + + def set_track_opens(self, *args, **kwargs): + """Do nothing. Mailtrap supports this, but it is not configured in the send request.""" + pass + + def set_metadata(self, metadata): + self.data.setdefault("custom_variables", {}).update( + {str(k): str(v) for k, v in metadata.items()} + ) + self.metadata = metadata # save for set_merge_metadata + + def set_template_id(self, template_id): + self.data["template_uuid"] = template_id + + def set_merge_global_data(self, merge_global_data: Dict[str, Any]): + self.data.setdefault("template_variables", {}).update(merge_global_data) + + def set_esp_extra(self, extra): + update_deep(self.data, extra) + + +class EmailBackend(AnymailRequestsBackend): + """ + Mailtrap API Email Backend + """ + + esp_name = "Mailtrap" + + def __init__(self, **kwargs): + """Init options from Django settings""" + self.api_token = get_anymail_setting( + "api_token", esp_name=self.esp_name, kwargs=kwargs, allow_bare=True + ) + api_url = get_anymail_setting( + "api_url", + esp_name=self.esp_name, + kwargs=kwargs, + default="https://send.api.mailtrap.io/api/", + ) + if not api_url.endswith("/"): + api_url += "/" + + test_api_url = get_anymail_setting( + "test_api_url", + esp_name=self.esp_name, + kwargs=kwargs, + default="https://sandbox.api.mailtrap.io/api/", + ) + if not test_api_url.endswith("/"): + test_api_url += "/" + self.test_api_url = test_api_url + + self.testing_enabled = get_anymail_setting( + "testing", + esp_name=self.esp_name, + kwargs=kwargs, + default=False, + ) + + if self.testing_enabled: + self.test_inbox_id = get_anymail_setting( + "test_inbox_id", + esp_name=self.esp_name, + kwargs=kwargs, + # (no default means required -- error if not set) + ) + api_url = self.test_api_url + else: + self.test_inbox_id = None + + super().__init__(api_url, **kwargs) + + def build_message_payload(self, message, defaults): + return MailtrapPayload(message, defaults, self) + + def parse_recipient_status( + self, response, payload: MailtrapPayload, message: AnymailMessage + ): + parsed_response = self.deserialize_json_response(response, payload, message) + + # TODO: how to handle fail_silently? + if not self.fail_silently and ( + not parsed_response.get("success") + or ("errors" in parsed_response and parsed_response["errors"]) + or ("message_ids" not in parsed_response) + ): + raise AnymailRequestsAPIError( + email_message=message, payload=payload, response=response, backend=self + ) + else: + # message-ids will be in this order + recipient_status_order = [ + *payload.recipients_to, + *payload.recipients_cc, + *payload.recipients_bcc, + ] + recipient_status = { + email: AnymailRecipientStatus( + message_id=message_id, + status="sent", + ) + for email, message_id in zip( + recipient_status_order, parsed_response["message_ids"] + ) + } + + return recipient_status diff --git a/anymail/urls.py b/anymail/urls.py index 050d9b76..09e65aed 100644 --- a/anymail/urls.py +++ b/anymail/urls.py @@ -11,6 +11,7 @@ ) from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView +from .webhooks.mailtrap import MailtrapTrackingWebhookView from .webhooks.mandrill import MandrillCombinedWebhookView from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView @@ -108,6 +109,11 @@ MailjetTrackingWebhookView.as_view(), name="mailjet_tracking_webhook", ), + path( + "mailtrap/tracking/", + MailtrapTrackingWebhookView.as_view(), + name="mailtrap_tracking_webhook", + ), path( "postal/tracking/", PostalTrackingWebhookView.as_view(), diff --git a/anymail/webhooks/mailtrap.py b/anymail/webhooks/mailtrap.py new file mode 100644 index 00000000..1adcd7b0 --- /dev/null +++ b/anymail/webhooks/mailtrap.py @@ -0,0 +1,100 @@ +import json +import sys +from datetime import datetime, timezone + +if sys.version_info < (3, 11): + from typing_extensions import Dict, Literal, NotRequired, TypedDict, Union +else: + from typing import Dict, Literal, NotRequired, TypedDict, Union + +from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking +from .base import AnymailBaseWebhookView + + +class MailtrapEvent(TypedDict): + event: Literal[ + "delivery", + "open", + "click", + "unsubscribe", + "spam", + "soft bounce", + "bounce", + "suspension", + "reject", + ] + message_id: str + sending_stream: Literal["transactional", "bulk"] + email: str + timestamp: int + event_id: str + category: NotRequired[str] + custom_variables: NotRequired[Dict[str, Union[str, int, float, bool]]] + reason: NotRequired[str] + response: NotRequired[str] + response_code: NotRequired[int] + bounce_category: NotRequired[str] + ip: NotRequired[str] + user_agent: NotRequired[str] + url: NotRequired[str] + + +class MailtrapTrackingWebhookView(AnymailBaseWebhookView): + """Handler for Mailtrap delivery and engagement tracking webhooks""" + + esp_name = "Mailtrap" + signal = tracking + + def parse_events(self, request): + esp_events: list[MailtrapEvent] = json.loads(request.body.decode("utf-8")).get( + "events", [] + ) + return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] + + # https://help.mailtrap.io/article/87-statuses-and-events + event_types = { + # Map Mailtrap event: Anymail normalized type + "delivery": EventType.DELIVERED, + "open": EventType.OPENED, + "click": EventType.CLICKED, + "bounce": EventType.BOUNCED, + "soft bounce": EventType.DEFERRED, + "spam": EventType.COMPLAINED, + "unsubscribe": EventType.UNSUBSCRIBED, + "reject": EventType.REJECTED, + "suspension": EventType.DEFERRED, + } + + reject_reasons = { + # Map Mailtrap event type to Anymail normalized reject_reason + "bounce": RejectReason.BOUNCED, + "blocked": RejectReason.BLOCKED, + "spam": RejectReason.SPAM, + "unsubscribe": RejectReason.UNSUBSCRIBED, + "reject": RejectReason.BLOCKED, + "suspension": RejectReason.OTHER, + "soft bounce": RejectReason.OTHER, + } + + def esp_to_anymail_event(self, esp_event: MailtrapEvent): + event_type = self.event_types.get(esp_event["event"], EventType.UNKNOWN) + timestamp = datetime.fromtimestamp(esp_event["timestamp"], tz=timezone.utc) + reject_reason = self.reject_reasons.get(esp_event["event"]) + custom_variables = esp_event.get("custom_variables", {}) + category = esp_event.get("category") + tags = [category] if category else [] + + return AnymailTrackingEvent( + event_type=event_type, + timestamp=timestamp, + message_id=esp_event["message_id"], + event_id=esp_event.get("event_id"), + recipient=esp_event.get("email"), + reject_reason=reject_reason, + mta_response=esp_event.get("response"), + tags=tags, + metadata=custom_variables, + click_url=esp_event.get("url"), + user_agent=esp_event.get("user_agent"), + esp_event=esp_event, + ) diff --git a/docs/esps/esp-feature-matrix.csv b/docs/esps/esp-feature-matrix.csv index 406fa004..7d028fd1 100644 --- a/docs/esps/esp-feature-matrix.csv +++ b/docs/esps/esp-feature-matrix.csv @@ -1,20 +1,20 @@ -Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend` +Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mailtrap-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend` .. rubric:: :ref:`Anymail send options `,,,,,,,,,,,, -:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes,No -:attr:`~AnymailMessage.merge_headers`,Yes [#caveats]_,Yes,No,Yes,Yes,No,No,Yes,Yes,Yes,Yes [#caveats]_,Yes [#caveats]_ -:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_metadata`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes -:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes,Yes -:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag,Yes -:attr:`~AnymailMessage.track_clicks`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes -:attr:`~AnymailMessage.track_opens`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes -:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes,Yes +:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,No,Domain only,Yes,No,No,No,Yes,No +:attr:`~AnymailMessage.merge_headers`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes,No,No,Yes,Yes,Yes,Yes [#caveats]_,Yes [#caveats]_ +:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_metadata`,Yes [#caveats]_,Yes,No,Yes,Yes,No,Yes,No,Yes,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,No,Yes,No,No,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag,Yes +:attr:`~AnymailMessage.track_clicks`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,No [#nocontrol]_,Yes,No,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.track_opens`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,No [#nocontrol]_,Yes,No,Yes,No,Yes,Yes,Yes +:ref:`amp-email`,Yes,No,No,Yes,No,Yes,No,No,No,No,Yes,Yes,Yes .. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,, -:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_global_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,No,Yes,No,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_global_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes .. rubric:: :ref:`Status ` and :ref:`event tracking `,,,,,,,,,,,, -:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes -:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes +:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes .. rubric:: :ref:`Inbound handling `,,,,,,,,,,,, -:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes,No +:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,No diff --git a/docs/esps/mailtrap.rst b/docs/esps/mailtrap.rst new file mode 100644 index 00000000..2202e1a0 --- /dev/null +++ b/docs/esps/mailtrap.rst @@ -0,0 +1,120 @@ +.. mailtrap-backend: + +Mailtrap +======== + +Anymail integrates with `Mailtrap `_'s +transactional, bulk, or test email services, using the corresponding +`REST API`_. + +.. note:: + + By default, Anymail connects to Mailtrap's transactional API servers. + If you are using Mailtrap's bulk send service, be sure to change the + :setting:`MAILTRAP_API_URL ` Anymail setting + as shown below. Likewise, if you are using Mailtrap's test email service, + be sure to set :setting:`MAILTRAP_TESTING_ENABLED ` + and :setting:`MAILTRAP_TEST_INBOX_ID `. + +.. _REST API: https://api-docs.mailtrap.io/docs/mailtrap-api-docs/ + + +Settings +-------- + +.. rubric:: EMAIL_BACKEND + +To use Anymail's Mailtrap backend, set: + + .. code-block:: python + + EMAIL_BACKEND = "anymail.backends.mailtrap.EmailBackend" + +in your settings.py. + + +.. setting:: ANYMAIL_MAILTRAP_API_TOKEN + +.. rubric:: MAILTRAP_API_TOKEN + +Required for sending: + + .. code-block:: python + + ANYMAIL = { + ... + "MAILTRAP_API_TOKEN": "", + } + +Anymail will also look for ``MAILTRAP_API_TOKEN`` at the +root of the settings file if neither ``ANYMAIL["MAILTRAP_API_TOKEN"]`` +nor ``ANYMAIL_MAILTRAP_API_TOKEN`` is set. + + +.. setting:: ANYMAIL_MAILTRAP_API_URL + +.. rubric:: MAILTRAP_API_URL + +The base url for calling the Mailtrap API. + +The default is ``MAILTRAP_API_URL = "https://send.api.mailtrap.io/api"``, which connects +to Mailtrap's transactional service. You must change this if you are using Mailtrap's bulk +send service. For example, to use the bulk send service: + + .. code-block:: python + + ANYMAIL = { + "MAILTRAP_API_TOKEN": "...", + "MAILTRAP_API_URL": "https://bulk.api.mailtrap.io/api", + # ... + } + + +.. setting:: ANYMAIL_MAILTRAP_TESTING_ENABLED + +.. rubric:: MAILTRAP_TESTING_ENABLED + +Use Mailtrap's test email service by setting this to ``True``, and providing +:setting:`MAILTRAP_TEST_INBOX_ID `: + + .. code-block:: python + + ANYMAIL = { + "MAILTRAP_API_TOKEN": "...", + "MAILTRAP_TESTING_ENABLED": True, + "MAILTRAP_TEST_INBOX_ID": "", + # ... + } + +By default, Anymail will switch to using Mailtrap's test email service API: ``https://sandbox.api.mailtrap.io/api``. + +.. setting:: ANYMAIL_MAILTRAP_TEST_INBOX_ID + +.. rubric:: MAILTRAP_TEST_INBOX_ID + +Required if :setting:`MAILTRAP_TESTING_ENABLED ` is ``True``. + + +.. _mailtrap-quirks: + +Limitations and quirks +---------------------- + +**merge_metadata unsupported** + Mailtrap supports :ref:`ESP stored templates `, + but does NOT support per-recipient merge data via their :ref:`batch sending ` + service. + + +.. _mailtrap-webhooks: + +Status tracking webhooks +------------------------ + +If you are using Anymail's normalized :ref:`status tracking `, enter +the url in the Mailtrap webhooks config for your domain. (Note that Mailtrap's sandbox domain +does not trigger webhook events.) + + +.. _About Mailtrap webhooks: https://help.mailtrap.io/article/102-webhooks +.. _Mailtrap webhook payload: https://api-docs.mailtrap.io/docs/mailtrap-api-docs/016fe2a1efd5a-receive-events-json-format diff --git a/pyproject.toml b/pyproject.toml index b4f67861..c7234d7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,8 @@ authors = [ ] description = """\ Django email backends and webhooks for Amazon SES, Brevo, - MailerSend, Mailgun, Mailjet, Mandrill, Postal, Postmark, Resend, - SendGrid, SparkPost and Unisender Go + MailerSend, Mailgun, Mailjet, Mailtrap, Mandrill, Postal, Postmark, + Resend, SendGrid, SparkPost and Unisender Go (EmailBackend, transactional email tracking and inbound email signals)\ """ # readme: see tool.hatch.metadata.hooks.custom below @@ -26,6 +26,7 @@ keywords = [ "Brevo", "SendinBlue", "MailerSend", "Mailgun", "Mailjet", "Sinch", + "Mailtrap", "Mandrill", "MailChimp", "Postal", "Postmark", "ActiveCampaign", @@ -63,6 +64,7 @@ dependencies = [ "django>=4.0", "requests>=2.4.3", "urllib3>=1.25.0", # requests dependency: fixes RFC 7578 header encoding + "typing_extensions>=4.12;python_version<'3.11'", # for older Python compatibility ] [project.optional-dependencies] @@ -74,6 +76,7 @@ brevo = [] mailersend = [] mailgun = [] mailjet = [] +mailtrap = [] mandrill = [] postmark = [] resend = ["svix"] diff --git a/tests/test_mailtrap_backend.py b/tests/test_mailtrap_backend.py new file mode 100644 index 00000000..e8e83437 --- /dev/null +++ b/tests/test_mailtrap_backend.py @@ -0,0 +1,299 @@ +# FILE: tests/test_mailtrap_backend.py + +import unittest +from datetime import datetime +from decimal import Decimal + +from django.core import mail +from django.core.exceptions import ImproperlyConfigured +from django.test import SimpleTestCase, override_settings, tag +from django.utils.timezone import timezone + +from anymail.exceptions import ( + AnymailAPIError, + AnymailRecipientsRefused, + AnymailSerializationError, + AnymailUnsupportedFeature, +) +from anymail.message import attach_inline_image + +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import AnymailTestMixin, sample_image_content + + +@tag("mailtrap") +@override_settings( + EMAIL_BACKEND="anymail.backends.mailtrap.EmailBackend", + ANYMAIL={"MAILTRAP_API_TOKEN": "test_api_token"}, +) +class MailtrapBackendMockAPITestCase(RequestsBackendMockAPITestCase): + DEFAULT_RAW_RESPONSE = b"""{ + "success": true, + "message_ids": ["1df37d17-0286-4d8b-8edf-bc4ec5be86e6"] + }""" + + def setUp(self): + super().setUp() + self.message = mail.EmailMultiAlternatives( + "Subject", "Body", "from@example.com", ["to@example.com"] + ) + + def test_send_email(self): + """Test sending a basic email""" + response = self.message.send() + self.assertEqual(response, 1) + self.assert_esp_called("https://send.api.mailtrap.io/api/send") + + def test_send_with_attachments(self): + """Test sending an email with attachments""" + self.message.attach("test.txt", "This is a test", "text/plain") + response = self.message.send() + self.assertEqual(response, 1) + self.assert_esp_called("https://send.api.mailtrap.io/api/send") + + def test_send_with_inline_image(self): + """Test sending an email with inline images""" + image_data = sample_image_content() # Read from a png file + + cid = attach_inline_image(self.message, image_data) + html_content = ( + '

This has an inline image.

' % cid + ) + self.message.attach_alternative(html_content, "text/html") + + response = self.message.send() + self.assertEqual(response, 1) + self.assert_esp_called("https://send.api.mailtrap.io/api/send") + + def test_send_with_metadata(self): + """Test sending an email with metadata""" + self.message.metadata = {"user_id": "12345"} + response = self.message.send() + self.assertEqual(response, 1) + self.assert_esp_called("https://send.api.mailtrap.io/api/send") + + def test_send_with_tag(self): + """Test sending an email with one tag""" + self.message.tags = ["tag1"] + response = self.message.send() + self.assertEqual(response, 1) + self.assert_esp_called("https://send.api.mailtrap.io/api/send") + + def test_send_with_tags(self): + """Test sending an email with tags""" + self.message.tags = ["tag1", "tag2"] + with self.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + def test_send_with_template(self): + """Test sending an email with a template""" + self.message.template_id = "template_id" + response = self.message.send() + self.assertEqual(response, 1) + self.assert_esp_called("https://send.api.mailtrap.io/api/send") + + def test_send_with_merge_data(self): + """Test sending an email with merge data""" + self.message.merge_data = {"to@example.com": {"name": "Recipient"}} + with self.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + def test_send_with_invalid_api_token(self): + """Test sending an email with an invalid API token""" + self.set_mock_response(status_code=401, raw=b'{"error": "Invalid API token"}') + with self.assertRaises(AnymailAPIError): + self.message.send() + + @unittest.skip("TODO: is this test correct/necessary?") + def test_send_with_recipients_refused(self): + """Test sending an email with all recipients refused""" + self.set_mock_response( + status_code=400, raw=b'{"error": "All recipients refused"}' + ) + with self.assertRaises(AnymailRecipientsRefused): + self.message.send() + + def test_send_with_serialization_error(self): + """Test sending an email with a serialization error""" + self.message.extra_headers = { + "foo": Decimal("1.23") + } # Decimal can't be serialized + with self.assertRaises(AnymailSerializationError) as cm: + self.message.send() + err = cm.exception + self.assertIsInstance(err, TypeError) + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") + + def test_send_with_api_error(self): + """Test sending an email with a generic API error""" + self.set_mock_response( + status_code=500, raw=b'{"error": "Internal server error"}' + ) + with self.assertRaises(AnymailAPIError): + self.message.send() + + def test_send_with_headers_and_recipients(self): + """Test sending an email with headers and multiple recipients""" + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com", "Also To "], + bcc=["bcc1@example.com", "Also BCC "], + cc=["cc1@example.com", "Also CC "], + headers={ + "Reply-To": "another@example.com", + "X-MyHeader": "my value", + "Message-ID": "mycustommsgid@example.com", + }, + ) + email.send() + data = self.get_api_call_json() + self.assertEqual(data["subject"], "Subject") + self.assertEqual(data["text"], "Body goes here") + self.assertEqual(data["from"]["email"], "from@example.com") + self.assertEqual( + data["headers"], + { + "Reply-To": "another@example.com", + "X-MyHeader": "my value", + "Message-ID": "mycustommsgid@example.com", + }, + ) + # Verify recipients correctly identified as "to", "cc", or "bcc" + self.assertEqual( + data["to"], + [ + {"email": "to1@example.com"}, + {"email": "to2@example.com", "name": "Also To"}, + ], + ) + self.assertEqual( + data["cc"], + [ + {"email": "cc1@example.com"}, + {"email": "cc2@example.com", "name": "Also CC"}, + ], + ) + self.assertEqual( + data["bcc"], + [ + {"email": "bcc1@example.com"}, + {"email": "bcc2@example.com", "name": "Also BCC"}, + ], + ) + + +@tag("mailtrap") +class MailtrapBackendAnymailFeatureTests(MailtrapBackendMockAPITestCase): + """Test backend support for Anymail added features""" + + def test_envelope_sender(self): + self.message.envelope_sender = "envelope@example.com" + with self.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + def test_metadata(self): + self.message.metadata = {"user_id": "12345"} + response = self.message.send() + self.assertEqual(response, 1) + data = self.get_api_call_json() + self.assertEqual(data["custom_variables"], {"user_id": "12345"}) + + def test_send_at(self): + send_at = datetime(2023, 10, 1, 12, 0, 0, tzinfo=timezone.utc) + self.message.send_at = send_at + with self.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + def test_tags(self): + self.message.tags = ["tag1"] + response = self.message.send() + self.assertEqual(response, 1) + data = self.get_api_call_json() + self.assertEqual(data["category"], "tag1") + + def test_tracking(self): + self.message.track_clicks = True + self.message.track_opens = True + response = self.message.send() + self.assertEqual(response, 1) + + def test_template_id(self): + self.message.template_id = "template_id" + response = self.message.send() + self.assertEqual(response, 1) + data = self.get_api_call_json() + self.assertEqual(data["template_uuid"], "template_id") + + def test_merge_data(self): + self.message.merge_data = {"to@example.com": {"name": "Recipient"}} + with self.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + def test_merge_global_data(self): + self.message.merge_global_data = {"global_name": "Global Recipient"} + response = self.message.send() + self.assertEqual(response, 1) + data = self.get_api_call_json() + self.assertEqual( + data["template_variables"], {"global_name": "Global Recipient"} + ) + + def test_esp_extra(self): + self.message.esp_extra = {"custom_option": "value"} + response = self.message.send() + self.assertEqual(response, 1) + data = self.get_api_call_json() + self.assertEqual(data["custom_option"], "value") + + +@tag("mailtrap") +class MailtrapBackendRecipientsRefusedTests(MailtrapBackendMockAPITestCase): + """ + Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid + """ + + @unittest.skip("TODO: is this test correct/necessary?") + def test_recipients_refused(self): + self.set_mock_response( + status_code=400, raw=b'{"error": "All recipients refused"}' + ) + with self.assertRaises(AnymailRecipientsRefused): + self.message.send() + + @unittest.skip( + "TODO: is this test correct/necessary? How to handle this in mailtrap backend?" + ) + def test_fail_silently(self): + self.set_mock_response( + status_code=400, raw=b'{"error": "All recipients refused"}' + ) + self.message.fail_silently = True + sent = self.message.send() + self.assertEqual(sent, 0) + + +@tag("mailtrap") +class MailtrapBackendSessionSharingTestCase( + SessionSharingTestCases, MailtrapBackendMockAPITestCase +): + """Requests session sharing tests""" + + pass # tests are defined in SessionSharingTestCases + + +@tag("mailtrap") +@override_settings(EMAIL_BACKEND="anymail.backends.mailtrap.EmailBackend") +class MailtrapBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): + """Test ESP backend without required settings in place""" + + def test_missing_api_token(self): + with self.assertRaises(ImproperlyConfigured) as cm: + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) + errmsg = str(cm.exception) + self.assertRegex(errmsg, r"\bMAILTRAP_API_TOKEN\b") + self.assertRegex(errmsg, r"\bANYMAIL_MAILTRAP_API_TOKEN\b") diff --git a/tests/test_mailtrap_webhooks.py b/tests/test_mailtrap_webhooks.py new file mode 100644 index 00000000..3c547ba4 --- /dev/null +++ b/tests/test_mailtrap_webhooks.py @@ -0,0 +1,374 @@ +from datetime import datetime, timezone +from unittest.mock import ANY + +from django.test import tag + +from anymail.signals import AnymailTrackingEvent +from anymail.webhooks.mailtrap import MailtrapTrackingWebhookView + +from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase + + +@tag("mailtrap") +class MailtrapWebhookSecurityTestCase(WebhookBasicAuthTestCase): + def call_webhook(self): + return self.client.post( + "/anymail/mailtrap/tracking/", + content_type="application/json", + data={}, + ) + + # Actual tests are in WebhookBasicAuthTestCase + + +@tag("mailtrap") +class MailtrapDeliveryTestCase(WebhookTestCase): + def test_sent_event(self): + payload = { + "events": [ + { + "event": "delivery", + "timestamp": 1498093527, + "sending_stream": "transactional", + "category": "password-reset", + "custom_variables": {"variable_a": "value", "variable_b": "value2"}, + "message_id": "1df37d17-0286-4d8b-8edf-bc4ec5be86e6", + "email": "receiver@example.com", + "event_id": "bede7236-2284-43d6-a953-1fdcafd0fdbc", + }, + ] + } + response = self.client.post( + "/anymail/mailtrap/tracking/", + content_type="application/json", + data=payload, + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailtrapTrackingWebhookView, + event=ANY, + esp_name="Mailtrap", + ) + event = kwargs["event"] + self.assertIsInstance(event, AnymailTrackingEvent) + self.assertEqual(event.event_type, "delivered") + self.assertEqual( + event.timestamp, datetime(2017, 6, 22, 1, 5, 27, tzinfo=timezone.utc) + ) + self.assertEqual(event.esp_event, payload["events"][0]) + self.assertEqual( + event.mta_response, + None, + ) + self.assertEqual(event.message_id, "1df37d17-0286-4d8b-8edf-bc4ec5be86e6") + self.assertEqual(event.recipient, "receiver@example.com") + self.assertEqual(event.tags, ["password-reset"]) + self.assertEqual( + event.metadata, {"variable_a": "value", "variable_b": "value2"} + ) + + def test_open_event(self): + payload = { + "events": [ + { + "event": "open", + "timestamp": 1498093527, + "sending_stream": "transactional", + "message_id": "1df37d17-0286-4d8b-8edf-bc4ec5be86e6", + "email": "receiver@example.com", + "event_id": "bede7236-2284-43d6-a953-1fdcafd0fdbc", + "ip": "192.168.1.42", + "user_agent": "Mozilla/5.0 (via ggpht.com GoogleImageProxy)", + }, + ] + } + response = self.client.post( + "/anymail/mailtrap/tracking/", + content_type="application/json", + data=payload, + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailtrapTrackingWebhookView, + event=ANY, + esp_name="Mailtrap", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "opened") + self.assertEqual(event.message_id, "1df37d17-0286-4d8b-8edf-bc4ec5be86e6") + self.assertEqual(event.recipient, "receiver@example.com") + self.assertEqual( + event.user_agent, "Mozilla/5.0 (via ggpht.com GoogleImageProxy)" + ) + self.assertEqual(event.tags, []) + self.assertEqual(event.metadata, {}) + + def test_click_event(self): + payload = { + "events": [ + { + "event": "click", + "timestamp": 1498093527, + "sending_stream": "transactional", + "message_id": "1df37d17-0286-4d8b-8edf-bc4ec5be86e6", + "email": "receiver@example.com", + "event_id": "bede7236-2284-43d6-a953-1fdcafd0fdbc", + "category": "custom-value", + "custom_variables": {"testing": True}, + "ip": "192.168.1.42", + "user_agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) Chrome/58.0.3029.110)" + ), + "url": "http://example.com/anymail", + }, + ] + } + response = self.client.post( + "/anymail/mailtrap/tracking/", + content_type="application/json", + data=payload, + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailtrapTrackingWebhookView, + event=ANY, + esp_name="Mailtrap", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "clicked") + self.assertEqual(event.message_id, "1df37d17-0286-4d8b-8edf-bc4ec5be86e6") + self.assertEqual(event.recipient, "receiver@example.com") + self.assertEqual( + event.user_agent, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) Chrome/58.0.3029.110)", + ) + self.assertEqual(event.click_url, "http://example.com/anymail") + self.assertEqual(event.tags, ["custom-value"]) + self.assertEqual(event.metadata, {"testing": True}) + + def test_bounce_event(self): + payload = { + "events": [ + { + "event": "bounce", + "timestamp": 1498093527, + "sending_stream": "transactional", + "message_id": "1df37d17-0286-4d8b-8edf-bc4ec5be86e6", + "email": "invalid@example.com", + "event_id": "bede7236-2284-43d6-a953-1fdcafd0fdbc", + "category": "custom-value", + "custom_variables": {"testing": True}, + "response": ( + "bounced (550 5.1.1 The email account that you tried to reach " + "does not exist. a67bc12345def.22 - gsmtp)" + ), + "response_code": 550, + "bounce_category": "hard", + }, + ] + } + response = self.client.post( + "/anymail/mailtrap/tracking/", + content_type="application/json", + data=payload, + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailtrapTrackingWebhookView, + event=ANY, + esp_name="Mailtrap", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "bounced") + self.assertEqual(event.message_id, "1df37d17-0286-4d8b-8edf-bc4ec5be86e6") + self.assertEqual(event.recipient, "invalid@example.com") + self.assertEqual(event.reject_reason, "bounced") + self.assertEqual( + event.mta_response, + ( + "bounced (550 5.1.1 The email account that you tried to reach does not exist. " + "a67bc12345def.22 - gsmtp)" + ), + ) + + def test_soft_bounce_event(self): + payload = { + "events": [ + { + "event": "soft bounce", + "timestamp": 1498093527, + "sending_stream": "transactional", + "message_id": "1df37d17-0286-4d8b-8edf-bc4ec5be86e6", + "email": "receiver@example.com", + "event_id": "bede7236-2284-43d6-a953-1fdcafd0fdbc", + "response": ( + "soft bounce (450 4.2.0 The email account that you tried to reach is " + "temporarily unavailable. a67bc12345def.22 - gsmtp)" + ), + "response_code": 450, + "bounce_category": "unavailable", + }, + ] + } + response = self.client.post( + "/anymail/mailtrap/tracking/", + content_type="application/json", + data=payload, + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailtrapTrackingWebhookView, + event=ANY, + esp_name="Mailtrap", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "deferred") + self.assertEqual(event.message_id, "1df37d17-0286-4d8b-8edf-bc4ec5be86e6") + self.assertEqual(event.recipient, "receiver@example.com") + self.assertEqual(event.reject_reason, "other") + self.assertEqual( + event.mta_response, + ( + "soft bounce (450 4.2.0 The email account that you tried to reach is " + "temporarily unavailable. a67bc12345def.22 - gsmtp)" + ), + ) + + def test_spam_event(self): + payload = { + "events": [ + { + "event": "spam", + "timestamp": 1498093527, + "sending_stream": "transactional", + "message_id": "1df37d17-0286-4d8b-8edf-bc4ec5be86e6", + "email": "receiver@example.com", + "event_id": "bede7236-2284-43d6-a953-1fdcafd0fdbc", + }, + ] + } + response = self.client.post( + "/anymail/mailtrap/tracking/", + content_type="application/json", + data=payload, + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailtrapTrackingWebhookView, + event=ANY, + esp_name="Mailtrap", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "complained") + self.assertEqual(event.message_id, "1df37d17-0286-4d8b-8edf-bc4ec5be86e6") + self.assertEqual(event.recipient, "receiver@example.com") + self.assertEqual(event.reject_reason, "spam") + + def test_unsubscribe_event(self): + payload = { + "events": [ + { + "event": "unsubscribe", + "timestamp": 1498093527, + "sending_stream": "transactional", + "message_id": "1df37d17-0286-4d8b-8edf-bc4ec5be86e6", + "email": "receiver@example.com", + "event_id": "bede7236-2284-43d6-a953-1fdcafd0fdbc", + "ip": "192.168.1.42", + "user_agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) Chrome/58.0.3029.110)" + ), + }, + ] + } + response = self.client.post( + "/anymail/mailtrap/tracking/", + content_type="application/json", + data=payload, + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailtrapTrackingWebhookView, + event=ANY, + esp_name="Mailtrap", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "unsubscribed") + self.assertEqual(event.message_id, "1df37d17-0286-4d8b-8edf-bc4ec5be86e6") + self.assertEqual(event.recipient, "receiver@example.com") + self.assertEqual(event.reject_reason, "unsubscribed") + self.assertEqual( + event.user_agent, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) Chrome/58.0.3029.110)", + ) + + def test_suspension_event(self): + payload = { + "events": [ + { + "event": "suspension", + "timestamp": 1498093527, + "sending_stream": "transactional", + "message_id": "1df37d17-0286-4d8b-8edf-bc4ec5be86e6", + "email": "receiver@example.com", + "event_id": "bede7236-2284-43d6-a953-1fdcafd0fdbc", + "reason": "other", + }, + ] + } + response = self.client.post( + "/anymail/mailtrap/tracking/", + content_type="application/json", + data=payload, + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailtrapTrackingWebhookView, + event=ANY, + esp_name="Mailtrap", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "deferred") + self.assertEqual(event.message_id, "1df37d17-0286-4d8b-8edf-bc4ec5be86e6") + self.assertEqual(event.recipient, "receiver@example.com") + self.assertEqual(event.reject_reason, "other") + + def test_reject_event(self): + payload = { + "events": [ + { + "event": "reject", + "timestamp": 1498093527, + "sending_stream": "transactional", + "message_id": "1df37d17-0286-4d8b-8edf-bc4ec5be86e6", + "email": "receiver@example.com", + "event_id": "bede7236-2284-43d6-a953-1fdcafd0fdbc", + "reason": "unknown", + }, + ] + } + response = self.client.post( + "/anymail/mailtrap/tracking/", + content_type="application/json", + data=payload, + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailtrapTrackingWebhookView, + event=ANY, + esp_name="Mailtrap", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "rejected") + self.assertEqual(event.message_id, "1df37d17-0286-4d8b-8edf-bc4ec5be86e6") + self.assertEqual(event.recipient, "receiver@example.com") + self.assertEqual(event.reject_reason, "blocked") diff --git a/tox.ini b/tox.ini index f37f70f9..4c352b24 100644 --- a/tox.ini +++ b/tox.ini @@ -58,6 +58,7 @@ setenv = mailersend: ANYMAIL_ONLY_TEST=mailersend mailgun: ANYMAIL_ONLY_TEST=mailgun mailjet: ANYMAIL_ONLY_TEST=mailjet + mailtrap: ANYMAIL_ONLY_TEST=mailtrap mandrill: ANYMAIL_ONLY_TEST=mandrill postal: ANYMAIL_ONLY_TEST=postal postmark: ANYMAIL_ONLY_TEST=postmark