From ee9de922e89625ca02b0910663fb8d1008e65cdc Mon Sep 17 00:00:00 2001 From: Conor Heine Date: Sun, 10 Nov 2024 14:08:56 -0800 Subject: [PATCH 01/14] Add support for Mailtrap --- .github/workflows/integration-test.yml | 5 + README.rst | 1 + anymail/backends/mailtrap.py | 266 ++++++++++++++++++ anymail/urls.py | 6 + anymail/webhooks/mailtrap.py | 100 +++++++ docs/esps/esp-feature-matrix.csv | 42 +-- docs/esps/index.rst | 1 + docs/esps/mailtrap.rst | 120 ++++++++ pyproject.toml | 5 +- tests/test_mailtrap_backend.py | 288 +++++++++++++++++++ tests/test_mailtrap_integration.py | 145 ++++++++++ tests/test_mailtrap_webhooks.py | 374 +++++++++++++++++++++++++ tox.ini | 1 + 13 files changed, 1332 insertions(+), 22 deletions(-) create mode 100644 anymail/backends/mailtrap.py create mode 100644 anymail/webhooks/mailtrap.py create mode 100644 docs/esps/mailtrap.rst create mode 100644 tests/test_mailtrap_backend.py create mode 100644 tests/test_mailtrap_integration.py create mode 100644 tests/test_mailtrap_webhooks.py diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 674f79b7..017afd52 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -44,6 +44,7 @@ jobs: - { tox: django52-py313-mailersend, python: "3.13" } - { tox: django52-py313-mailgun, python: "3.13" } - { tox: django52-py313-mailjet, python: "3.13" } + - { tox: django41-py310-mailtrap, python: "3.13" } - { tox: django52-py313-mandrill, python: "3.13" } - { tox: django52-py313-postal, python: "3.13" } - { tox: django52-py313-postmark, python: "3.13" } @@ -89,6 +90,10 @@ jobs: ANYMAIL_TEST_MAILJET_DOMAIN: ${{ vars.ANYMAIL_TEST_MAILJET_DOMAIN }} ANYMAIL_TEST_MAILJET_SECRET_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_SECRET_KEY }} ANYMAIL_TEST_MAILJET_TEMPLATE_ID: ${{ vars.ANYMAIL_TEST_MAILJET_TEMPLATE_ID }} + ANYMAIL_TEST_MAILTRAP_API_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILTRAP_API_TOKEN }} + ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID: ${{ vars.ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID }} + ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID: ${{ vars.ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID }} + ANYMAIL_TEST_MAILTRAP_DOMAIN: ${{ vars.ANYMAIL_TEST_MAILTRAP_DOMAIN }} ANYMAIL_TEST_MANDRILL_API_KEY: ${{ secrets.ANYMAIL_TEST_MANDRILL_API_KEY }} ANYMAIL_TEST_MANDRILL_DOMAIN: ${{ secrets.ANYMAIL_TEST_MANDRILL_DOMAIN }} ANYMAIL_TEST_POSTMARK_DOMAIN: ${{ secrets.ANYMAIL_TEST_POSTMARK_DOMAIN }} diff --git a/README.rst b/README.rst index b3782231..b118de10 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..95f927b7 --- /dev/null +++ b/anymail/backends/mailtrap.py @@ -0,0 +1,266 @@ +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 = { + "disposition": "attachment", + "filename": attachment.name, + "content": attachment.b64content, + } + if attachment.mimetype: + att["type"] = attachment.mimetype + if attachment.inline: + if not attachment.cid: + self.unsupported_feature("inline attachment without content-id") + att["disposition"] = "inline" + att["content_id"] = attachment.cid + elif not attachment.name: + self.unsupported_feature("attachment without filename") + 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_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=parsed_response["message_ids"][0], + status="sent", + ) + for email in recipient_status_order + } + 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 ead22453..b9813257 100644 --- a/docs/esps/esp-feature-matrix.csv +++ b/docs/esps/esp-feature-matrix.csv @@ -1,21 +1,21 @@ -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:`scaleway-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend` -Anymail support status [#support-status]_,Full,Full,Full,Full,Full,Limited,Limited,Full,Full,Full,**Unsupported**,Full,Full -.. rubric:: :ref:`Anymail send options `,,,,,,,,,,,,, -:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,No,Yes,No -:attr:`~AnymailMessage.merge_headers`,Yes [#caveats]_,Yes,No,Yes,Yes,No,No,Yes,Yes,No,Yes,Yes [#caveats]_,Yes [#caveats]_ -:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_metadata`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes,No,Yes,Yes,No,Yes,Yes,Yes -:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,Yes,No,Yes,Yes,Yes -:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Yes,Max 1 tag,Yes -:attr:`~AnymailMessage.track_clicks`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes -:attr:`~AnymailMessage.track_opens`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes -:ref:`amp-email`,Yes,No,No,Yes,No,No,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,No,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_global_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,No,Yes,No,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,Yes -:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Not yet,Yes,Yes,Yes -.. rubric:: :ref:`Inbound handling `,,,,,,,,,,,,, -:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,No,Yes,Yes,No +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:`scaleway-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend` +Anymail support status [#support-status]_,Full,Full,Full,Full,Full,Limited,Limited,Limited,Full,Full,Full,**Unsupported**,Full,Full +.. rubric:: :ref:`Anymail send options `,,,,,,,,,,,,,, +:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,No,Domain only,Yes,No,No,No,No,Yes,No +:attr:`~AnymailMessage.merge_headers`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes,No,No,Yes,Yes,No,Yes,Yes [#caveats]_,Yes [#caveats]_ +:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_metadata`,Yes [#caveats]_,Yes,No,Yes,Yes,No,Yes,No,Yes,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,No,Yes,No,No,Yes,No,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,Yes,Max 1 tag,Yes +:attr:`~AnymailMessage.track_clicks`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,No [#nocontrol]_,Yes,No,Yes,No,No,Yes,Yes,Yes +:attr:`~AnymailMessage.track_opens`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,No [#nocontrol]_,Yes,No,Yes,No,No,Yes,Yes,Yes +:ref:`amp-email`,Yes,No,No,Yes,No,Yes,No,No,No,No,No,Yes,Yes,Yes +.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,,,, +:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,No,Yes,No,Yes,No,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_global_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,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,Yes,Yes +:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Not yet,Yes,Yes,Yes +.. rubric:: :ref:`Inbound handling `,,,,,,,,,,,,,, +:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes,Yes,No,No,Yes,Yes,No diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 783e72f8..22f1964d 100644 --- a/docs/esps/index.rst +++ b/docs/esps/index.rst @@ -17,6 +17,7 @@ and notes about any quirks or limitations: mailersend mailgun mailjet + mailtrap mandrill postal postmark diff --git a/docs/esps/mailtrap.rst b/docs/esps/mailtrap.rst new file mode 100644 index 00000000..e1a4ba3e --- /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 d30f55a2..7b30dbea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ authors = [ ] description = """\ Django email backends and webhooks for Amazon SES, Brevo, - MailerSend, Mailgun, Mailjet, Mandrill, Postal, Postmark, Resend, + MailerSend, Mailgun, Mailjet, Mailtrap, Mandrill, Postal, Postmark, Resend, Scaleway TEM, SendGrid, SparkPost, and Unisender Go (EmailBackend, transactional email tracking and inbound email signals)\ """ @@ -26,6 +26,7 @@ keywords = [ "Brevo", "SendinBlue", "MailerSend", "Mailgun", "Mailjet", "Sinch", + "Mailtrap", "Mandrill", "MailChimp", "Postal", "Postmark", "ActiveCampaign", @@ -66,6 +67,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] @@ -77,6 +79,7 @@ brevo = [] mailersend = [] mailgun = [] mailjet = [] +mailtrap = [] mandrill = [] postal = [ # Postal requires cryptography for verifying webhooks. diff --git a/tests/test_mailtrap_backend.py b/tests/test_mailtrap_backend.py new file mode 100644 index 00000000..3ba34450 --- /dev/null +++ b/tests/test_mailtrap_backend.py @@ -0,0 +1,288 @@ +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() + + 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 + with self.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + 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_integration.py b/tests/test_mailtrap_integration.py new file mode 100644 index 00000000..a22eba2b --- /dev/null +++ b/tests/test_mailtrap_integration.py @@ -0,0 +1,145 @@ +import os +import unittest +from email.utils import formataddr + +from django.test import SimpleTestCase, override_settings, tag + +from anymail.exceptions import AnymailAPIError +from anymail.message import AnymailMessage + +from .utils import AnymailTestMixin, sample_image_path + +ANYMAIL_TEST_MAILTRAP_API_TOKEN = os.getenv("ANYMAIL_TEST_MAILTRAP_API_TOKEN") +ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID = os.getenv("ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID") +# Optional: if provided, use for nicer From address; sandbox doesn't require this +ANYMAIL_TEST_MAILTRAP_DOMAIN = os.getenv("ANYMAIL_TEST_MAILTRAP_DOMAIN") +ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID = os.getenv("ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID") + + +@tag("mailtrap", "live") +@unittest.skipUnless( + ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID, + "Set ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID" + " environment variables to run Mailtrap integration tests", +) +@override_settings( + ANYMAIL={ + "MAILTRAP_API_TOKEN": ANYMAIL_TEST_MAILTRAP_API_TOKEN, + # Use Mailtrap sandbox (testing) API so we don't actually send email + "MAILTRAP_TESTING": True, + "MAILTRAP_TEST_INBOX_ID": ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID, + # You can override MAILTRAP_TEST_API_URL via env if needed; default is fine + }, + EMAIL_BACKEND="anymail.backends.mailtrap.EmailBackend", +) +class MailtrapBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): + """Mailtrap API integration tests (using sandbox testing inbox) + + These tests run against the live Mailtrap API in testing mode, using + ANYMAIL_TEST_MAILTRAP_API_TOKEN for authentication and + ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID for the sandbox inbox id. No real + email is sent in this mode. + """ + + def setUp(self): + super().setUp() + from_domain = ANYMAIL_TEST_MAILTRAP_DOMAIN or "anymail.dev" + self.from_email = f"from@{from_domain}" + self.message = AnymailMessage( + "Anymail Mailtrap integration test", + "Text content", + self.from_email, + ["test+to1@anymail.dev"], + ) + self.message.attach_alternative("

HTML content

", "text/html") + + def test_simple_send(self): + # Example of getting the Mailtrap send status and message id from the message + sent_count = self.message.send() + self.assertEqual(sent_count, 1) + + anymail_status = self.message.anymail_status + sent_status = anymail_status.recipients["test+to1@anymail.dev"].status + message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id + + self.assertEqual(sent_status, "sent") # Mailtrap reports sent on success + self.assertRegex(message_id, r".+") # non-empty string + # set of all recipient statuses: + self.assertEqual(anymail_status.status, {sent_status}) + self.assertEqual(anymail_status.message_id, message_id) + + def test_all_options(self): + message = AnymailMessage( + subject="Anymail Mailtrap all-options integration test", + body="This is the text body", + from_email=formataddr(("Test From, with comma", self.from_email)), + to=[ + "test+to1@anymail.dev", + "Recipient 2 ", + ], + cc=["test+cc1@anymail.dev", "Copy 2 "], + bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], + reply_to=[ + '"Reply, with comma" ', + "reply2@example.com", + ], + headers={"X-Anymail-Test": "value", "X-Anymail-Count": "3"}, + metadata={"meta1": "simple string", "meta2": 2}, + # Mailtrap supports only a single tag/category + tags=["tag 1"], + track_clicks=True, + track_opens=True, + ) + message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") + message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") + cid = message.attach_inline_image_file(sample_image_path()) + message.attach_alternative( + "

HTML: with link" + f"and image: ", + "text/html", + ) + + message.send() + self.assertEqual(message.anymail_status.status, {"sent"}) + self.assertEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].status, "sent" + ) + self.assertEqual( + message.anymail_status.recipients["test+to2@anymail.dev"].status, "sent" + ) + + @unittest.skipUnless( + ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, + "Set ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID to test Mailtrap stored templates", + ) + def test_stored_template(self): + message = AnymailMessage( + # UUID of a template available in your Mailtrap account + template_id=ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, + to=["test+to1@anymail.dev", "Second Recipient "], + merge_global_data={ # Mailtrap uses template_variables for global vars + "company_info_name": "Test_Company_info_name", + "name": "Test_Name", + "company_info_address": "Test_Company_info_address", + "company_info_city": "Test_Company_info_city", + "company_info_zip_code": "Test_Company_info_zip_code", + "company_info_country": "Test_Company_info_country", + }, + ) + # Use template's configured sender if desired + message.from_email = self.from_email + message.send() + self.assertEqual(message.anymail_status.status, {"sent"}) + + @override_settings( + ANYMAIL={ + "MAILTRAP_API_TOKEN": "Hey, that's not an API token!", + "MAILTRAP_TESTING": True, + "MAILTRAP_TEST_INBOX_ID": ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID, + } + ) + def test_invalid_api_token(self): + with self.assertRaises(AnymailAPIError) as cm: + self.message.send() + err = cm.exception + self.assertEqual(err.status_code, 401) 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 ac16f9cf..2f4d03cb 100644 --- a/tox.ini +++ b/tox.ini @@ -60,6 +60,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 From 25d433bdb33f94f16b9af6899672ed5632787a92 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Thu, 16 Oct 2025 19:09:41 -0700 Subject: [PATCH 02/14] Simplify settings, rework parse_recipient_response, fix typos - Remove MAILTRAP_TESTING_ENABLED setting; assume sandbox mode when MAILTRAP_TEST_INBOX_ID is provided. - Rename backend `testing_enabled` prop to `use_sandbox`. (Mailtrap seems to use the word "sandbox" throughout their docs.) - Rework parse_recipient_status to work with either transactional or sandbox API response format. (Needs more tests.) - Fix some typos and minor errors. --- .github/workflows/integration-test.yml | 2 +- CHANGELOG.rst | 13 +++ anymail/backends/mailtrap.py | 116 ++++++++++++++----------- docs/esps/mailtrap.rst | 74 ++++++++-------- tests/test_mailtrap_backend.py | 56 ++++++++++-- tests/test_mailtrap_integration.py | 2 - 6 files changed, 162 insertions(+), 101 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 017afd52..894ecb12 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -44,7 +44,7 @@ jobs: - { tox: django52-py313-mailersend, python: "3.13" } - { tox: django52-py313-mailgun, python: "3.13" } - { tox: django52-py313-mailjet, python: "3.13" } - - { tox: django41-py310-mailtrap, python: "3.13" } + - { tox: django41-py313-mailtrap, python: "3.13" } - { tox: django52-py313-mandrill, python: "3.13" } - { tox: django52-py313-postal, python: "3.13" } - { tox: django52-py313-postmark, python: "3.13" } diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2a33d3a3..1d780e60 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,18 @@ Release history ^^^^^^^^^^^^^^^ .. This extra heading level keeps the ToC from becoming unmanageably long +vNext +----- + +*Unreleased changes* + +Features +~~~~~~~~ + +* **Mailtrap:** Add support for this ESP. + (See `docs `__. + Thanks to `@cahna`_ for the contribution.) + v13.1 ----- @@ -1819,6 +1831,7 @@ Features .. _@Arondit: https://github.com/Arondit .. _@b0d0nne11: https://github.com/b0d0nne11 .. _@blag: https://github.com/blag +.. _@cahna: https://github.com/cahna .. _@calvin: https://github.com/calvin .. _@carrerasrodrigo: https://github.com/carrerasrodrigo .. _@chickahoona: https://github.com/chickahoona diff --git a/anymail/backends/mailtrap.py b/anymail/backends/mailtrap.py index 95f927b7..cbfa899b 100644 --- a/anymail/backends/mailtrap.py +++ b/anymail/backends/mailtrap.py @@ -39,7 +39,7 @@ class MailtrapAttachment(TypedDict): "text": str, "html": NotRequired[str], "category": NotRequired[str], - "template_id": NotRequired[str], + "template_uuid": NotRequired[str], "template_variables": NotRequired[Dict[str, Any]], }, ) @@ -73,7 +73,7 @@ def __init__( ) def get_api_endpoint(self): - if self.backend.testing_enabled: + if self.backend.use_sandbox: test_inbox_id = quote(self.backend.test_inbox_id, safe="") return f"send/{test_inbox_id}" return "send" @@ -188,48 +188,32 @@ class EmailBackend(AnymailRequestsBackend): esp_name = "Mailtrap" + DEFAULT_API_URL = "https://send.api.mailtrap.io/api/" + DEFAULT_SANDBOX_API_URL = "https://sandbox.api.mailtrap.io/api/" + 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 ) + self.test_inbox_id = get_anymail_setting( + "test_inbox_id", esp_name=self.esp_name, kwargs=kwargs, default=None + ) + self.use_sandbox = self.test_inbox_id is not None + api_url = get_anymail_setting( "api_url", esp_name=self.esp_name, kwargs=kwargs, - default="https://send.api.mailtrap.io/api/", + default=( + self.DEFAULT_SANDBOX_API_URL + if self.use_sandbox + else self.DEFAULT_API_URL + ), ) 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): @@ -240,27 +224,53 @@ def parse_recipient_status( ): 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) - ): + if parsed_response.get("errors") or not parsed_response.get("success"): + # Superclass has already filtered error status responses, so this shouldn't happen. + status = response.status_code raise AnymailRequestsAPIError( - email_message=message, payload=payload, response=response, backend=self + f"Unexpected API failure fields with response status {status}", + 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=parsed_response["message_ids"][0], - status="sent", - ) - for email in recipient_status_order - } - return recipient_status + + try: + message_ids = parsed_response["message_ids"] + except KeyError: + raise AnymailRequestsAPIError( + "Unexpected API response format", + email_message=message, + payload=payload, + response=response, + backend=self, + ) + + # The sandbox API always returns a single message id for all recipients; + # the production API returns one message id per recipient in this order: + recipients = [ + *payload.recipients_to, + *payload.recipients_cc, + *payload.recipients_bcc, + ] + expected_count = 1 if self.use_sandbox else len(recipients) + actual_count = len(message_ids) + if expected_count != actual_count: + raise AnymailRequestsAPIError( + f"Expected {expected_count} message_ids, got {actual_count}", + email_message=message, + payload=payload, + response=response, + backend=self, + ) + if self.use_sandbox: + message_ids = [message_ids[0]] * expected_count + + recipient_status = { + email: AnymailRecipientStatus( + message_id=parsed_response["message_ids"][0], + status="sent", + ) + for email, message_id in zip(recipients, message_ids) + } + return recipient_status diff --git a/docs/esps/mailtrap.rst b/docs/esps/mailtrap.rst index e1a4ba3e..1b9b0da5 100644 --- a/docs/esps/mailtrap.rst +++ b/docs/esps/mailtrap.rst @@ -4,31 +4,25 @@ Mailtrap ======== Anymail integrates with `Mailtrap `_'s -transactional, bulk, or test email services, using the corresponding -`REST API`_. +transactional or test (sandbox) email services, using the +`Mailtrap REST API v2`_. -.. 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/ +.. _Mailtrap REST API v2: 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" + ANYMAIL = { + "MAILTRAP_API_TOKEN": "", + # Optional, to use the sandbox API: + "MAILTRAP_TEST_INBOX_ID": , + } in your settings.py. @@ -51,48 +45,48 @@ 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 +.. setting:: ANYMAIL_MAILTRAP_TEST_INBOX_ID -The base url for calling the Mailtrap API. +.. rubric:: MAILTRAP_TEST_INBOX_ID -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: +Required to use Mailtrap's test inbox. (If not provided, emails will be sent +using Mailbox's transactional API.) .. code-block:: python ANYMAIL = { - "MAILTRAP_API_TOKEN": "...", - "MAILTRAP_API_URL": "https://bulk.api.mailtrap.io/api", - # ... + ... + "MAILTRAP_TEST_INBOX_ID": 12345, } -.. setting:: ANYMAIL_MAILTRAP_TESTING_ENABLED +.. setting:: ANYMAIL_MAILTRAP_API_URL + +.. rubric:: MAILTRAP_API_URL + +The base url for calling the Mailtrap API. -.. rubric:: MAILTRAP_TESTING_ENABLED +The default is ``MAILTRAP_API_URL = "https://send.api.mailtrap.io/api/"`` +(Mailtrap's transactional service) +if :setting:`MAILTRAP_TEST_INBOX_ID ` is not set, +or ``"https://sandbox.api.mailtrap.io/api/"`` (Mailbox's sandbox testing service) +when a test inbox id is provided. -Use Mailtrap's test email service by setting this to ``True``, and providing -:setting:`MAILTRAP_TEST_INBOX_ID `: +Most users should not need to change this setting. However, you could set it +to use Mailtrap's bulk send service: .. code-block:: python ANYMAIL = { - "MAILTRAP_API_TOKEN": "...", - "MAILTRAP_TESTING_ENABLED": True, - "MAILTRAP_TEST_INBOX_ID": "", - # ... + ... + "MAILTRAP_API_URL": "https://bulk.api.mailtrap.io/api/", } -By default, Anymail will switch to using Mailtrap's test email service API: ``https://sandbox.api.mailtrap.io/api``. +(Note that Anymail has not been tested for use with Mailtrap's bulk API.) -.. setting:: ANYMAIL_MAILTRAP_TEST_INBOX_ID - -.. rubric:: MAILTRAP_TEST_INBOX_ID +The value must be only the API base URL: do not include the ``"/send"`` endpoint +or your test inbox id. -Required if :setting:`MAILTRAP_TESTING_ENABLED ` is ``True``. .. _mailtrap-quirks: @@ -100,10 +94,10 @@ Required if :setting:`MAILTRAP_TESTING_ENABLED `, - but does NOT support per-recipient merge data via their :ref:`batch sending ` - service. + but Anymail does not yet support per-recipient merge data with their + batch sending APIs. .. _mailtrap-webhooks: diff --git a/tests/test_mailtrap_backend.py b/tests/test_mailtrap_backend.py index 3ba34450..e36c2c5d 100644 --- a/tests/test_mailtrap_backend.py +++ b/tests/test_mailtrap_backend.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from datetime import datetime from decimal import Decimal @@ -39,6 +41,19 @@ def setUp(self): "Subject", "Body", "from@example.com", ["to@example.com"] ) + def set_mock_response_message_ids(self, message_ids: list[str] | int): + if isinstance(message_ids, int): + message_ids = [f"message-id-{i}" for i in range(message_ids)] + self.set_mock_response( + json_data={ + "success": True, + "message_ids": message_ids, + }, + ) + + +@tag("mailtrap") +class MailtrapStandardEmailTests(MailtrapBackendMockAPITestCase): def test_send_email(self): """Test sending a basic email""" response = self.message.send() @@ -101,7 +116,9 @@ def test_send_with_merge_data(self): 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"}') + self.set_mock_response( + status_code=401, json_data={"success": False, "error": "Invalid API token"} + ) with self.assertRaises(AnymailAPIError): self.message.send() @@ -119,13 +136,40 @@ def test_send_with_serialization_error(self): 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"}' + status_code=500, json_data={"error": "Internal server error"} ) - with self.assertRaises(AnymailAPIError): + with self.assertRaisesMessage(AnymailAPIError, "Internal server error"): + self.message.send() + + def test_unexpected_success_false(self): + """Test sending an email with an unexpected API response""" + self.set_mock_response( + status_code=200, + json_data={"success": False, "message_ids": ["message-id-1"]}, + ) + with self.assertRaisesMessage( + AnymailAPIError, "Unexpected API failure fields with response status 200" + ): + self.message.send() + + def test_unexpected_errors(self): + """Test sending an email with an unexpected API response""" + self.set_mock_response( + status_code=200, + json_data={ + "success": True, + "errors": ["oops"], + "message_ids": ["message-id-1"], + }, + ) + with self.assertRaisesMessage( + AnymailAPIError, "Unexpected API failure fields with response status 200" + ): self.message.send() def test_send_with_headers_and_recipients(self): """Test sending an email with headers and multiple recipients""" + self.set_mock_response_message_ids(6) email = mail.EmailMessage( "Subject", "Body goes here", @@ -219,6 +263,8 @@ def test_template_id(self): self.assertEqual(data["template_uuid"], "template_id") def test_merge_data(self): + # TODO: merge_data should switch to /api/batch + # and populate requests[].template_variables self.message.merge_data = {"to@example.com": {"name": "Recipient"}} with self.assertRaises(AnymailUnsupportedFeature): self.message.send() @@ -249,7 +295,7 @@ class MailtrapBackendRecipientsRefusedTests(MailtrapBackendMockAPITestCase): @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"}' + status_code=400, json_data={"error": "All recipients refused"} ) with self.assertRaises(AnymailRecipientsRefused): self.message.send() @@ -259,7 +305,7 @@ def test_recipients_refused(self): ) def test_fail_silently(self): self.set_mock_response( - status_code=400, raw=b'{"error": "All recipients refused"}' + status_code=400, json_data={"error": "All recipients refused"} ) self.message.fail_silently = True sent = self.message.send() diff --git a/tests/test_mailtrap_integration.py b/tests/test_mailtrap_integration.py index a22eba2b..69352ec1 100644 --- a/tests/test_mailtrap_integration.py +++ b/tests/test_mailtrap_integration.py @@ -26,7 +26,6 @@ ANYMAIL={ "MAILTRAP_API_TOKEN": ANYMAIL_TEST_MAILTRAP_API_TOKEN, # Use Mailtrap sandbox (testing) API so we don't actually send email - "MAILTRAP_TESTING": True, "MAILTRAP_TEST_INBOX_ID": ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID, # You can override MAILTRAP_TEST_API_URL via env if needed; default is fine }, @@ -134,7 +133,6 @@ def test_stored_template(self): @override_settings( ANYMAIL={ "MAILTRAP_API_TOKEN": "Hey, that's not an API token!", - "MAILTRAP_TESTING": True, "MAILTRAP_TEST_INBOX_ID": ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID, } ) From 57d9e61ee80c530d5d27a3fc09d64bf3441192b8 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Thu, 23 Oct 2025 13:20:27 -0700 Subject: [PATCH 03/14] Update tests and fix backend bugs Tests: - Borrow some tests from other ESPs to cover all Anymail features (doesn't yet include batch sending) - Remove unnecessary RecipientsRefusedTests Backend: - Avoid trying to enforce Mailtrap's API restrictions in our code - Provide default attachment filename, matching Mailjet backend behavior - Don't set empty/unused payload fields (interferes with template sending) - Fix parse_recipient_status bugs from previous commit --- anymail/backends/mailtrap.py | 48 +-- tests/test_mailtrap_backend.py | 670 +++++++++++++++++++++++---------- 2 files changed, 502 insertions(+), 216 deletions(-) diff --git a/anymail/backends/mailtrap.py b/anymail/backends/mailtrap.py index cbfa899b..66539404 100644 --- a/anymail/backends/mailtrap.py +++ b/anymail/backends/mailtrap.py @@ -28,15 +28,17 @@ class MailtrapAttachment(TypedDict): MailtrapData = TypedDict( "MailtrapData", { - "from": MailtrapAddress, + # Although "from" and "subject" are technically required, + # allow Mailtrap's API to enforce that. + "from": NotRequired[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, + "subject": NotRequired[str], + "text": NotRequired[str], "html": NotRequired[str], "category": NotRequired[str], "template_uuid": NotRequired[str], @@ -74,7 +76,7 @@ def __init__( def get_api_endpoint(self): if self.backend.use_sandbox: - test_inbox_id = quote(self.backend.test_inbox_id, safe="") + test_inbox_id = quote(str(self.backend.test_inbox_id), safe="") return f"send/{test_inbox_id}" return "send" @@ -86,13 +88,7 @@ def serialize_data(self): # def init_payload(self): - self.data: MailtrapData = { - "from": { - "email": "", - }, - "subject": "", - "text": "", - } + self.data: MailtrapData = {} @staticmethod def _mailtrap_email(email: EmailAddress) -> MailtrapAddress: @@ -122,18 +118,24 @@ def set_recipients( self.recipients_bcc = [email.addr_spec for email in emails] def set_subject(self, subject): - self.data["subject"] = subject + if subject: + # (must ignore default empty subject for use with template_uuid) + 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 - ) + if emails: + # Use header rather than "reply_to" param + # to allow multiple reply-to addresses + 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 + if body: + self.data["text"] = body def set_html_body(self, body): if "html" in self.data: @@ -144,19 +146,17 @@ def set_html_body(self, body): def add_attachment(self, attachment: Attachment): att: MailtrapAttachment = { - "disposition": "attachment", - "filename": attachment.name, + # Mailtrap requires filename even for inline attachments. + # Provide a fallback filename like the Mailjet backend does. + "filename": attachment.name or "attachment", "content": attachment.b64content, + # default disposition is "attachment" } if attachment.mimetype: att["type"] = attachment.mimetype if attachment.inline: - if not attachment.cid: - self.unsupported_feature("inline attachment without content-id") att["disposition"] = "inline" att["content_id"] = attachment.cid - elif not attachment.name: - self.unsupported_feature("attachment without filename") self.data.setdefault("attachments", []).append(att) def set_tags(self, tags: List[str]): @@ -264,11 +264,11 @@ def parse_recipient_status( backend=self, ) if self.use_sandbox: - message_ids = [message_ids[0]] * expected_count + message_ids = [message_ids[0]] * len(recipients) recipient_status = { email: AnymailRecipientStatus( - message_id=parsed_response["message_ids"][0], + message_id=message_id, status="sent", ) for email, message_id in zip(recipients, message_ids) diff --git a/tests/test_mailtrap_backend.py b/tests/test_mailtrap_backend.py index e36c2c5d..bd8cf1d0 100644 --- a/tests/test_mailtrap_backend.py +++ b/tests/test_mailtrap_backend.py @@ -1,8 +1,10 @@ from __future__ import annotations -import unittest +from base64 import b64encode from datetime import datetime from decimal import Decimal +from email.mime.base import MIMEBase +from email.mime.image import MIMEImage from django.core import mail from django.core.exceptions import ImproperlyConfigured @@ -11,17 +13,22 @@ from anymail.exceptions import ( AnymailAPIError, - AnymailRecipientsRefused, AnymailSerializationError, AnymailUnsupportedFeature, ) -from anymail.message import attach_inline_image +from anymail.message import AnymailMessage, attach_inline_image_file from .mock_requests_backend import ( RequestsBackendMockAPITestCase, SessionSharingTestCases, ) -from .utils import AnymailTestMixin, sample_image_content +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + decode_att, + sample_image_content, + sample_image_path, +) @tag("mailtrap") @@ -42,6 +49,10 @@ def setUp(self): ) def set_mock_response_message_ids(self, message_ids: list[str] | int): + """ + Set a "success" mock response payload with multiple message_ids. + Call with either the count of ids to generate or the list of desired ids. + """ if isinstance(message_ids, int): message_ids = [f"message-id-{i}" for i in range(message_ids)] self.set_mock_response( @@ -53,171 +64,280 @@ def set_mock_response_message_ids(self, message_ids: list[str] | int): @tag("mailtrap") -class MailtrapStandardEmailTests(MailtrapBackendMockAPITestCase): - def test_send_email(self): - """Test sending a basic email""" - response = self.message.send() - self.assertEqual(response, 1) +class MailtrapBackendStandardEmailTests(MailtrapBackendMockAPITestCase): + def test_send_mail(self): + """Test basic API for simple send""" + mail.send_mail( + "Subject here", + "Here is the message.", + "from@sender.example.com", + ["to@example.com"], + fail_silently=False, + ) + # Uses transactional API self.assert_esp_called("https://send.api.mailtrap.io/api/send") + headers = self.get_api_call_headers() + self.assertEqual(headers["Api-Token"], "test_api_token") + data = self.get_api_call_json() + self.assertEqual(data["subject"], "Subject here") + self.assertEqual(data["text"], "Here is the message.") + self.assertEqual(data["from"], {"email": "from@sender.example.com"}) + self.assertEqual(data["to"], [{"email": "to@example.com"}]) - 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_name_addr(self): + """Make sure RFC2822 name-addr format (with display-name) is allowed + + (Test both sender and recipient addresses) + """ + msg = mail.EmailMessage( + "Subject", + "Message", + "From Name ", + ["Recipient #1 ", "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "bcc2@example.com"], + ) + self.set_mock_response_message_ids(6) + msg.send() + data = self.get_api_call_json() + self.assertEqual( + data["from"], {"name": "From Name", "email": "from@example.com"} + ) + self.assertEqual( + data["to"], + [ + {"name": "Recipient #1", "email": "to1@example.com"}, + {"email": "to2@example.com"}, + ], + ) + self.assertEqual( + data["cc"], + [ + {"name": "Carbon Copy", "email": "cc1@example.com"}, + {"email": "cc2@example.com"}, + ], + ) + self.assertEqual( + data["bcc"], + [ + {"name": "Blind Copy", "email": "bcc1@example.com"}, + {"email": "bcc2@example.com"}, + ], + ) + + def test_html_message(self): + text_content = "This is an important message." + html_content = "

This is an important message.

" + email = mail.EmailMultiAlternatives( + "Subject", text_content, "from@example.com", ["to@example.com"] + ) + email.attach_alternative(html_content, "text/html") + email.send() + data = self.get_api_call_json() + self.assertEqual(data["text"], text_content) + self.assertEqual(data["html"], html_content) + # Don't accidentally send the html part as an attachment: + self.assertNotIn("attachments", data) + + def test_html_only_message(self): + html_content = "

This is an important message.

" + email = mail.EmailMessage( + "Subject", html_content, "from@example.com", ["to@example.com"] + ) + email.content_subtype = "html" # Main content is now text/html + email.send() + data = self.get_api_call_json() + self.assertNotIn("text", data) + self.assertEqual(data["html"], html_content) + + def test_extra_headers(self): + self.message.extra_headers = {"X-Custom": "string", "X-Num": 123} + self.message.send() + data = self.get_api_call_json() + self.assertCountEqual(data["headers"], {"X-Custom": "string", "X-Num": 123}) + + def test_extra_headers_serialization_error(self): + self.message.extra_headers = {"X-Custom": Decimal(12.5)} + with self.assertRaisesMessage(AnymailSerializationError, "Decimal"): + self.message.send() + + def test_reply_to(self): + # Reply-To is handled as a header, rather than API "reply_to" field, + # to support multiple addresses. + self.message.reply_to = ["reply@example.com", "Other "] + self.message.extra_headers = {"X-Other": "Keep"} + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["headers"], + { + "Reply-To": "reply@example.com, Other ", + "X-Other": "Keep", + }, + ) + + def test_attachments(self): + text_content = "* Item one\n* Item two\n* Item three" + self.message.attach( + filename="test.txt", content=text_content, mimetype="text/plain" + ) + + # Should guess mimetype if not provided... + png_content = b"PNG\xb4 pretend this is the contents of a png file" + self.message.attach(filename="test.png", content=png_content) + + # Should work with a MIMEBase object (also tests no filename)... + pdf_content = b"PDF\xb4 pretend this is valid pdf data" + mimeattachment = MIMEBase("application", "pdf") + mimeattachment.set_payload(pdf_content) + self.message.attach(mimeattachment) - def test_send_with_inline_image(self): - """Test sending an email with inline images""" - image_data = sample_image_content() # Read from a png file + self.message.send() + data = self.get_api_call_json() + attachments = data["attachments"] + self.assertEqual(len(attachments), 3) + self.assertEqual(attachments[0]["filename"], "test.txt") + self.assertEqual(attachments[0]["type"], "text/plain") + self.assertEqual( + decode_att(attachments[0]["content"]).decode("ascii"), text_content + ) + self.assertEqual(attachments[0].get("disposition", "attachment"), "attachment") + self.assertNotIn("content_id", attachments[0]) + + # ContentType inferred from filename: + self.assertEqual(attachments[1]["type"], "image/png") + self.assertEqual(attachments[1]["filename"], "test.png") + self.assertEqual(decode_att(attachments[1]["content"]), png_content) + # make sure image not treated as inline: + self.assertEqual(attachments[1].get("disposition", "attachment"), "attachment") + self.assertNotIn("content_id", attachments[1]) + + self.assertEqual(attachments[2]["type"], "application/pdf") + self.assertEqual(attachments[2]["filename"], "attachment") # default + self.assertEqual(decode_att(attachments[2]["content"]), pdf_content) + self.assertEqual(attachments[2].get("disposition", "attachment"), "attachment") + self.assertNotIn("content_id", attachments[2]) + + def test_unicode_attachment_correctly_decoded(self): + self.message.attach( + "Une pièce jointe.html", "

\u2019

", mimetype="text/html" + ) + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["attachments"], + [ + { + "filename": "Une pièce jointe.html", + "type": "text/html", + "content": b64encode("

\u2019

".encode("utf-8")).decode( + "ascii" + ), + } + ], + ) + + def test_embedded_images(self): + image_filename = SAMPLE_IMAGE_FILENAME + image_path = sample_image_path(image_filename) + image_data = sample_image_content(image_filename) - cid = attach_inline_image(self.message, image_data) + cid = attach_inline_image_file(self.message, image_path) # Read from a png file 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") + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["html"], html_content) - 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") + attachments = data["attachments"] + self.assertEqual(len(attachments), 1) + self.assertEqual(attachments[0]["filename"], image_filename) + self.assertEqual(attachments[0]["type"], "image/png") + self.assertEqual(decode_att(attachments[0]["content"]), image_data) + self.assertEqual(attachments[0]["disposition"], "inline") + self.assertEqual(attachments[0]["content_id"], cid) - 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_attached_images(self): + image_filename = SAMPLE_IMAGE_FILENAME + image_path = sample_image_path(image_filename) + image_data = sample_image_content(image_filename) - def test_send_with_tags(self): - """Test sending an email with tags""" - self.message.tags = ["tag1", "tag2"] - with self.assertRaises(AnymailUnsupportedFeature): - self.message.send() + # option 1: attach as a file + self.message.attach_file(image_path) - 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") + # option 2: construct the MIMEImage and attach it directly + image = MIMEImage(image_data) + self.message.attach(image) - 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() + image_data_b64 = b64encode(image_data).decode("ascii") - def test_send_with_invalid_api_token(self): - """Test sending an email with an invalid API token""" - self.set_mock_response( - status_code=401, json_data={"success": False, "error": "Invalid API token"} + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["attachments"], + [ + { + "filename": image_filename, # the named one + "type": "image/png", + "content": image_data_b64, + }, + { + "filename": "attachment", # the unnamed one + "type": "image/png", + "content": image_data_b64, + }, + ], ) - with self.assertRaises(AnymailAPIError): - 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: + def test_multiple_html_alternatives(self): + # Multiple alternatives not allowed + self.message.attach_alternative("

First html is OK

", "text/html") + self.message.attach_alternative("

But not second html

", "text/html") + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple html parts"): 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, json_data={"error": "Internal server error"} - ) - with self.assertRaisesMessage(AnymailAPIError, "Internal server error"): + def test_html_alternative(self): + # Only html alternatives allowed + self.message.attach_alternative("{'not': 'allowed'}", "application/json") + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "alternative part with type 'application/json'" + ): self.message.send() - def test_unexpected_success_false(self): - """Test sending an email with an unexpected API response""" - self.set_mock_response( - status_code=200, - json_data={"success": False, "message_ids": ["message-id-1"]}, - ) + def test_alternatives_fail_silently(self): + # Make sure fail_silently is respected + self.message.attach_alternative("{'not': 'allowed'}", "application/json") + sent = self.message.send(fail_silently=True) + self.assert_esp_not_called("API should not be called when send fails silently") + self.assertEqual(sent, 0) + + def test_multiple_from_emails(self): + self.message.from_email = 'first@example.com, "From, also" ' with self.assertRaisesMessage( - AnymailAPIError, "Unexpected API failure fields with response status 200" + AnymailUnsupportedFeature, "multiple from emails" ): self.message.send() - def test_unexpected_errors(self): - """Test sending an email with an unexpected API response""" + def test_api_failure(self): self.set_mock_response( - status_code=200, - json_data={ - "success": True, - "errors": ["oops"], - "message_ids": ["message-id-1"], - }, + status_code=400, + json_data={"success": False, "errors": ["helpful error message"]}, ) with self.assertRaisesMessage( - AnymailAPIError, "Unexpected API failure fields with response status 200" - ): + AnymailAPIError, r"Mailtrap API response 400" + ) as cm: self.message.send() + # Error message includes response details: + self.assertIn("helpful error message", str(cm.exception)) - def test_send_with_headers_and_recipients(self): - """Test sending an email with headers and multiple recipients""" - self.set_mock_response_message_ids(6) - 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"}, - ], - ) + def test_api_failure_fail_silently(self): + # Make sure fail_silently is respected + self.set_mock_response(status_code=500) + sent = self.message.send(fail_silently=True) + self.assertEqual(sent, 0) @tag("mailtrap") @@ -225,91 +345,257 @@ 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.envelope_sender = "anything@bounces.example.com" + with self.assertRaisesMessage(AnymailUnsupportedFeature, "envelope_sender"): self.message.send() def test_metadata(self): - self.message.metadata = {"user_id": "12345"} - response = self.message.send() - self.assertEqual(response, 1) + self.message.metadata = {"user_id": "12345", "items": 6} + self.message.send() data = self.get_api_call_json() - self.assertEqual(data["custom_variables"], {"user_id": "12345"}) + self.assertEqual(data["custom_variables"], {"user_id": "12345", "items": "6"}) 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_at = datetime(2023, 10, 1, 12, 0, 0, tzinfo=timezone.utc) + with self.assertRaisesMessage(AnymailUnsupportedFeature, "send_at"): self.message.send() def test_tags(self): - self.message.tags = ["tag1"] - response = self.message.send() - self.assertEqual(response, 1) + self.message.tags = ["receipt"] + self.message.send() data = self.get_api_call_json() - self.assertEqual(data["category"], "tag1") + self.assertEqual(data["category"], "receipt") - def test_tracking(self): - self.message.track_clicks = True - self.message.track_opens = True - with self.assertRaises(AnymailUnsupportedFeature): + def test_multiple_tags(self): + self.message.tags = ["receipt", "repeat-user"] + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple tags"): self.message.send() - def test_template_id(self): - self.message.template_id = "template_id" - response = self.message.send() - self.assertEqual(response, 1) + @override_settings(ANYMAIL_IGNORE_UNSUPPORTED_FEATURES=True) + def test_multiple_tags_ignore_unsupported_features(self): + # First tag only when ignoring unsupported features + self.message.tags = ["receipt", "repeat-user"] + self.message.send() data = self.get_api_call_json() - self.assertEqual(data["template_uuid"], "template_id") + self.assertEqual(data["category"], "receipt") - def test_merge_data(self): - # TODO: merge_data should switch to /api/batch - # and populate requests[].template_variables - self.message.merge_data = {"to@example.com": {"name": "Recipient"}} - with self.assertRaises(AnymailUnsupportedFeature): + def test_track_opens(self): + self.message.track_opens = True + with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"): 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) + def test_track_clicks(self): + self.message.track_clicks = True + with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_clicks"): + self.message.send() + + def test_non_batch_template(self): + # Mailtrap's usual /send endpoint works for template sends + # without per-recipient customization + message = AnymailMessage( + # Omit subject and body (Mailtrap prohibits them with templates) + from_email="from@example.com", + to=["to@example.com"], + template_id="template-uuid", + merge_global_data={"name": "Alice", "group": "Developers"}, + ) + message.send() + self.assert_esp_called("/send") data = self.get_api_call_json() + self.assertEqual(data["template_uuid"], "template-uuid") self.assertEqual( - data["template_variables"], {"global_name": "Global Recipient"} + data["template_variables"], {"name": "Alice", "group": "Developers"} ) + # Make sure Django default subject and body didn't end up in the payload: + self.assertNotIn("subject", data) + self.assertNotIn("text", data) + self.assertNotIn("html", data) + + # TODO: merge_data, merge_metadata, merge_headers and batch sending API + # TODO: does Mailtrap support inline templates? + + def test_default_omits_options(self): + """Make sure by default we don't send any ESP-specific options. + + Options not specified by the caller should be omitted entirely from + the API call (*not* sent as False or empty). This ensures + that your ESP account settings apply by default. + """ + self.message.send() + data = self.get_api_call_json() + self.assertNotIn("cc", data) + self.assertNotIn("bcc", data) + self.assertNotIn("reply_to", data) + self.assertNotIn("attachments", data) + self.assertNotIn("headers", data) + self.assertNotIn("custom_variables", data) + self.assertNotIn("category", data) def test_esp_extra(self): - self.message.esp_extra = {"custom_option": "value"} - response = self.message.send() - self.assertEqual(response, 1) + self.message.esp_extra = { + "future_mailtrap_option": "some-value", + } + self.message.send() data = self.get_api_call_json() - self.assertEqual(data["custom_option"], "value") + self.assertEqual(data["future_mailtrap_option"], "some-value") + + # noinspection PyUnresolvedReferences + def test_send_attaches_anymail_status(self): + """The anymail_status should be attached to the message when it is sent""" + response_content = { + "success": True, + # Transactional API response lists message ids in to, cc, bcc order + "message_ids": [ + "id-to1", + "id-to2", + "id-cc1", + "id-cc2", + "id-bcc1", + "id-bcc2", + ], + } + self.set_mock_response(json_data=response_content) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["Recipient ", "to2@example.com"], + cc=["CC ", "cc2@example.com"], + bcc=["BCC ", "bcc2@example.com"], + ) + sent = msg.send() + self.assertEqual(sent, 1) + self.assertEqual(msg.anymail_status.status, {"sent"}) + self.assertEqual( + msg.anymail_status.message_id, + {"id-to1", "id-to2", "id-cc1", "id-cc2", "id-bcc1", "id-bcc2"}, + ) + recipients = msg.anymail_status.recipients + self.assertEqual(recipients["to1@example.com"].status, "sent") + self.assertEqual(recipients["to1@example.com"].message_id, "id-to1") + self.assertEqual(recipients["to2@example.com"].status, "sent") + self.assertEqual(recipients["to2@example.com"].message_id, "id-to2") + self.assertEqual(recipients["cc1@example.com"].status, "sent") + self.assertEqual(recipients["cc1@example.com"].message_id, "id-cc1") + self.assertEqual(recipients["cc2@example.com"].status, "sent") + self.assertEqual(recipients["cc2@example.com"].message_id, "id-cc2") + self.assertEqual(recipients["bcc1@example.com"].status, "sent") + self.assertEqual(recipients["bcc1@example.com"].message_id, "id-bcc1") + self.assertEqual(recipients["bcc2@example.com"].status, "sent") + self.assertEqual(recipients["bcc2@example.com"].message_id, "id-bcc2") + self.assertEqual(msg.anymail_status.esp_response.json(), response_content) + + def test_wrong_message_id_count(self): + self.set_mock_response_message_ids(2) + with self.assertRaisesMessage(AnymailAPIError, "Expected 1 message_ids, got 2"): + self.message.send() + # noinspection PyUnresolvedReferences + @override_settings( + ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_TEST_INBOX_ID": 12345} + ) + def test_sandbox_send(self): + self.set_mock_response_message_ids(["sandbox-single-id"]) + self.message.to = ["Recipient #1 ", "to2@example.com"] + self.message.send() -@tag("mailtrap") -class MailtrapBackendRecipientsRefusedTests(MailtrapBackendMockAPITestCase): - """ - Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid - """ + self.assert_esp_called("https://sandbox.api.mailtrap.io/api/send/12345") + self.assertEqual(self.message.anymail_status.status, {"sent"}) + self.assertEqual( + self.message.anymail_status.message_id, + "sandbox-single-id", + ) + self.assertEqual( + self.message.anymail_status.recipients["to1@example.com"].message_id, + "sandbox-single-id", + ) + self.assertEqual( + self.message.anymail_status.recipients["to2@example.com"].message_id, + "sandbox-single-id", + ) + + @override_settings( + ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_TEST_INBOX_ID": 12345} + ) + def test_wrong_message_id_count_sandbox(self): + self.set_mock_response_message_ids(2) + self.message.to = ["Recipient #1 ", "to2@example.com"] + with self.assertRaisesMessage(AnymailAPIError, "Expected 1 message_ids, got 2"): + self.message.send() + + # noinspection PyUnresolvedReferences + def test_send_failed_anymail_status(self): + """If the send fails, anymail_status should contain initial values""" + self.set_mock_response(status_code=500) + sent = self.message.send(fail_silently=True) + self.assertEqual(sent, 0) + self.assertIsNone(self.message.anymail_status.status) + self.assertIsNone(self.message.anymail_status.message_id) + self.assertEqual(self.message.anymail_status.recipients, {}) + self.assertIsNone(self.message.anymail_status.esp_response) + + # noinspection PyUnresolvedReferences + def test_send_unparsable_response(self): + mock_response = self.set_mock_response( + status_code=200, raw=b"yikes, this isn't a real response" + ) + with self.assertRaises(AnymailAPIError): + self.message.send() + self.assertIsNone(self.message.anymail_status.status) + self.assertIsNone(self.message.anymail_status.message_id) + self.assertEqual(self.message.anymail_status.recipients, {}) + self.assertEqual(self.message.anymail_status.esp_response, mock_response) + + def test_send_with_serialization_error(self): + 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_error_response(self): + self.set_mock_response( + status_code=401, json_data={"success": False, "error": "Invalid API token"} + ) + with self.assertRaisesMessage(AnymailAPIError, "Invalid API token"): + self.message.send() - @unittest.skip("TODO: is this test correct/necessary?") - def test_recipients_refused(self): + def test_unexpected_success_false(self): self.set_mock_response( - status_code=400, json_data={"error": "All recipients refused"} + status_code=200, + json_data={"success": False, "message_ids": ["message-id-1"]}, ) - with self.assertRaises(AnymailRecipientsRefused): + with self.assertRaisesMessage( + AnymailAPIError, "Unexpected API failure fields with response status 200" + ): self.message.send() - @unittest.skip( - "TODO: is this test correct/necessary? How to handle this in mailtrap backend?" - ) - def test_fail_silently(self): + def test_unexpected_errors(self): self.set_mock_response( - status_code=400, json_data={"error": "All recipients refused"} + status_code=200, + json_data={ + "success": True, + "errors": ["oops"], + "message_ids": ["message-id-1"], + }, ) - self.message.fail_silently = True - sent = self.message.send() - self.assertEqual(sent, 0) + with self.assertRaisesMessage( + AnymailAPIError, "Unexpected API failure fields with response status 200" + ): + self.message.send() + + @override_settings( + ANYMAIL={ + "MAILTRAP_API_TOKEN": "test-token", + "MAILTRAP_API_URL": "https://bulk.api.mailtrap.io/api", + } + ) + def test_override_api_url(self): + self.message.send() + self.assert_esp_called("https://bulk.api.mailtrap.io/api/send") @tag("mailtrap") From dd79e983e54171d4fc54300653f60cd77d9bc098 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Sat, 29 Nov 2025 13:42:28 -0800 Subject: [PATCH 04/14] Implement batch sending Switch to Mailtrap's batch send API when Anymail batch features are used. --- anymail/backends/mailtrap.py | 145 +++++++++++-- tests/test_mailtrap_backend.py | 370 ++++++++++++++++++++++++++++++++- 2 files changed, 496 insertions(+), 19 deletions(-) diff --git a/anymail/backends/mailtrap.py b/anymail/backends/mailtrap.py index 66539404..790656f8 100644 --- a/anymail/backends/mailtrap.py +++ b/anymail/backends/mailtrap.py @@ -46,6 +46,14 @@ class MailtrapAttachment(TypedDict): }, ) +MailtrapBatchData = TypedDict( + "MailtrapBatchData", + { + "base": MailtrapData, + "requests": List[MailtrapData], + }, +) + class MailtrapPayload(RequestsPayload): def __init__( @@ -63,7 +71,11 @@ def __init__( } # Yes, the parent sets this, but setting it here, too, gives type hints self.backend = backend - self.metadata = None + + # Late bound batch send data + self.merge_data: Dict[str, Any] = {} + self.merge_metadata: Dict[str, Dict[str, str]] = {} + self.merge_headers: Dict[str, Dict[str, str]] = {} # needed for backend.parse_recipient_status self.recipients_to: List[str] = [] @@ -75,13 +87,47 @@ def __init__( ) def get_api_endpoint(self): + endpoint = "batch" if self.is_batch() else "send" if self.backend.use_sandbox: test_inbox_id = quote(str(self.backend.test_inbox_id), safe="") - return f"send/{test_inbox_id}" - return "send" + return f"{endpoint}/{test_inbox_id}" + else: + return endpoint def serialize_data(self): - return self.serialize_json(self.data) + data = self.burst_for_batch() if self.is_batch() else self.data + return self.serialize_json(data) + + def burst_for_batch(self) -> MailtrapBatchData: + """Transform self.data into the payload for a batch send.""" + # One batch send request for each 'to' address. + # Any cc and bcc recipients are duplicated to every request. + to = self.data.pop("to", []) + cc = self.data.pop("cc", None) + bcc = self.data.pop("bcc", None) + base_template_variables = self.data.get("template_variables", {}) + base_custom_variables = self.data.get("custom_variables", {}) + base_headers = self.data.get("headers", {}) + requests = [] + for recipient in to: + email = recipient["email"] + request: MailtrapData = {"to": [recipient]} + if cc: + request["cc"] = cc + if bcc: + request["bcc"] = bcc + # Any request props completely override base props, so must merge base. + if email in self.merge_data: + request["template_variables"] = base_template_variables.copy() + request["template_variables"].update(self.merge_data[email]) + if email in self.merge_metadata: + request["custom_variables"] = base_custom_variables.copy() + request["custom_variables"].update(self.merge_metadata[email]) + if email in self.merge_headers: + request["headers"] = base_headers.copy() + request["headers"].update(self.merge_headers[email]) + requests.append(request) + return {"base": self.data, "requests": requests} # # Payload construction @@ -93,7 +139,7 @@ def init_payload(self): @staticmethod def _mailtrap_email(email: EmailAddress) -> MailtrapAddress: """Expand an Anymail EmailAddress into Mailtrap's {"email", "name"} dict""" - result = {"email": email.addr_spec} + result: MailtrapAddress = {"email": email.addr_spec} if email.display_name: result["name"] = email.display_name return result @@ -131,6 +177,9 @@ def set_reply_to(self, emails: List[EmailAddress]): ) def set_extra_headers(self, headers): + # Note: Mailtrap appears to correctly RFC 2047 encode non-ASCII header + # values for us, even though its docs say that we "must ensure these + # are properly encoded if they contain unicode characters." self.data.setdefault("headers", {}).update(headers) def set_text_body(self, body): @@ -169,14 +218,25 @@ 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_data(self, merge_data): + # Late-bound in burst_for_batch + self.merge_data = merge_data + + def set_merge_headers(self, merge_headers): + # Late-bound in burst_for_batch + self.merge_headers = merge_headers + def set_merge_global_data(self, merge_global_data: Dict[str, Any]): self.data.setdefault("template_variables", {}).update(merge_global_data) + def set_merge_metadata(self, merge_metadata): + # Late-bound in burst_for_batch + self.merge_metadata = merge_metadata + def set_esp_extra(self, extra): update_deep(self.data, extra) @@ -225,7 +285,8 @@ def parse_recipient_status( parsed_response = self.deserialize_json_response(response, payload, message) if parsed_response.get("errors") or not parsed_response.get("success"): - # Superclass has already filtered error status responses, so this shouldn't happen. + # Superclass has already filtered http error status responses, + # so errors here (or general batch send error) shouldn't be possible. status = response.status_code raise AnymailRequestsAPIError( f"Unexpected API failure fields with response status {status}", @@ -235,24 +296,76 @@ def parse_recipient_status( backend=self, ) + if payload.is_batch(): + try: + responses = parsed_response["responses"] + except KeyError: + raise AnymailRequestsAPIError("") + if len(payload.recipients_to) != len(responses): + raise AnymailRequestsAPIError( + f"Expected {len(payload.recipients_to)} batch send responses" + f" but got {len(responses)}", + email_message=message, + payload=payload, + response=response, + backend=self, + ) + + # Merge recipient statuses for each item in the batch. + # Each API response includes message_ids in the order 'to', 'cc, 'bcc'. + recipient_status: Dict[str, AnymailRecipientStatus] = {} + for to, one_response in zip(payload.recipients_to, responses): + recipients = [to, *payload.recipients_cc, *payload.recipients_bcc] + one_status = self.parse_one_response( + one_response, recipients, response, payload, message + ) + recipient_status.update(one_status) + else: + # Non-batch send. + # API response includes message_ids in the order 'to', 'cc, 'bcc'. + recipients = [ + *payload.recipients_to, + *payload.recipients_cc, + *payload.recipients_bcc, + ] + recipient_status = self.parse_one_response( + parsed_response, recipients, response, payload, message + ) + + return recipient_status + + def parse_one_response( + self, + one_response, + recipients: List[str], + raw_response, + payload: MailtrapPayload, + message: AnymailMessage, + ) -> Dict[str, AnymailRecipientStatus]: + """ + Return parsed status for recipients in one_response, which is either + a top-level send response or an individual 'responses' item for batch send. + """ + if not one_response["success"]: + # (Could try to parse status out of one_response["errors"].) + return { + email: AnymailRecipientStatus(message_id=None, status="failed") + for email in recipients + } + try: - message_ids = parsed_response["message_ids"] + message_ids = one_response["message_ids"] except KeyError: raise AnymailRequestsAPIError( "Unexpected API response format", email_message=message, payload=payload, - response=response, + response=raw_response, backend=self, ) # The sandbox API always returns a single message id for all recipients; - # the production API returns one message id per recipient in this order: - recipients = [ - *payload.recipients_to, - *payload.recipients_cc, - *payload.recipients_bcc, - ] + # the production API returns one message id per recipient. expected_count = 1 if self.use_sandbox else len(recipients) actual_count = len(message_ids) if expected_count != actual_count: @@ -260,7 +373,7 @@ def parse_recipient_status( f"Expected {expected_count} message_ids, got {actual_count}", email_message=message, payload=payload, - response=response, + response=raw_response, backend=self, ) if self.use_sandbox: diff --git a/tests/test_mailtrap_backend.py b/tests/test_mailtrap_backend.py index bd8cf1d0..2d5ba31f 100644 --- a/tests/test_mailtrap_backend.py +++ b/tests/test_mailtrap_backend.py @@ -400,7 +400,7 @@ def test_non_batch_template(self): merge_global_data={"name": "Alice", "group": "Developers"}, ) message.send() - self.assert_esp_called("/send") + self.assert_esp_called("/api/send") data = self.get_api_call_json() self.assertEqual(data["template_uuid"], "template-uuid") self.assertEqual( @@ -411,8 +411,328 @@ def test_non_batch_template(self): self.assertNotIn("text", data) self.assertNotIn("html", data) - # TODO: merge_data, merge_metadata, merge_headers and batch sending API - # TODO: does Mailtrap support inline templates? + _mock_batch_response = { + "success": True, + "responses": [ + {"success": True, "message_ids": ["message-id-alice-to"]}, + {"success": True, "message_ids": ["message-id-bob-to"]}, + {"success": True, "message_ids": ["message-id-cam-to"]}, + ], + } + + def test_merge_data(self): + self.set_mock_response(json_data=self._mock_batch_response) + message = AnymailMessage( + from_email="from@example.com", + to=["alice@example.com", "Bob ", "cam@example.com"], + template_id="template-uuid", + merge_data={ + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined + "nobody@example.com": {"name": "Not a recipient for this message"}, + }, + merge_global_data={"group": "Users", "site": "ExampleCo"}, + ) + message.send() + + # Use batch send endpoint + self.assert_esp_called("/api/batch") + data = self.get_api_call_json() + + # Common parameters in "base": + self.assertEqual(data["base"]["from"], {"email": "from@example.com"}) + self.assertEqual(data["base"]["template_uuid"], "template-uuid") + self.assertEqual( + data["base"]["template_variables"], {"group": "Users", "site": "ExampleCo"} + ) + self.assertNotIn("subject", data["base"]) # invalid with template_uuid + self.assertNotIn("text", data["base"]) + self.assertNotIn("html", data["base"]) + + # Per-recipient parameters in "requests" array: + self.assertEqual(len(data["requests"]), 3) + self.assertEqual( + data["requests"][0], + { + "to": [{"email": "alice@example.com"}], + # Completely overrides base template_variables + "template_variables": { + "name": "Alice", + "group": "Developers", + "site": "ExampleCo", + }, + }, + ) + self.assertEqual( + data["requests"][1], + { + "to": [{"email": "bob@example.com", "name": "Bob"}], + "template_variables": { + "name": "Bob", + "group": "Users", + "site": "ExampleCo", + }, + }, + ) + self.assertEqual( + data["requests"][2], + { + "to": [{"email": "cam@example.com"}], + # No template_variables (no merge_data for cam, so global base applies) + }, + ) + + recipients = message.anymail_status.recipients + self.assertEqual(recipients["alice@example.com"].status, "sent") + self.assertEqual( + recipients["alice@example.com"].message_id, + "message-id-alice-to", + ) + self.assertEqual(recipients["bob@example.com"].status, "sent") + self.assertEqual( + recipients["bob@example.com"].message_id, + "message-id-bob-to", + ) + self.assertEqual(recipients["cam@example.com"].status, "sent") + self.assertEqual( + recipients["cam@example.com"].message_id, + "message-id-cam-to", + ) + + def test_merge_metadata(self): + self.set_mock_response(json_data=self._mock_batch_response) + self.message.to = [ + "alice@example.com", + "Bob ", + "cam@example.com", + ] + self.message.merge_metadata = { + "alice@example.com": {"order_id": 123, "tier": "premium"}, + "bob@example.com": {"order_id": 678}, + } + self.message.metadata = {"notification_batch": "zx912", "tier": "basic"} + self.message.send() + + self.assert_esp_called("/api/batch") + data = self.get_api_call_json() + self.assertEqual(data["base"]["from"], {"email": "from@example.com"}) + self.assertEqual(data["base"]["subject"], "Subject") + self.assertEqual(data["base"]["text"], "Body") + self.assertEqual( + data["base"]["custom_variables"], + {"notification_batch": "zx912", "tier": "basic"}, + ) + + self.assertEqual(len(data["requests"]), 3) + self.assertEqual( + data["requests"][0], + { + "to": [{"email": "alice@example.com"}], + "custom_variables": { + "order_id": 123, + "tier": "premium", + "notification_batch": "zx912", + }, + }, + ) + self.assertEqual( + data["requests"][1], + { + "to": [{"email": "bob@example.com", "name": "Bob"}], + "custom_variables": { + "order_id": 678, + "notification_batch": "zx912", + "tier": "basic", + }, + }, + ) + self.assertEqual( + data["requests"][2], + { + "to": [{"email": "cam@example.com"}], + # No custom_variables (no merge_data for cam, so global base applies) + }, + ) + + def test_merge_headers(self): + self.set_mock_response(json_data=self._mock_batch_response) + self.message.to = [ + "alice@example.com", + "Bob ", + "cam@example.com", + ] + self.message.extra_headers = { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + } + self.message.merge_headers = { + "alice@example.com": { + "List-Unsubscribe": "", + }, + "bob@example.com": { + "List-Unsubscribe": "", + }, + } + self.message.send() + + self.assert_esp_called("/api/batch") + data = self.get_api_call_json() + self.assertEqual( + data["base"]["headers"], + { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + ) + + self.assertEqual(len(data["requests"]), 3) + self.assertEqual( + data["requests"][0], + { + "to": [{"email": "alice@example.com"}], + "headers": { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + }, + ) + self.assertEqual( + data["requests"][1], + { + "to": [{"email": "bob@example.com", "name": "Bob"}], + "headers": { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + }, + ) + self.assertEqual( + data["requests"][2], + { + "to": [{"email": "cam@example.com"}], + # No headers (no merge_data for cam, so global base applies) + }, + ) + + def test_batch_send_with_cc_and_bcc(self): + self.set_mock_response( + json_data={ + "success": True, + "responses": [ + { + "success": True, + "message_ids": [ + "message-id-alice-to", + "message-id-alice-cc0", + "message-id-alice-cc1", + "message-id-alice-bcc0", + ], + }, + { + "success": True, + "message_ids": [ + "message-id-bob-to", + "message-id-bob-cc0", + "message-id-bob-cc1", + "message-id-bob-bcc0", + ], + }, + ], + } + ) + message = AnymailMessage( + to=["alice@example.com", "Bob "], + cc=["cc0@example.com", "Also CC "], + bcc=["bcc0@example.com"], + merge_metadata={}, # force batch send + ) + message.send() + + self.assert_esp_called("/api/batch") + + # cc and bcc must be copied to each subrequest (cannot be in base) + data = self.get_api_call_json() + self.assertEqual(len(data["requests"]), 2) + self.assertEqual( + data["requests"][0], + { + "to": [{"email": "alice@example.com"}], + "cc": [ + {"email": "cc0@example.com"}, + {"email": "cc1@example.com", "name": "Also CC"}, + ], + "bcc": [{"email": "bcc0@example.com"}], + }, + ) + self.assertEqual( + data["requests"][1], + { + "to": [{"email": "bob@example.com", "name": "Bob"}], + "cc": [ + {"email": "cc0@example.com"}, + {"email": "cc1@example.com", "name": "Also CC"}, + ], + "bcc": [{"email": "bcc0@example.com"}], + }, + ) + + recipients = message.anymail_status.recipients + self.assertEqual(recipients["alice@example.com"].status, "sent") + self.assertEqual( + recipients["alice@example.com"].message_id, + "message-id-alice-to", + ) + self.assertEqual(recipients["bob@example.com"].status, "sent") + self.assertEqual( + recipients["bob@example.com"].message_id, + "message-id-bob-to", + ) + # anymail_status.recipients can't represent separate statuses for batch + # cc and bcc recipients. For Mailtrap, the status will reflect the cc/bcc + # for the last 'to' recipient: + self.assertEqual(recipients["cc0@example.com"].status, "sent") + self.assertEqual( + recipients["cc0@example.com"].message_id, + "message-id-bob-cc0", + ) + self.assertEqual(recipients["cc1@example.com"].status, "sent") + self.assertEqual( + recipients["cc1@example.com"].message_id, + "message-id-bob-cc1", + ) + self.assertEqual(recipients["bcc0@example.com"].status, "sent") + self.assertEqual( + recipients["bcc0@example.com"].message_id, + "message-id-bob-bcc0", + ) + + def test_batch_send_with_mixed_responses(self): + self.set_mock_response( + json_data={ + "success": True, + "responses": [ + { + "success": True, + "message_ids": ["message-id-alice-to"], + }, + {"success": False, "errors": ["address is invalid in 'to' 0"]}, + ], + } + ) + message = AnymailMessage( + to=["alice@example.com", "invalid@address"], + merge_metadata={}, # force batch send + ) + message.send() + + recipients = message.anymail_status.recipients + self.assertEqual(recipients["alice@example.com"].status, "sent") + self.assertEqual( + recipients["alice@example.com"].message_id, + "message-id-alice-to", + ) + self.assertEqual(recipients["invalid@address"].status, "failed") + self.assertIsNone(recipients["invalid@address"].message_id) def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. @@ -514,6 +834,50 @@ def test_sandbox_send(self): "sandbox-single-id", ) + @override_settings( + ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_TEST_INBOX_ID": 12345} + ) + def test_sandbox_batch_send(self): + self.set_mock_response( + json_data={ + "success": True, + "responses": [ + # Sandbox returns single message_id per request, + # even with multiple recipients via cc/bcc. + {"success": True, "message_ids": ["sandbox-single-id-1"]}, + {"success": True, "message_ids": ["sandbox-single-id-2"]}, + ], + } + ) + message = AnymailMessage( + "Subject", + "Body", + "from@example.com", + ["Recipient #1 ", "to2@example.com"], + cc=["cc@example.com"], + merge_data={}, # force batch send + ) + message.send() + + self.assert_esp_called("https://sandbox.api.mailtrap.io/api/batch/12345") + self.assertEqual( + message.anymail_status.message_id, + {"sandbox-single-id-1", "sandbox-single-id-2"}, + ) + self.assertEqual( + message.anymail_status.recipients["to1@example.com"].message_id, + "sandbox-single-id-1", + ) + self.assertEqual( + message.anymail_status.recipients["to2@example.com"].message_id, + "sandbox-single-id-2", + ) + self.assertEqual( + # For batch cc and bcc, message_id from the last recipient is used + message.anymail_status.recipients["cc@example.com"].message_id, + "sandbox-single-id-2", + ) + @override_settings( ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_TEST_INBOX_ID": 12345} ) From 9198e6dbf3129994c86a53b3e8e0a9093ff74e61 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Sat, 29 Nov 2025 13:46:55 -0800 Subject: [PATCH 05/14] Change recipient status from "sent" to "queued" Mailtrap's send and batch APIs queue messages. (Many problems that would prevent sending are reported via webhook, some time after calling the API.) Use "queued" rather than "sent" for consistency with other ESPs that use this approach. --- anymail/backends/mailtrap.py | 2 +- tests/test_mailtrap_backend.py | 34 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/anymail/backends/mailtrap.py b/anymail/backends/mailtrap.py index 790656f8..a9d65dea 100644 --- a/anymail/backends/mailtrap.py +++ b/anymail/backends/mailtrap.py @@ -382,7 +382,7 @@ def parse_one_response( recipient_status = { email: AnymailRecipientStatus( message_id=message_id, - status="sent", + status="queued", ) for email, message_id in zip(recipients, message_ids) } diff --git a/tests/test_mailtrap_backend.py b/tests/test_mailtrap_backend.py index 2d5ba31f..258f8e61 100644 --- a/tests/test_mailtrap_backend.py +++ b/tests/test_mailtrap_backend.py @@ -483,17 +483,17 @@ def test_merge_data(self): ) recipients = message.anymail_status.recipients - self.assertEqual(recipients["alice@example.com"].status, "sent") + self.assertEqual(recipients["alice@example.com"].status, "queued") self.assertEqual( recipients["alice@example.com"].message_id, "message-id-alice-to", ) - self.assertEqual(recipients["bob@example.com"].status, "sent") + self.assertEqual(recipients["bob@example.com"].status, "queued") self.assertEqual( recipients["bob@example.com"].message_id, "message-id-bob-to", ) - self.assertEqual(recipients["cam@example.com"].status, "sent") + self.assertEqual(recipients["cam@example.com"].status, "queued") self.assertEqual( recipients["cam@example.com"].message_id, "message-id-cam-to", @@ -677,12 +677,12 @@ def test_batch_send_with_cc_and_bcc(self): ) recipients = message.anymail_status.recipients - self.assertEqual(recipients["alice@example.com"].status, "sent") + self.assertEqual(recipients["alice@example.com"].status, "queued") self.assertEqual( recipients["alice@example.com"].message_id, "message-id-alice-to", ) - self.assertEqual(recipients["bob@example.com"].status, "sent") + self.assertEqual(recipients["bob@example.com"].status, "queued") self.assertEqual( recipients["bob@example.com"].message_id, "message-id-bob-to", @@ -690,17 +690,17 @@ def test_batch_send_with_cc_and_bcc(self): # anymail_status.recipients can't represent separate statuses for batch # cc and bcc recipients. For Mailtrap, the status will reflect the cc/bcc # for the last 'to' recipient: - self.assertEqual(recipients["cc0@example.com"].status, "sent") + self.assertEqual(recipients["cc0@example.com"].status, "queued") self.assertEqual( recipients["cc0@example.com"].message_id, "message-id-bob-cc0", ) - self.assertEqual(recipients["cc1@example.com"].status, "sent") + self.assertEqual(recipients["cc1@example.com"].status, "queued") self.assertEqual( recipients["cc1@example.com"].message_id, "message-id-bob-cc1", ) - self.assertEqual(recipients["bcc0@example.com"].status, "sent") + self.assertEqual(recipients["bcc0@example.com"].status, "queued") self.assertEqual( recipients["bcc0@example.com"].message_id, "message-id-bob-bcc0", @@ -726,7 +726,7 @@ def test_batch_send_with_mixed_responses(self): message.send() recipients = message.anymail_status.recipients - self.assertEqual(recipients["alice@example.com"].status, "sent") + self.assertEqual(recipients["alice@example.com"].status, "queued") self.assertEqual( recipients["alice@example.com"].message_id, "message-id-alice-to", @@ -785,23 +785,23 @@ def test_send_attaches_anymail_status(self): ) sent = msg.send() self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {"sent"}) + self.assertEqual(msg.anymail_status.status, {"queued"}) self.assertEqual( msg.anymail_status.message_id, {"id-to1", "id-to2", "id-cc1", "id-cc2", "id-bcc1", "id-bcc2"}, ) recipients = msg.anymail_status.recipients - self.assertEqual(recipients["to1@example.com"].status, "sent") + self.assertEqual(recipients["to1@example.com"].status, "queued") self.assertEqual(recipients["to1@example.com"].message_id, "id-to1") - self.assertEqual(recipients["to2@example.com"].status, "sent") + self.assertEqual(recipients["to2@example.com"].status, "queued") self.assertEqual(recipients["to2@example.com"].message_id, "id-to2") - self.assertEqual(recipients["cc1@example.com"].status, "sent") + self.assertEqual(recipients["cc1@example.com"].status, "queued") self.assertEqual(recipients["cc1@example.com"].message_id, "id-cc1") - self.assertEqual(recipients["cc2@example.com"].status, "sent") + self.assertEqual(recipients["cc2@example.com"].status, "queued") self.assertEqual(recipients["cc2@example.com"].message_id, "id-cc2") - self.assertEqual(recipients["bcc1@example.com"].status, "sent") + self.assertEqual(recipients["bcc1@example.com"].status, "queued") self.assertEqual(recipients["bcc1@example.com"].message_id, "id-bcc1") - self.assertEqual(recipients["bcc2@example.com"].status, "sent") + self.assertEqual(recipients["bcc2@example.com"].status, "queued") self.assertEqual(recipients["bcc2@example.com"].message_id, "id-bcc2") self.assertEqual(msg.anymail_status.esp_response.json(), response_content) @@ -820,7 +820,7 @@ def test_sandbox_send(self): self.message.send() self.assert_esp_called("https://sandbox.api.mailtrap.io/api/send/12345") - self.assertEqual(self.message.anymail_status.status, {"sent"}) + self.assertEqual(self.message.anymail_status.status, {"queued"}) self.assertEqual( self.message.anymail_status.message_id, "sandbox-single-id", From 88210df909248fd23484a18bf6ed9935737bda8e Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Sat, 29 Nov 2025 16:56:20 -0800 Subject: [PATCH 06/14] Update integration tests Run tests for both transactional and sandbox APIs. Had to disable most sandbox tests due to limit of 1 send per 10 seconds on free plan. (Batch sends in the sandbox immediately exceed that.) --- tests/test_mailtrap_integration.py | 235 +++++++++++++++++++++++------ 1 file changed, 192 insertions(+), 43 deletions(-) diff --git a/tests/test_mailtrap_integration.py b/tests/test_mailtrap_integration.py index 69352ec1..a8b7101b 100644 --- a/tests/test_mailtrap_integration.py +++ b/tests/test_mailtrap_integration.py @@ -9,40 +9,176 @@ from .utils import AnymailTestMixin, sample_image_path +# Environment variables to run these live integration tests... +# API token for both sets of tests: ANYMAIL_TEST_MAILTRAP_API_TOKEN = os.getenv("ANYMAIL_TEST_MAILTRAP_API_TOKEN") -ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID = os.getenv("ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID") -# Optional: if provided, use for nicer From address; sandbox doesn't require this +# Validated sending domain for transactional API tests: ANYMAIL_TEST_MAILTRAP_DOMAIN = os.getenv("ANYMAIL_TEST_MAILTRAP_DOMAIN") +# Test inbox id for sandbox API tests: +ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID = os.getenv("ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID") +# Template id for both sets of tests: ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID = os.getenv("ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID") +@tag("mailtrap", "live") +@unittest.skipUnless( + ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_DOMAIN, + "Set ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_DOMAIN" + " environment variables to run Mailtrap transactional integration tests", +) +@override_settings( + ANYMAIL={ + "MAILTRAP_API_TOKEN": ANYMAIL_TEST_MAILTRAP_API_TOKEN, + }, + EMAIL_BACKEND="anymail.backends.mailtrap.EmailBackend", +) +class MailtrapBackendTransactionalIntegrationTests(AnymailTestMixin, SimpleTestCase): + """ + Mailtrap API integration tests using transactional API + + These tests run against the live Mailtrap Transactional API. + They send real email (to /dev/null mailboxes on the anymail.dev domain). + """ + + def setUp(self): + super().setUp() + from_domain = ANYMAIL_TEST_MAILTRAP_DOMAIN + self.from_email = f"from@{from_domain}" + self.message = AnymailMessage( + "Anymail Mailtrap integration test", + "Text content", + self.from_email, + ["test+to1@anymail.dev"], + ) + self.message.attach_alternative("

HTML content

", "text/html") + + def test_simple_send(self): + # Example of getting the Mailtrap send status and message id from the message + sent_count = self.message.send() + self.assertEqual(sent_count, 1) + + anymail_status = self.message.anymail_status + sent_status = anymail_status.recipients["test+to1@anymail.dev"].status + message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id + + self.assertEqual(sent_status, "queued") + self.assertGreater(len(message_id), 0) # non-empty string + # set of all recipient statuses: + self.assertEqual(anymail_status.status, {sent_status}) + self.assertEqual(anymail_status.message_id, message_id) + + def test_all_options(self): + message = AnymailMessage( + subject="Anymail Mailtrap all-options integration test", + body="This is the text body", + from_email=formataddr(("Test From, with comma", self.from_email)), + to=["test+to1@anymail.dev", "Recipient 2 "], + cc=["test+cc1@anymail.dev", "Copy 2 "], + bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], + reply_to=["reply1@example.com", "Reply 2 "], + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + # no send_at support + metadata={"meta1": "simple string", "meta2": 2}, + tags=["tag 1"], # max one tag + # no track_clicks/track_opens support + # either of these merge_ options will force batch send + # (unique message for each "to" recipient) + merge_metadata={ + "test+to1@anymail.dev": {"customer-id": "ZXK9123"}, + "test+to2@anymail.dev": {"customer-id": "ZZT4192"}, + }, + merge_headers={ + "test+to1@anymail.dev": { + "List-Unsubscribe": "", + }, + "test+to2@anymail.dev": { + "List-Unsubscribe": "", + }, + }, + ) + message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") + message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") + cid = message.attach_inline_image_file(sample_image_path()) + message.attach_alternative( + "

HTML: with link" + "and image: " % cid, + "text/html", + ) + + message.send() + self.assertEqual(message.anymail_status.status, {"queued"}) + self.assertEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].status, "queued" + ) + self.assertEqual( + message.anymail_status.recipients["test+to2@anymail.dev"].status, "queued" + ) + # distinct messages should have different message_ids: + self.assertNotEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].message_id, + message.anymail_status.recipients["test+to2@anymail.dev"].message_id, + ) + + def test_invalid_from(self): + self.message.from_email = "webmaster@localhost" # Django's default From + with self.assertRaises(AnymailAPIError) as cm: + self.message.send() + err = cm.exception + self.assertEqual(err.status_code, 401) + self.assertIn("Unauthorized", str(err)) + + @unittest.skipUnless( + ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, + "Set ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID to test Mailtrap stored templates", + ) + def test_template(self): + message = AnymailMessage( + from_email=self.from_email, + to=["test+to1@anymail.dev", "Second Recipient "], + template_id=ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, + merge_data={ + "test+to1@anymail.dev": {"name": "Recipient 1", "order_no": "12345"}, + "test+to2@anymail.dev": {"order_no": "6789"}, + }, + merge_global_data={"name": "Valued Customer"}, + ) + message.send() + self.assertEqual(message.anymail_status.status, {"queued"}) + + @override_settings(ANYMAIL={"MAILTRAP_API_TOKEN": "Hey, that's not an API token!"}) + def test_invalid_api_token(self): + # Invalid API key generates same error as unvalidated from address + with self.assertRaisesMessage(AnymailAPIError, "Unauthorized"): + self.message.send() + + @tag("mailtrap", "live") @unittest.skipUnless( ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID, "Set ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID" - " environment variables to run Mailtrap integration tests", + " environment variables to run Mailtrap sandbox integration tests", ) @override_settings( ANYMAIL={ "MAILTRAP_API_TOKEN": ANYMAIL_TEST_MAILTRAP_API_TOKEN, - # Use Mailtrap sandbox (testing) API so we don't actually send email "MAILTRAP_TEST_INBOX_ID": ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID, - # You can override MAILTRAP_TEST_API_URL via env if needed; default is fine }, EMAIL_BACKEND="anymail.backends.mailtrap.EmailBackend", ) -class MailtrapBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): - """Mailtrap API integration tests (using sandbox testing inbox) +class MailtrapBackendSandboxIntegrationTests(AnymailTestMixin, SimpleTestCase): + """ + Mailtrap API integration tests using sandbox testing inbox - These tests run against the live Mailtrap API in testing mode, using - ANYMAIL_TEST_MAILTRAP_API_TOKEN for authentication and - ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID for the sandbox inbox id. No real - email is sent in this mode. + These tests run against the live Mailtrap Test API ("sandbox"). + Mail is delivered to the test inbox; no email is sent. """ def setUp(self): super().setUp() - from_domain = ANYMAIL_TEST_MAILTRAP_DOMAIN or "anymail.dev" + from_domain = ANYMAIL_TEST_MAILTRAP_DOMAIN or "example.com" self.from_email = f"from@{from_domain}" self.message = AnymailMessage( "Anymail Mailtrap integration test", @@ -61,74 +197,86 @@ def test_simple_send(self): sent_status = anymail_status.recipients["test+to1@anymail.dev"].status message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id - self.assertEqual(sent_status, "sent") # Mailtrap reports sent on success + self.assertEqual(sent_status, "queued") # Mailtrap reports queued on success self.assertRegex(message_id, r".+") # non-empty string # set of all recipient statuses: self.assertEqual(anymail_status.status, {sent_status}) self.assertEqual(anymail_status.message_id, message_id) + @unittest.skip("Batch with two recipients exceeds rate limit on free plan") def test_all_options(self): message = AnymailMessage( subject="Anymail Mailtrap all-options integration test", body="This is the text body", from_email=formataddr(("Test From, with comma", self.from_email)), - to=[ - "test+to1@anymail.dev", - "Recipient 2 ", - ], + to=["test+to1@anymail.dev", "Recipient 2 "], cc=["test+cc1@anymail.dev", "Copy 2 "], bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], - reply_to=[ - '"Reply, with comma" ', - "reply2@example.com", - ], - headers={"X-Anymail-Test": "value", "X-Anymail-Count": "3"}, + reply_to=["reply1@example.com", "Reply 2 "], + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + # no send_at support metadata={"meta1": "simple string", "meta2": 2}, - # Mailtrap supports only a single tag/category - tags=["tag 1"], - track_clicks=True, - track_opens=True, + tags=["tag 1"], # max one tag + # no track_clicks/track_opens support + # either of these merge_ options will force batch send + # (unique message for each "to" recipient) + merge_metadata={ + "test+to1@anymail.dev": {"customer-id": "ZXK9123"}, + "test+to2@anymail.dev": {"customer-id": "ZZT4192"}, + }, + merge_headers={ + "test+to1@anymail.dev": { + "List-Unsubscribe": "", + }, + "test+to2@anymail.dev": { + "List-Unsubscribe": "", + }, + }, ) message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") cid = message.attach_inline_image_file(sample_image_path()) message.attach_alternative( "

HTML: with link" - f"and image: ", + "and image: " % cid, "text/html", ) message.send() - self.assertEqual(message.anymail_status.status, {"sent"}) + self.assertEqual(message.anymail_status.status, {"queued"}) self.assertEqual( - message.anymail_status.recipients["test+to1@anymail.dev"].status, "sent" + message.anymail_status.recipients["test+to1@anymail.dev"].status, "queued" ) self.assertEqual( - message.anymail_status.recipients["test+to2@anymail.dev"].status, "sent" + message.anymail_status.recipients["test+to2@anymail.dev"].status, "queued" + ) + # distinct messages should have different message_ids: + self.assertNotEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].message_id, + message.anymail_status.recipients["test+to2@anymail.dev"].message_id, ) + @unittest.skip("Batch with two recipients exceeds rate limit on free plan") @unittest.skipUnless( ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, "Set ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID to test Mailtrap stored templates", ) - def test_stored_template(self): + def test_template(self): message = AnymailMessage( - # UUID of a template available in your Mailtrap account - template_id=ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, + from_email=self.from_email, to=["test+to1@anymail.dev", "Second Recipient "], - merge_global_data={ # Mailtrap uses template_variables for global vars - "company_info_name": "Test_Company_info_name", - "name": "Test_Name", - "company_info_address": "Test_Company_info_address", - "company_info_city": "Test_Company_info_city", - "company_info_zip_code": "Test_Company_info_zip_code", - "company_info_country": "Test_Company_info_country", + template_id=ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, + merge_data={ + "test+to1@anymail.dev": {"name": "Recipient 1", "order_no": "12345"}, + "test+to2@anymail.dev": {"order_no": "6789"}, }, + merge_global_data={"name": "Valued Customer"}, ) - # Use template's configured sender if desired - message.from_email = self.from_email message.send() - self.assertEqual(message.anymail_status.status, {"sent"}) + self.assertEqual(message.anymail_status.status, {"queued"}) @override_settings( ANYMAIL={ @@ -140,4 +288,5 @@ def test_invalid_api_token(self): with self.assertRaises(AnymailAPIError) as cm: self.message.send() err = cm.exception + self.assertIn("Unauthorized", str(err)) self.assertEqual(err.status_code, 401) From 371101e2b782bbd8813f149a005b61403a46e180 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Sat, 29 Nov 2025 19:42:33 -0800 Subject: [PATCH 07/14] Rename MAILTRAP_TEST_INBOX_ID to MAILTRAP_SANDBOX_ID Mailtrap's product name is "Email Sandbox," and they consistently use the term "sandbox" (or sometimes "inbox" in context, but not "test inbox") throughout their UI and docs. --- anymail/backends/mailtrap.py | 10 +++++----- tests/test_mailtrap_backend.py | 6 +++--- tests/test_mailtrap_integration.py | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/anymail/backends/mailtrap.py b/anymail/backends/mailtrap.py index a9d65dea..f2cda72c 100644 --- a/anymail/backends/mailtrap.py +++ b/anymail/backends/mailtrap.py @@ -89,8 +89,8 @@ def __init__( def get_api_endpoint(self): endpoint = "batch" if self.is_batch() else "send" if self.backend.use_sandbox: - test_inbox_id = quote(str(self.backend.test_inbox_id), safe="") - return f"{endpoint}/{test_inbox_id}" + sandbox_id = quote(str(self.backend.sandbox_id), safe="") + return f"{endpoint}/{sandbox_id}" else: return endpoint @@ -256,10 +256,10 @@ def __init__(self, **kwargs): self.api_token = get_anymail_setting( "api_token", esp_name=self.esp_name, kwargs=kwargs, allow_bare=True ) - self.test_inbox_id = get_anymail_setting( - "test_inbox_id", esp_name=self.esp_name, kwargs=kwargs, default=None + self.sandbox_id = get_anymail_setting( + "sandbox_id", esp_name=self.esp_name, kwargs=kwargs, default=None ) - self.use_sandbox = self.test_inbox_id is not None + self.use_sandbox = self.sandbox_id is not None api_url = get_anymail_setting( "api_url", diff --git a/tests/test_mailtrap_backend.py b/tests/test_mailtrap_backend.py index 258f8e61..124e322a 100644 --- a/tests/test_mailtrap_backend.py +++ b/tests/test_mailtrap_backend.py @@ -812,7 +812,7 @@ def test_wrong_message_id_count(self): # noinspection PyUnresolvedReferences @override_settings( - ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_TEST_INBOX_ID": 12345} + ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_SANDBOX_ID": 12345} ) def test_sandbox_send(self): self.set_mock_response_message_ids(["sandbox-single-id"]) @@ -835,7 +835,7 @@ def test_sandbox_send(self): ) @override_settings( - ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_TEST_INBOX_ID": 12345} + ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_SANDBOX_ID": 12345} ) def test_sandbox_batch_send(self): self.set_mock_response( @@ -879,7 +879,7 @@ def test_sandbox_batch_send(self): ) @override_settings( - ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_TEST_INBOX_ID": 12345} + ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_SANDBOX_ID": 12345} ) def test_wrong_message_id_count_sandbox(self): self.set_mock_response_message_ids(2) diff --git a/tests/test_mailtrap_integration.py b/tests/test_mailtrap_integration.py index a8b7101b..af55ac97 100644 --- a/tests/test_mailtrap_integration.py +++ b/tests/test_mailtrap_integration.py @@ -15,7 +15,7 @@ # Validated sending domain for transactional API tests: ANYMAIL_TEST_MAILTRAP_DOMAIN = os.getenv("ANYMAIL_TEST_MAILTRAP_DOMAIN") # Test inbox id for sandbox API tests: -ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID = os.getenv("ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID") +ANYMAIL_TEST_MAILTRAP_SANDBOX_ID = os.getenv("ANYMAIL_TEST_MAILTRAP_SANDBOX_ID") # Template id for both sets of tests: ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID = os.getenv("ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID") @@ -157,14 +157,14 @@ def test_invalid_api_token(self): @tag("mailtrap", "live") @unittest.skipUnless( - ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID, - "Set ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID" + ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_SANDBOX_ID, + "Set ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_SANDBOX_ID" " environment variables to run Mailtrap sandbox integration tests", ) @override_settings( ANYMAIL={ "MAILTRAP_API_TOKEN": ANYMAIL_TEST_MAILTRAP_API_TOKEN, - "MAILTRAP_TEST_INBOX_ID": ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID, + "MAILTRAP_SANDBOX_ID": ANYMAIL_TEST_MAILTRAP_SANDBOX_ID, }, EMAIL_BACKEND="anymail.backends.mailtrap.EmailBackend", ) @@ -281,7 +281,7 @@ def test_template(self): @override_settings( ANYMAIL={ "MAILTRAP_API_TOKEN": "Hey, that's not an API token!", - "MAILTRAP_TEST_INBOX_ID": ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID, + "MAILTRAP_SANDBOX_ID": ANYMAIL_TEST_MAILTRAP_SANDBOX_ID, } ) def test_invalid_api_token(self): From 1563a314eecdfa27abaaa3c7a363ef191a6bb575 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Sat, 29 Nov 2025 19:50:34 -0800 Subject: [PATCH 08/14] Don't use sandbox API when MAILTRAP_SANDBOX_ID is empty string Simplifies using env file (with an empty value) to control whether to use sandbox. --- anymail/backends/mailtrap.py | 2 +- tests/test_mailtrap_backend.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/anymail/backends/mailtrap.py b/anymail/backends/mailtrap.py index f2cda72c..7de48042 100644 --- a/anymail/backends/mailtrap.py +++ b/anymail/backends/mailtrap.py @@ -259,7 +259,7 @@ def __init__(self, **kwargs): self.sandbox_id = get_anymail_setting( "sandbox_id", esp_name=self.esp_name, kwargs=kwargs, default=None ) - self.use_sandbox = self.sandbox_id is not None + self.use_sandbox = bool(self.sandbox_id) api_url = get_anymail_setting( "api_url", diff --git a/tests/test_mailtrap_backend.py b/tests/test_mailtrap_backend.py index 124e322a..7b6d548f 100644 --- a/tests/test_mailtrap_backend.py +++ b/tests/test_mailtrap_backend.py @@ -834,6 +834,14 @@ def test_sandbox_send(self): "sandbox-single-id", ) + @override_settings( + ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_SANDBOX_ID": ""} + ) + def test_sandbox_id_empty_string(self): + """Use transactional API when MAILTRAP_SANDBOX_ID is an empty string.""" + self.message.send() + self.assert_esp_called("https://send.api.mailtrap.io/api/send") + @override_settings( ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_SANDBOX_ID": 12345} ) From 18d6dd30fa4eb3d94e6f15298df7b7c102044b35 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Sat, 29 Nov 2025 21:06:03 -0800 Subject: [PATCH 09/14] Update docs --- anymail/webhooks/mailtrap.py | 1 + docs/esps/esp-feature-matrix.csv | 8 +- docs/esps/mailtrap.rst | 213 +++++++++++++++++++++++++++---- 3 files changed, 191 insertions(+), 31 deletions(-) diff --git a/anymail/webhooks/mailtrap.py b/anymail/webhooks/mailtrap.py index 1adcd7b0..ddd11b1a 100644 --- a/anymail/webhooks/mailtrap.py +++ b/anymail/webhooks/mailtrap.py @@ -12,6 +12,7 @@ class MailtrapEvent(TypedDict): + # https://api-docs.mailtrap.io/docs/mailtrap-api-docs/016fe2a1efd5a-receive-events-json-format event: Literal[ "delivery", "open", diff --git a/docs/esps/esp-feature-matrix.csv b/docs/esps/esp-feature-matrix.csv index b9813257..c3681264 100644 --- a/docs/esps/esp-feature-matrix.csv +++ b/docs/esps/esp-feature-matrix.csv @@ -1,18 +1,18 @@ 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:`scaleway-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend` -Anymail support status [#support-status]_,Full,Full,Full,Full,Full,Limited,Limited,Limited,Full,Full,Full,**Unsupported**,Full,Full +Anymail support status [#support-status]_,Full,Full,Full,Full,Full,Full,Limited,Limited,Full,Full,Full,**Unsupported**,Full,Full .. rubric:: :ref:`Anymail send options `,,,,,,,,,,,,,, :attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,No,Domain only,Yes,No,No,No,No,Yes,No :attr:`~AnymailMessage.merge_headers`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes,No,No,Yes,Yes,No,Yes,Yes [#caveats]_,Yes [#caveats]_ :attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_metadata`,Yes [#caveats]_,Yes,No,Yes,Yes,No,Yes,No,Yes,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_metadata`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes,Yes,No,Yes,Yes,No,Yes,Yes,Yes :attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,No,Yes,No,No,Yes,No,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,Yes,Max 1 tag,Yes :attr:`~AnymailMessage.track_clicks`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,No [#nocontrol]_,Yes,No,Yes,No,No,Yes,Yes,Yes :attr:`~AnymailMessage.track_opens`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,No [#nocontrol]_,Yes,No,Yes,No,No,Yes,Yes,Yes -:ref:`amp-email`,Yes,No,No,Yes,No,Yes,No,No,No,No,No,Yes,Yes,Yes +:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,No,No,Yes,Yes,Yes .. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,,,, :attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,No,Yes,No,Yes,No,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes :attr:`~AnymailMessage.merge_global_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,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,Yes,Yes diff --git a/docs/esps/mailtrap.rst b/docs/esps/mailtrap.rst index 1b9b0da5..62eacd99 100644 --- a/docs/esps/mailtrap.rst +++ b/docs/esps/mailtrap.rst @@ -3,11 +3,25 @@ Mailtrap ======== -Anymail integrates with `Mailtrap `_'s -transactional or test (sandbox) email services, using the -`Mailtrap REST API v2`_. +.. versionadded:: vNext -.. _Mailtrap REST API v2: https://api-docs.mailtrap.io/docs/mailtrap-api-docs/ +Anymail integrates with `Mailtrap`_'s Email API/SMTP (transactional) and +Email Sandbox (test) email services, using the `Mailtrap API v2`_. +(Anymail uses Mailtrap's REST-oriented HTTP API, not the SMTP protocol.) + +Anymail should also work correctly with Mailtrap's Bulk Sending service +(which uses an identical API), but this scenario is not tested separately. + +.. note:: + + **Troubleshooting:** + If your Mailtrap transactional or bulk messages aren't being delivered + as expected, check the `Email Logs`_ in Mailtrap's dashboard. + The "Event History" tab for an individual message is often helpful. + +.. _Mailtrap: https://mailtrap.io +.. _Mailtrap API v2: https://api-docs.mailtrap.io/docs/mailtrap-api-docs/ +.. _Email Logs: https://mailtrap.io/sending/email_logs Settings @@ -20,12 +34,16 @@ To use Anymail's Mailtrap backend, set: EMAIL_BACKEND = "anymail.backends.mailtrap.EmailBackend" ANYMAIL = { "MAILTRAP_API_TOKEN": "", - # Optional, to use the sandbox API: - "MAILTRAP_TEST_INBOX_ID": , + # Only to use the Email Sandbox service: + "MAILTRAP_SANDBOX_ID": , } in your settings.py. +When :setting:`MAILTRAP_SANDBOX_ID ` is set, +Anymail uses Mailtrap's Email Sandbox service. If it is not set, Anymail +uses Mailtrap's transactional Email API/SMTP service. + .. setting:: ANYMAIL_MAILTRAP_API_TOKEN @@ -45,20 +63,26 @@ root of the settings file if neither ``ANYMAIL["MAILTRAP_API_TOKEN"]`` nor ``ANYMAIL_MAILTRAP_API_TOKEN`` is set. -.. setting:: ANYMAIL_MAILTRAP_TEST_INBOX_ID +.. setting:: ANYMAIL_MAILTRAP_SANDBOX_ID -.. rubric:: MAILTRAP_TEST_INBOX_ID +.. rubric:: MAILTRAP_SANDBOX_ID -Required to use Mailtrap's test inbox. (If not provided, emails will be sent -using Mailbox's transactional API.) +Required to use Mailtrap's Email Sandbox test inbox. (And must *not* be set +to use Mailtrap's Email API/SMTP transactional service.) .. code-block:: python ANYMAIL = { ... - "MAILTRAP_TEST_INBOX_ID": 12345, + "MAILTRAP_SANDBOX_ID": 12345, } +The sandbox id can be found in Mailtrap's dashboard: click into the desired +sandbox and look for the number in the dashboard url. For example, +``https://mailtrap.io/inboxes/12345/messages`` would be sandbox id 12345. + +The value can be a string or number. For convenience when using env files, +Anymail treats an empty string or ``None`` (or any falsy value) as "not set." .. setting:: ANYMAIL_MAILTRAP_API_URL @@ -67,10 +91,10 @@ using Mailbox's transactional API.) The base url for calling the Mailtrap API. The default is ``MAILTRAP_API_URL = "https://send.api.mailtrap.io/api/"`` -(Mailtrap's transactional service) -if :setting:`MAILTRAP_TEST_INBOX_ID ` is not set, -or ``"https://sandbox.api.mailtrap.io/api/"`` (Mailbox's sandbox testing service) -when a test inbox id is provided. +(Mailtrap's Email API/SMTP transactional service) +if :setting:`MAILTRAP_SANDBOX_ID ` is not set, +or ``"https://sandbox.api.mailtrap.io/api/"`` (Mailbox's Email Sandbox testing +service) when a sandbox id is provided. Most users should not need to change this setting. However, you could set it to use Mailtrap's bulk send service: @@ -82,11 +106,40 @@ to use Mailtrap's bulk send service: "MAILTRAP_API_URL": "https://bulk.api.mailtrap.io/api/", } -(Note that Anymail has not been tested for use with Mailtrap's bulk API.) +(Note that Anymail is not specifically tested with Mailtrap's bulk API.) + +The value must be only the API base URL: do not include the ``"/send"`` endpoint. +(When provided, this is used as the base URL *always*. If you are also setting +a sandbox id, the base URL must be compatible with Mailtrap's sandbox API.) + + +.. _mailtrap-esp-extra: + +esp_extra support +----------------- + +To use Mailtrap features not directly supported by Anymail, you can +set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to +a `dict` of Mailtraps's `Send email API body parameters`_. +Your :attr:`esp_extra` dict will be deeply merged into the Mailtrap +API payload, with `esp_extra` having precedence in conflicts. +(For batch sends, the `esp_extra` values are merged into the ``"base"`` +payload shared by all recipients.) + +Example: -The value must be only the API base URL: do not include the ``"/send"`` endpoint -or your test inbox id. + .. code-block:: python + message.esp_extra = { + "future_mailtrap_feature": "value" + } + + +(You can also set `"esp_extra"` in Anymail's :ref:`global send defaults ` +to apply it to all messages.) + +.. _Send email API body parameters: + https://api-docs.mailtrap.io/docs/mailtrap-api-docs/67f1d70aeb62c-send-email-including-templates#request-body .. _mailtrap-quirks: @@ -94,10 +147,84 @@ or your test inbox id. Limitations and quirks ---------------------- -**merge_data and merge_metadata not yet supported** - Mailtrap supports :ref:`ESP stored templates `, - but Anymail does not yet support per-recipient merge data with their - batch sending APIs. +**Single tag** + Anymail uses Mailtrap's ``"category"`` option for tags, and Mailtrap allows + only a single category per message. If your message has two or more + :attr:`~anymail.message.AnymailMessage.tags`, you'll get an + :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error---or + if you've enabled :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`, + Anymail will use only the first tag. + +**No delayed sending** + Mailtrap does not support :attr:`~anymail.message.AnymailMessage.send_at`. + +**Attachments require filenames** + Mailtrap requires that all attachments and inline images have filenames. If you + don't supply a filename, Anymail will use ``"attachment"`` as the filename. + +**No click-tracking or open-tracking options** + Mailtrap does not provide a way to control open or click tracking for individual + messages. Anymail's :attr:`~anymail.message.AnymailMessage.track_clicks` and + :attr:`~anymail.message.AnymailMessage.track_opens` settings are unsupported. + (You *can* `exclude specific links from tracking`_ using Mailtrap-proprietary + attributes in your HTML.) + +**No envelope sender overrides** + Mailtrap does not support overriding :attr:`~anymail.message.AnymailMessage.envelope_sender`. + + +.. _exclude specific links from tracking: + https://help.mailtrap.io/article/184-excluding-specific-links-from-tracking + +.. _mailtrap-templates: + +Batch sending/merge and ESP templates +------------------------------------- + +Mailtrap offers both :ref:`ESP stored templates ` +and :ref:`batch sending ` with per-recipient merge data. + +When you send a message with multiple ``to`` addresses, the +:attr:`~anymail.message.AnymailMessage.merge_data`, +:attr:`~anymail.message.AnymailMessage.merge_metadata` +and :attr:`~anymail.message.AnymailMessage.merge_headers` properties +determine how many distinct messages are sent: + +* If the ``merge_...`` properties are *not* set (the default), Anymail + will tell Mailtrap to send a single message, and all recipients will see + the complete list of To addresses. +* If *any* of the ``merge_...`` properties are set---even to an empty `{}` dict, + Anymail will tell Mailtrap to send a separate message for each ``to`` + address, and the recipients won't see the other To addresses. + +You can use a Mailtrap stored template by setting a message's +:attr:`~anymail.message.AnymailMessage.template_id` to the template's +"Template UUID." Find the template UUID in the Templates section of Mailtrap's +dashboard, under the template's details. When a Mailtrap template is used, +your Django code must not provide a message subject or text or html body. + +Supply the template merge data values with Anymail's +normalized :attr:`~anymail.message.AnymailMessage.merge_data` +and :attr:`~anymail.message.AnymailMessage.merge_global_data` +message attributes. + + .. code-block:: python + + message = EmailMessage( + from_email="from@example.com", + to=["alice@example.com", "Bob "], + # omit subject and body (or set to None) to use template content + ... + ) + message.template_id = "11111111-abcd-1234-0000-0123456789ab" # Template UUID + message.merge_data = { + 'alice@example.com': {'name': "Alice", 'order_no': "12345"}, + 'bob@example.com': {'name': "Bob", 'order_no': "54321"}, + } + message.merge_global_data = { + 'ship_date': "May 15", + } + message.send() .. _mailtrap-webhooks: @@ -105,10 +232,42 @@ Limitations and quirks 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.) +If you are using Anymail's normalized :ref:`status tracking `, +create a webhook in the Settings section of the Mailtrap dashboard under Webhooks. +See their `Webhooks help`_ article for more information. + +(Note that Mailtrap's Email Sandbox service does not trigger webhook events.) + +In Mailtrap's "Add new webhook" screen, enter: + +* Webhook URL: + + :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mailtrap/tracking/` + + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret + * *yoursite.example.com* is your Django site + +* Payload format: JSON (*not* JSON Lines) + +* Select area: Email Sending + + * Select stream: Transactional (unless you have overridden Anymail's + :setting:`MAILTRAP_API_URL ` to use Mailtrap's + bulk sending API). + * Select domain: the desired sending domain(s) + * Select events to listen to: check all you want to receive + +Mailtrap will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s: +rejected, bounced, deferred, delivered, opened, clicked, complained, unsubscribed. + + +.. _Webhooks help: https://help.mailtrap.io/article/102-webhooks + + +.. _mailtrap-inbound: +Inbound webhook +--------------- -.. _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 +Mailtrap's inbound service is currently under development, and APIs are not +yet publicly available. From ae1ebec89707cfc82d023a183d2ec8ce4671dbb9 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Sun, 30 Nov 2025 14:07:17 -0800 Subject: [PATCH 10/14] Update integration test workflow --- .github/workflows/integration-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 894ecb12..812b4358 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -44,7 +44,7 @@ jobs: - { tox: django52-py313-mailersend, python: "3.13" } - { tox: django52-py313-mailgun, python: "3.13" } - { tox: django52-py313-mailjet, python: "3.13" } - - { tox: django41-py313-mailtrap, python: "3.13" } + - { tox: django52-py313-mailtrap, python: "3.13" } - { tox: django52-py313-mandrill, python: "3.13" } - { tox: django52-py313-postal, python: "3.13" } - { tox: django52-py313-postmark, python: "3.13" } @@ -91,9 +91,9 @@ jobs: ANYMAIL_TEST_MAILJET_SECRET_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_SECRET_KEY }} ANYMAIL_TEST_MAILJET_TEMPLATE_ID: ${{ vars.ANYMAIL_TEST_MAILJET_TEMPLATE_ID }} ANYMAIL_TEST_MAILTRAP_API_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILTRAP_API_TOKEN }} - ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID: ${{ vars.ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID }} - ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID: ${{ vars.ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID }} ANYMAIL_TEST_MAILTRAP_DOMAIN: ${{ vars.ANYMAIL_TEST_MAILTRAP_DOMAIN }} + ANYMAIL_TEST_MAILTRAP_SANDBOX_ID: ${{ vars.ANYMAIL_TEST_MAILTRAP_SANDBOX_ID }} + ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID: ${{ vars.ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID }} ANYMAIL_TEST_MANDRILL_API_KEY: ${{ secrets.ANYMAIL_TEST_MANDRILL_API_KEY }} ANYMAIL_TEST_MANDRILL_DOMAIN: ${{ secrets.ANYMAIL_TEST_MANDRILL_DOMAIN }} ANYMAIL_TEST_POSTMARK_DOMAIN: ${{ secrets.ANYMAIL_TEST_POSTMARK_DOMAIN }} From 5a2bc61dd5e44a0dd83a854930ce06a1d7ea42e7 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Sun, 30 Nov 2025 17:35:26 -0800 Subject: [PATCH 11/14] Couple of cleanup items - Note a couple more limitations and quirks in the docs. - Update test_reply_to() to ensure we handle _structured_ header encoding. (We do. But Mailtrap would use _unstructured_ header encoding if we ever didn't.) --- docs/esps/mailtrap.rst | 14 ++++++++++++++ tests/test_mailtrap_backend.py | 11 +++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/esps/mailtrap.rst b/docs/esps/mailtrap.rst index 62eacd99..c80cd790 100644 --- a/docs/esps/mailtrap.rst +++ b/docs/esps/mailtrap.rst @@ -155,6 +155,20 @@ Limitations and quirks if you've enabled :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`, Anymail will use only the first tag. +**Tag not compatible with template** + Trying to send with both :attr:`~anymail.message.AnymailMessage.tags` and a + :attr:`~anymail.message.AnymailMessage.template_id` will result in a Mailtrap + API error that "'category' is not allowed with 'template_uuid'." + +**Error when non-ASCII From name includes comma** + Trying to send a message with a ``from_email`` display name containing both + a non-ASCII Unicode character *and* a comma (e.g., ``'"Ng, Göta" '``) + will result in a Mailtrap API error that the "'From:' header does not match + the sender's domain." This does not affect other address fields (like ``to`` + or ``reply_to``). It appears to be a limitation of Mailtrap's API, and there + is no general workaround Anymail could apply. To avoid the problem, you must + rework your *From* address to either remove either the comma or be ASCII-only. + **No delayed sending** Mailtrap does not support :attr:`~anymail.message.AnymailMessage.send_at`. diff --git a/tests/test_mailtrap_backend.py b/tests/test_mailtrap_backend.py index 7b6d548f..be9d3906 100644 --- a/tests/test_mailtrap_backend.py +++ b/tests/test_mailtrap_backend.py @@ -164,14 +164,21 @@ def test_extra_headers_serialization_error(self): def test_reply_to(self): # Reply-To is handled as a header, rather than API "reply_to" field, # to support multiple addresses. - self.message.reply_to = ["reply@example.com", "Other "] + self.message.reply_to = [ + "reply@example.com", + '"Other, with comma" ', + "Інше ", + ] self.message.extra_headers = {"X-Other": "Keep"} self.message.send() data = self.get_api_call_json() self.assertEqual( data["headers"], { - "Reply-To": "reply@example.com, Other ", + # Reply-To must be properly formatted as an address header: + "Reply-To": "reply@example.com," + ' "Other, with comma" ,' + " =?utf-8?b?0IbQvdGI0LU=?= ", "X-Other": "Keep", }, ) From 890a79974cc4a330e85bc6382daa4bbd1ce083c9 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Thu, 4 Dec 2025 15:13:45 -0800 Subject: [PATCH 12/14] Re-enable sandbox integration tests --- tests/test_mailtrap_integration.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_mailtrap_integration.py b/tests/test_mailtrap_integration.py index af55ac97..491e9ddf 100644 --- a/tests/test_mailtrap_integration.py +++ b/tests/test_mailtrap_integration.py @@ -203,7 +203,6 @@ def test_simple_send(self): self.assertEqual(anymail_status.status, {sent_status}) self.assertEqual(anymail_status.message_id, message_id) - @unittest.skip("Batch with two recipients exceeds rate limit on free plan") def test_all_options(self): message = AnymailMessage( subject="Anymail Mailtrap all-options integration test", @@ -259,7 +258,6 @@ def test_all_options(self): message.anymail_status.recipients["test+to2@anymail.dev"].message_id, ) - @unittest.skip("Batch with two recipients exceeds rate limit on free plan") @unittest.skipUnless( ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, "Set ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID to test Mailtrap stored templates", From 508777372946bbcd9853c351f765cf1cd7ab21eb Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Wed, 17 Dec 2025 15:10:07 -0800 Subject: [PATCH 13/14] Use improved Unicode handling in attachments and address headers Update to match PRs #448 and #450. Update docs to reflect Mailtrap's recent bug fix for Unicode From display-name with comma, and add information on Unicode handling from API testing. Use Mailtrap's `reply_to` parameter for single reply addresses. (Only compose a Reply-To header for multiple addresses.) --- anymail/backends/mailtrap.py | 34 +++--- docs/esps/mailtrap.rst | 26 +++-- tests/test_mailtrap_backend.py | 196 +++++++++++++++------------------ 3 files changed, 124 insertions(+), 132 deletions(-) diff --git a/anymail/backends/mailtrap.py b/anymail/backends/mailtrap.py index 7de48042..feaf7b8d 100644 --- a/anymail/backends/mailtrap.py +++ b/anymail/backends/mailtrap.py @@ -34,6 +34,7 @@ class MailtrapAttachment(TypedDict): "to": NotRequired[List[MailtrapAddress]], "cc": NotRequired[List[MailtrapAddress]], "bcc": NotRequired[List[MailtrapAddress]], + "reply_to": NotRequired[MailtrapAddress], "attachments": NotRequired[List[MailtrapAttachment]], "headers": NotRequired[Dict[str, str]], "custom_variables": NotRequired[Dict[str, str]], @@ -136,16 +137,8 @@ def burst_for_batch(self) -> MailtrapBatchData: def init_payload(self): self.data: MailtrapData = {} - @staticmethod - def _mailtrap_email(email: EmailAddress) -> MailtrapAddress: - """Expand an Anymail EmailAddress into Mailtrap's {"email", "name"} dict""" - result: MailtrapAddress = {"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) + self.data["from"] = email.as_dict(idna_encode=self.backend.idna_encode) def set_recipients( self, recipient_type: Literal["to", "cc", "bcc"], emails: List[EmailAddress] @@ -153,7 +146,7 @@ def set_recipients( assert recipient_type in ["to", "cc", "bcc"] if emails: self.data[recipient_type] = [ - self._mailtrap_email(email) for email in emails + email.as_dict(idna_encode=self.backend.idna_encode) for email in emails ] if recipient_type == "to": @@ -169,11 +162,21 @@ def set_subject(self, subject): self.data["subject"] = subject def set_reply_to(self, emails: List[EmailAddress]): - if emails: - # Use header rather than "reply_to" param - # to allow multiple reply-to addresses + if len(emails) == 1: + # Let Mailtrap handle the header generation (and EAI if needed) + self.data["reply_to"] = emails[0].as_dict( + idna_encode=self.backend.idna_encode + ) + elif len(emails) >= 2: + # Use header rather than "reply_to" param for multiple reply-to + # addresses. We must format (and encode) the header ourselves. + if any(email.uses_eai for email in emails): + # There's no way for us to encode an EAI address properly. + # (Mailtrap will apply rfc2047 if any 8-bit header content.) + self.unsupported_feature("EAI with multiple reply_to addresses") self.data.setdefault("headers", {})["Reply-To"] = ", ".join( - email.address for email in emails + email.format(use_rfc2047=True, idna_encode=self.backend.idna_encode) + for email in emails ) def set_extra_headers(self, headers): @@ -198,11 +201,10 @@ def add_attachment(self, attachment: Attachment): # Mailtrap requires filename even for inline attachments. # Provide a fallback filename like the Mailjet backend does. "filename": attachment.name or "attachment", + "type": attachment.content_type, "content": attachment.b64content, # default disposition is "attachment" } - if attachment.mimetype: - att["type"] = attachment.mimetype if attachment.inline: att["disposition"] = "inline" att["content_id"] = attachment.cid diff --git a/docs/esps/mailtrap.rst b/docs/esps/mailtrap.rst index c80cd790..6b6bde84 100644 --- a/docs/esps/mailtrap.rst +++ b/docs/esps/mailtrap.rst @@ -160,15 +160,6 @@ Limitations and quirks :attr:`~anymail.message.AnymailMessage.template_id` will result in a Mailtrap API error that "'category' is not allowed with 'template_uuid'." -**Error when non-ASCII From name includes comma** - Trying to send a message with a ``from_email`` display name containing both - a non-ASCII Unicode character *and* a comma (e.g., ``'"Ng, Göta" '``) - will result in a Mailtrap API error that the "'From:' header does not match - the sender's domain." This does not affect other address fields (like ``to`` - or ``reply_to``). It appears to be a limitation of Mailtrap's API, and there - is no general workaround Anymail could apply. To avoid the problem, you must - rework your *From* address to either remove either the comma or be ASCII-only. - **No delayed sending** Mailtrap does not support :attr:`~anymail.message.AnymailMessage.send_at`. @@ -176,6 +167,10 @@ Limitations and quirks Mailtrap requires that all attachments and inline images have filenames. If you don't supply a filename, Anymail will use ``"attachment"`` as the filename. +**Non-ASCII attachment filenames will be garbled** + Mailtrap's API does not properly encode Unicode characters in attachment + filenames. Some email clients will display those characters incorrectly. + **No click-tracking or open-tracking options** Mailtrap does not provide a way to control open or click tracking for individual messages. Anymail's :attr:`~anymail.message.AnymailMessage.track_clicks` and @@ -186,6 +181,19 @@ Limitations and quirks **No envelope sender overrides** Mailtrap does not support overriding :attr:`~anymail.message.AnymailMessage.envelope_sender`. +**Non-ASCII mailboxes (EAI)** + Mailtrap partially supports Unicode mailboxes (the *user* part of + *user\@domain*---see :ref:`EAI `). EAI recipient addresses (to, cc, bcc) + are delivered correctly, but Mailtrap generates invalid header fields that may + display as empty or garbled, depending on the email app. + + Trying to use an EAI ``from_email`` results in a Mailtrap API error that the + "'From' header does not match the sender's domain." + + EAI in ``reply_to`` is supported (though may generate an invalid header + field) for a single address. Using EAI with multiple reply addresses will + cause an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error because + Anymail cannot accurately communicate that to Mailtrap's API. .. _exclude specific links from tracking: https://help.mailtrap.io/article/184-excluding-specific-links-from-tracking diff --git a/tests/test_mailtrap_backend.py b/tests/test_mailtrap_backend.py index be9d3906..9055eb72 100644 --- a/tests/test_mailtrap_backend.py +++ b/tests/test_mailtrap_backend.py @@ -1,10 +1,7 @@ from __future__ import annotations -from base64 import b64encode from datetime import datetime from decimal import Decimal -from email.mime.base import MIMEBase -from email.mime.image import MIMEImage from django.core import mail from django.core.exceptions import ImproperlyConfigured @@ -16,18 +13,17 @@ AnymailSerializationError, AnymailUnsupportedFeature, ) -from anymail.message import AnymailMessage, attach_inline_image_file +from anymail.message import AnymailMessage, attach_inline_image from .mock_requests_backend import ( RequestsBackendMockAPITestCase, SessionSharingTestCases, ) from .utils import ( - SAMPLE_IMAGE_FILENAME, AnymailTestMixin, + create_text_attachment, decode_att, sample_image_content, - sample_image_path, ) @@ -162,12 +158,21 @@ def test_extra_headers_serialization_error(self): self.message.send() def test_reply_to(self): - # Reply-To is handled as a header, rather than API "reply_to" field, - # to support multiple addresses. + self.message.reply_to = ['"Reply, with comma" '] + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["reply_to"], + {"name": "Reply, with comma", "email": "reply@example.com"}, + ) + + def test_multiple_reply_to(self): + # Mailtrap allows a fully-formatted Reply-To header + # instead of the `reply_to` parameter. self.message.reply_to = [ "reply@example.com", '"Other, with comma" ', - "Інше ", + "Інше ", ] self.message.extra_headers = {"X-Other": "Keep"} self.message.send() @@ -175,129 +180,106 @@ def test_reply_to(self): self.assertEqual( data["headers"], { - # Reply-To must be properly formatted as an address header: + # Reply-To must be properly formatted as an address header + # using an RFC 2047 encoded-word for Unicode display-names "Reply-To": "reply@example.com," ' "Other, with comma" ,' - " =?utf-8?b?0IbQvdGI0LU=?= ", + " =?utf-8?b?0IbQvdGI0LU=?= ", "X-Other": "Keep", }, ) - def test_attachments(self): - text_content = "* Item one\n* Item two\n* Item three" - self.message.attach( - filename="test.txt", content=text_content, mimetype="text/plain" - ) - - # Should guess mimetype if not provided... - png_content = b"PNG\xb4 pretend this is the contents of a png file" - self.message.attach(filename="test.png", content=png_content) - - # Should work with a MIMEBase object (also tests no filename)... - pdf_content = b"PDF\xb4 pretend this is valid pdf data" - mimeattachment = MIMEBase("application", "pdf") - mimeattachment.set_payload(pdf_content) - self.message.attach(mimeattachment) + def test_eai_with_multiple_reply_to(self): + # When Anymail formats the Reply-To header, there's no way to handle EAI. + # (EAI single reply_to is included in test_non_ascii_headers() below.) + self.message.reply_to = ["відповідь@example.com", "other@example.com"] + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "EAI with multiple reply_to addresses" + ): + self.message.send() - self.message.send() + def test_non_ascii_headers(self): + # Mailtrap correctly encodes non-ASCII display-names and other headers + # (but requires IDNA encoding for non-ASCII domain names). + # Mailtrap (currently) incorrectly applies rfc2047 to EAI addresses, + # resulting in invalid address headers and an API error for EAI `from`. + # (For these mock backend tests, assume those bugs are fixed.) + email = mail.EmailMessage( + from_email='"Odesílatel, z adresy" <відправник@příklad.example.cz>', + to=['"Příjemce, na adresu" <одержувач@příklad.example.cz>'], + subject="Předmět e-mailu", + reply_to=['"Odpověď, adresa" <відповідь@příklad.example.cz>'], + headers={"X-Extra": "Další"}, + body="Prostý text", + ) + email.send() data = self.get_api_call_json() - attachments = data["attachments"] - self.assertEqual(len(attachments), 3) - self.assertEqual(attachments[0]["filename"], "test.txt") - self.assertEqual(attachments[0]["type"], "text/plain") self.assertEqual( - decode_att(attachments[0]["content"]).decode("ascii"), text_content - ) - self.assertEqual(attachments[0].get("disposition", "attachment"), "attachment") - self.assertNotIn("content_id", attachments[0]) - - # ContentType inferred from filename: - self.assertEqual(attachments[1]["type"], "image/png") - self.assertEqual(attachments[1]["filename"], "test.png") - self.assertEqual(decode_att(attachments[1]["content"]), png_content) - # make sure image not treated as inline: - self.assertEqual(attachments[1].get("disposition", "attachment"), "attachment") - self.assertNotIn("content_id", attachments[1]) - - self.assertEqual(attachments[2]["type"], "application/pdf") - self.assertEqual(attachments[2]["filename"], "attachment") # default - self.assertEqual(decode_att(attachments[2]["content"]), pdf_content) - self.assertEqual(attachments[2].get("disposition", "attachment"), "attachment") - self.assertNotIn("content_id", attachments[2]) - - def test_unicode_attachment_correctly_decoded(self): - self.message.attach( - "Une pièce jointe.html", "

\u2019

", mimetype="text/html" + data["from"], + { + "name": "Odesílatel, z adresy", + "email": "відправник@xn--pklad-zsa96e.example.cz", + }, ) - self.message.send() - data = self.get_api_call_json() self.assertEqual( - data["attachments"], + data["to"], [ { - "filename": "Une pièce jointe.html", - "type": "text/html", - "content": b64encode("

\u2019

".encode("utf-8")).decode( - "ascii" - ), + "name": "Příjemce, na adresu", + "email": "одержувач@xn--pklad-zsa96e.example.cz", } ], ) + self.assertEqual(data["subject"], "Předmět e-mailu") + self.assertEqual( + data["reply_to"], + { + "name": "Odpověď, adresa", + "email": "відповідь@xn--pklad-zsa96e.example.cz", + }, + ) + self.assertEqual( + data["headers"], + {"X-Extra": "Další"}, + ) - def test_embedded_images(self): - image_filename = SAMPLE_IMAGE_FILENAME - image_path = sample_image_path(image_filename) - image_data = sample_image_content(image_filename) - - cid = attach_inline_image_file(self.message, image_path) # Read from a png file - html_content = ( - '

This has an inline image.

' % cid + def test_attachments(self): + # Mailtrap supports non-utf-8 content with charset in the `type` field. + # Mailtrap accepts non-ASCII filenames but incorrectly sends them + # as 8-bit utf-8 (without using RFC 2231 encoding). + # The filename param is required. + text_content = "pièce jointe\n" + self.message.attach( + create_text_attachment("pièce jointe\n", charset="iso-8859-1") ) - self.message.attach_alternative(html_content, "text/html") + self.message.attach("émoticône.img", b";-)", "image/x-emoticon") + image_data = sample_image_content() + cid = attach_inline_image(self.message, image_data, "test.png") self.message.send() data = self.get_api_call_json() - self.assertEqual(data["html"], html_content) - attachments = data["attachments"] - self.assertEqual(len(attachments), 1) - self.assertEqual(attachments[0]["filename"], image_filename) - self.assertEqual(attachments[0]["type"], "image/png") - self.assertEqual(decode_att(attachments[0]["content"]), image_data) - self.assertEqual(attachments[0]["disposition"], "inline") - self.assertEqual(attachments[0]["content_id"], cid) - - def test_attached_images(self): - image_filename = SAMPLE_IMAGE_FILENAME - image_path = sample_image_path(image_filename) - image_data = sample_image_content(image_filename) - - # option 1: attach as a file - self.message.attach_file(image_path) - - # option 2: construct the MIMEImage and attach it directly - image = MIMEImage(image_data) - self.message.attach(image) - - image_data_b64 = b64encode(image_data).decode("ascii") + self.assertEqual(len(attachments), 3) - self.message.send() - data = self.get_api_call_json() + self.assertEqual(attachments[0]["type"], 'text/plain; charset="iso-8859-1"') + self.assertEqual(attachments[0]["filename"], "attachment") self.assertEqual( - data["attachments"], - [ - { - "filename": image_filename, # the named one - "type": "image/png", - "content": image_data_b64, - }, - { - "filename": "attachment", # the unnamed one - "type": "image/png", - "content": image_data_b64, - }, - ], + decode_att(attachments[0]["content"]).decode("iso-8859-1"), text_content ) + self.assertNotIn("disposition", attachments[0]) + self.assertNotIn("content_id", attachments[0]) + + self.assertEqual(attachments[1]["type"], "image/x-emoticon") + self.assertEqual(attachments[1]["filename"], "émoticône.img") + self.assertEqual(decode_att(attachments[1]["content"]), b";-)") + self.assertNotIn("disposition", attachments[1]) + self.assertNotIn("content_id", attachments[1]) + + self.assertEqual(attachments[2]["type"], "image/png") # from filename + self.assertEqual(attachments[2]["filename"], "test.png") + self.assertEqual(attachments[2]["disposition"], "inline") + self.assertEqual(attachments[2]["content_id"], cid) + self.assertEqual(decode_att(attachments[2]["content"]), image_data) def test_multiple_html_alternatives(self): # Multiple alternatives not allowed From aa4880f1bbb1113feaad67f336e53371489e0fcf Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Wed, 17 Dec 2025 15:17:20 -0800 Subject: [PATCH 14/14] (Workflow typo) --- .github/workflows/integration-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 58c5a936..835b2f06 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -44,7 +44,7 @@ jobs: - { tox: django60-py314-mailersend, python: "3.14" } - { tox: django60-py314-mailgun, python: "3.14" } - { tox: django60-py314-mailjet, python: "3.14" } - - { tox: django60-py314-mailtrap, python: "3.13" } + - { tox: django60-py314-mailtrap, python: "3.14" } - { tox: django60-py314-mandrill, python: "3.14" } - { tox: django60-py314-postal, python: "3.14" } - { tox: django60-py314-postmark, python: "3.14" }