From ee9de922e89625ca02b0910663fb8d1008e65cdc Mon Sep 17 00:00:00 2001 From: Conor Heine Date: Sun, 10 Nov 2024 14:08:56 -0800 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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")