diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 674f79b7..812b4358 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: django52-py313-mailtrap, python: "3.13" } - { tox: django52-py313-mandrill, python: "3.13" } - { tox: django52-py313-postal, python: "3.13" } - { tox: django52-py313-postmark, python: "3.13" } @@ -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_DOMAIN: ${{ vars.ANYMAIL_TEST_MAILTRAP_DOMAIN }} + ANYMAIL_TEST_MAILTRAP_SANDBOX_ID: ${{ vars.ANYMAIL_TEST_MAILTRAP_SANDBOX_ID }} + ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID: ${{ vars.ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID }} ANYMAIL_TEST_MANDRILL_API_KEY: ${{ secrets.ANYMAIL_TEST_MANDRILL_API_KEY }} ANYMAIL_TEST_MANDRILL_DOMAIN: ${{ secrets.ANYMAIL_TEST_MANDRILL_DOMAIN }} ANYMAIL_TEST_POSTMARK_DOMAIN: ${{ secrets.ANYMAIL_TEST_POSTMARK_DOMAIN }} 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/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..7de48042 --- /dev/null +++ b/anymail/backends/mailtrap.py @@ -0,0 +1,389 @@ +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", + { + # 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": NotRequired[str], + "text": NotRequired[str], + "html": NotRequired[str], + "category": NotRequired[str], + "template_uuid": NotRequired[str], + "template_variables": NotRequired[Dict[str, Any]], + }, +) + +MailtrapBatchData = TypedDict( + "MailtrapBatchData", + { + "base": MailtrapData, + "requests": List[MailtrapData], + }, +) + + +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 + + # Late bound batch send data + self.merge_data: Dict[str, Any] = {} + self.merge_metadata: Dict[str, Dict[str, str]] = {} + self.merge_headers: Dict[str, Dict[str, str]] = {} + + # needed for backend.parse_recipient_status + self.recipients_to: List[str] = [] + 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): + endpoint = "batch" if self.is_batch() else "send" + if self.backend.use_sandbox: + sandbox_id = quote(str(self.backend.sandbox_id), safe="") + return f"{endpoint}/{sandbox_id}" + else: + return endpoint + + def serialize_data(self): + data = self.burst_for_batch() if self.is_batch() else self.data + return self.serialize_json(data) + + def burst_for_batch(self) -> MailtrapBatchData: + """Transform self.data into the payload for a batch send.""" + # One batch send request for each 'to' address. + # Any cc and bcc recipients are duplicated to every request. + to = self.data.pop("to", []) + cc = self.data.pop("cc", None) + bcc = self.data.pop("bcc", None) + base_template_variables = self.data.get("template_variables", {}) + base_custom_variables = self.data.get("custom_variables", {}) + base_headers = self.data.get("headers", {}) + requests = [] + for recipient in to: + email = recipient["email"] + request: MailtrapData = {"to": [recipient]} + if cc: + request["cc"] = cc + if bcc: + request["bcc"] = bcc + # Any request props completely override base props, so must merge base. + if email in self.merge_data: + request["template_variables"] = base_template_variables.copy() + request["template_variables"].update(self.merge_data[email]) + if email in self.merge_metadata: + request["custom_variables"] = base_custom_variables.copy() + request["custom_variables"].update(self.merge_metadata[email]) + if email in self.merge_headers: + request["headers"] = base_headers.copy() + request["headers"].update(self.merge_headers[email]) + requests.append(request) + return {"base": self.data, "requests": requests} + + # + # Payload construction + # + + def init_payload(self): + self.data: MailtrapData = {} + + @staticmethod + def _mailtrap_email(email: EmailAddress) -> MailtrapAddress: + """Expand an Anymail EmailAddress into Mailtrap's {"email", "name"} dict""" + result: MailtrapAddress = {"email": email.addr_spec} + if email.display_name: + result["name"] = email.display_name + return result + + def set_from_email(self, email: EmailAddress): + self.data["from"] = self._mailtrap_email(email) + + 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): + if subject: + # (must ignore default empty subject for use with template_uuid) + self.data["subject"] = subject + + def set_reply_to(self, emails: List[EmailAddress]): + if emails: + # Use header rather than "reply_to" param + # to allow multiple reply-to addresses + self.data.setdefault("headers", {})["Reply-To"] = ", ".join( + email.address for email in emails + ) + + def set_extra_headers(self, headers): + # Note: Mailtrap appears to correctly RFC 2047 encode non-ASCII header + # values for us, even though its docs say that we "must ensure these + # are properly encoded if they contain unicode characters." + self.data.setdefault("headers", {}).update(headers) + + def set_text_body(self, body): + if 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 = { + # 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: + att["disposition"] = "inline" + att["content_id"] = attachment.cid + self.data.setdefault("attachments", []).append(att) + + def set_tags(self, tags: List[str]): + if len(tags) > 1: + self.unsupported_feature("multiple tags") + if len(tags) > 0: + self.data["category"] = tags[0] + + def set_metadata(self, metadata): + self.data.setdefault("custom_variables", {}).update( + {str(k): str(v) for k, v in metadata.items()} + ) + + def set_template_id(self, template_id): + self.data["template_uuid"] = template_id + + def set_merge_data(self, merge_data): + # Late-bound in burst_for_batch + self.merge_data = merge_data + + def set_merge_headers(self, merge_headers): + # Late-bound in burst_for_batch + self.merge_headers = merge_headers + + def set_merge_global_data(self, merge_global_data: Dict[str, Any]): + self.data.setdefault("template_variables", {}).update(merge_global_data) + + def set_merge_metadata(self, merge_metadata): + # Late-bound in burst_for_batch + self.merge_metadata = merge_metadata + + def set_esp_extra(self, extra): + update_deep(self.data, extra) + + +class EmailBackend(AnymailRequestsBackend): + """ + Mailtrap API Email Backend + """ + + 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.sandbox_id = get_anymail_setting( + "sandbox_id", esp_name=self.esp_name, kwargs=kwargs, default=None + ) + self.use_sandbox = bool(self.sandbox_id) + + api_url = get_anymail_setting( + "api_url", + esp_name=self.esp_name, + kwargs=kwargs, + default=( + self.DEFAULT_SANDBOX_API_URL + if self.use_sandbox + else self.DEFAULT_API_URL + ), + ) + if not api_url.endswith("/"): + api_url += "/" + + 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) + + if parsed_response.get("errors") or not parsed_response.get("success"): + # Superclass has already filtered http error status responses, + # so errors here (or general batch send error) shouldn't be possible. + status = response.status_code + raise AnymailRequestsAPIError( + f"Unexpected API failure fields with response status {status}", + email_message=message, + payload=payload, + response=response, + backend=self, + ) + + if payload.is_batch(): + try: + responses = parsed_response["responses"] + except KeyError: + raise AnymailRequestsAPIError("") + if len(payload.recipients_to) != len(responses): + raise AnymailRequestsAPIError( + f"Expected {len(payload.recipients_to)} batch send responses" + f" but got {len(responses)}", + email_message=message, + payload=payload, + response=response, + backend=self, + ) + + # Merge recipient statuses for each item in the batch. + # Each API response includes message_ids in the order 'to', 'cc, 'bcc'. + recipient_status: Dict[str, AnymailRecipientStatus] = {} + for to, one_response in zip(payload.recipients_to, responses): + recipients = [to, *payload.recipients_cc, *payload.recipients_bcc] + one_status = self.parse_one_response( + one_response, recipients, response, payload, message + ) + recipient_status.update(one_status) + else: + # Non-batch send. + # API response includes message_ids in the order 'to', 'cc, 'bcc'. + recipients = [ + *payload.recipients_to, + *payload.recipients_cc, + *payload.recipients_bcc, + ] + recipient_status = self.parse_one_response( + parsed_response, recipients, response, payload, message + ) + + return recipient_status + + def parse_one_response( + self, + one_response, + recipients: List[str], + raw_response, + payload: MailtrapPayload, + message: AnymailMessage, + ) -> Dict[str, AnymailRecipientStatus]: + """ + Return parsed status for recipients in one_response, which is either + a top-level send response or an individual 'responses' item for batch send. + """ + if not one_response["success"]: + # (Could try to parse status out of one_response["errors"].) + return { + email: AnymailRecipientStatus(message_id=None, status="failed") + for email in recipients + } + + try: + message_ids = one_response["message_ids"] + except KeyError: + raise AnymailRequestsAPIError( + "Unexpected API response format", + email_message=message, + payload=payload, + response=raw_response, + backend=self, + ) + + # The sandbox API always returns a single message id for all recipients; + # the production API returns one message id per recipient. + 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=raw_response, + backend=self, + ) + if self.use_sandbox: + message_ids = [message_ids[0]] * len(recipients) + + recipient_status = { + email: AnymailRecipientStatus( + message_id=message_id, + status="queued", + ) + for email, message_id in zip(recipients, message_ids) + } + return recipient_status diff --git a/anymail/urls.py b/anymail/urls.py index 050d9b76..09e65aed 100644 --- a/anymail/urls.py +++ b/anymail/urls.py @@ -11,6 +11,7 @@ ) from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView +from .webhooks.mailtrap import MailtrapTrackingWebhookView from .webhooks.mandrill import MandrillCombinedWebhookView from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView @@ -108,6 +109,11 @@ MailjetTrackingWebhookView.as_view(), name="mailjet_tracking_webhook", ), + path( + "mailtrap/tracking/", + MailtrapTrackingWebhookView.as_view(), + name="mailtrap_tracking_webhook", + ), path( "postal/tracking/", PostalTrackingWebhookView.as_view(), diff --git a/anymail/webhooks/mailtrap.py b/anymail/webhooks/mailtrap.py new file mode 100644 index 00000000..ddd11b1a --- /dev/null +++ b/anymail/webhooks/mailtrap.py @@ -0,0 +1,101 @@ +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): + # https://api-docs.mailtrap.io/docs/mailtrap-api-docs/016fe2a1efd5a-receive-events-json-format + 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..c3681264 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,Full,Limited,Limited,Full,Full,Full,**Unsupported**,Full,Full +.. rubric:: :ref:`Anymail send options `,,,,,,,,,,,,,, +:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,No,Domain only,Yes,No,No,No,No,Yes,No +:attr:`~AnymailMessage.merge_headers`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes,No,No,Yes,Yes,No,Yes,Yes [#caveats]_,Yes [#caveats]_ +:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_metadata`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes,Yes,No,Yes,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,No,Yes,No,No,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Yes,Max 1 tag,Yes +:attr:`~AnymailMessage.track_clicks`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,No [#nocontrol]_,Yes,No,Yes,No,No,Yes,Yes,Yes +:attr:`~AnymailMessage.track_opens`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,No [#nocontrol]_,Yes,No,Yes,No,No,Yes,Yes,Yes +:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,No,No,Yes,Yes,Yes +.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,,,, +:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_global_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes +.. rubric:: :ref:`Status ` and :ref:`event tracking `,,,,,,,,,,,,,, +:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes +: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..c80cd790 --- /dev/null +++ b/docs/esps/mailtrap.rst @@ -0,0 +1,287 @@ +.. _mailtrap-backend: + +Mailtrap +======== + +.. versionadded:: vNext + +Anymail integrates with `Mailtrap`_'s Email API/SMTP (transactional) and +Email Sandbox (test) email services, using the `Mailtrap API v2`_. +(Anymail uses Mailtrap's REST-oriented HTTP API, not the SMTP protocol.) + +Anymail should also work correctly with Mailtrap's Bulk Sending service +(which uses an identical API), but this scenario is not tested separately. + +.. note:: + + **Troubleshooting:** + If your Mailtrap transactional or bulk messages aren't being delivered + as expected, check the `Email Logs`_ in Mailtrap's dashboard. + The "Event History" tab for an individual message is often helpful. + +.. _Mailtrap: https://mailtrap.io +.. _Mailtrap API v2: https://api-docs.mailtrap.io/docs/mailtrap-api-docs/ +.. _Email Logs: https://mailtrap.io/sending/email_logs + + +Settings +-------- + +To use Anymail's Mailtrap backend, set: + + .. code-block:: python + + EMAIL_BACKEND = "anymail.backends.mailtrap.EmailBackend" + ANYMAIL = { + "MAILTRAP_API_TOKEN": "", + # Only to use the Email Sandbox service: + "MAILTRAP_SANDBOX_ID": , + } + +in your settings.py. + +When :setting:`MAILTRAP_SANDBOX_ID ` is set, +Anymail uses Mailtrap's Email Sandbox service. If it is not set, Anymail +uses Mailtrap's transactional Email API/SMTP service. + + +.. setting:: ANYMAIL_MAILTRAP_API_TOKEN + +.. 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_SANDBOX_ID + +.. rubric:: MAILTRAP_SANDBOX_ID + +Required to use Mailtrap's Email Sandbox test inbox. (And must *not* be set +to use Mailtrap's Email API/SMTP transactional service.) + + .. code-block:: python + + ANYMAIL = { + ... + "MAILTRAP_SANDBOX_ID": 12345, + } + +The sandbox id can be found in Mailtrap's dashboard: click into the desired +sandbox and look for the number in the dashboard url. For example, +``https://mailtrap.io/inboxes/12345/messages`` would be sandbox id 12345. + +The value can be a string or number. For convenience when using env files, +Anymail treats an empty string or ``None`` (or any falsy value) as "not set." + +.. setting:: ANYMAIL_MAILTRAP_API_URL + +.. rubric:: MAILTRAP_API_URL + +The base url for calling the Mailtrap API. + +The default is ``MAILTRAP_API_URL = "https://send.api.mailtrap.io/api/"`` +(Mailtrap's Email API/SMTP transactional service) +if :setting:`MAILTRAP_SANDBOX_ID ` is not set, +or ``"https://sandbox.api.mailtrap.io/api/"`` (Mailbox's Email Sandbox testing +service) when a sandbox id is provided. + +Most users should not need to change this setting. However, you could set it +to use Mailtrap's bulk send service: + + .. code-block:: python + + ANYMAIL = { + ... + "MAILTRAP_API_URL": "https://bulk.api.mailtrap.io/api/", + } + +(Note that Anymail is not specifically tested with Mailtrap's bulk API.) + +The value must be only the API base URL: do not include the ``"/send"`` endpoint. +(When provided, this is used as the base URL *always*. If you are also setting +a sandbox id, the base URL must be compatible with Mailtrap's sandbox API.) + + +.. _mailtrap-esp-extra: + +esp_extra support +----------------- + +To use Mailtrap features not directly supported by Anymail, you can +set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to +a `dict` of Mailtraps's `Send email API body parameters`_. +Your :attr:`esp_extra` dict will be deeply merged into the Mailtrap +API payload, with `esp_extra` having precedence in conflicts. +(For batch sends, the `esp_extra` values are merged into the ``"base"`` +payload shared by all recipients.) + +Example: + + .. code-block:: python + + message.esp_extra = { + "future_mailtrap_feature": "value" + } + + +(You can also set `"esp_extra"` in Anymail's :ref:`global send defaults ` +to apply it to all messages.) + +.. _Send email API body parameters: + https://api-docs.mailtrap.io/docs/mailtrap-api-docs/67f1d70aeb62c-send-email-including-templates#request-body + + +.. _mailtrap-quirks: + +Limitations and quirks +---------------------- + +**Single tag** + Anymail uses Mailtrap's ``"category"`` option for tags, and Mailtrap allows + only a single category per message. If your message has two or more + :attr:`~anymail.message.AnymailMessage.tags`, you'll get an + :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error---or + if you've enabled :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`, + Anymail will use only the first tag. + +**Tag not compatible with template** + Trying to send with both :attr:`~anymail.message.AnymailMessage.tags` and a + :attr:`~anymail.message.AnymailMessage.template_id` will result in a Mailtrap + API error that "'category' is not allowed with 'template_uuid'." + +**Error when non-ASCII From name includes comma** + Trying to send a message with a ``from_email`` display name containing both + a non-ASCII Unicode character *and* a comma (e.g., ``'"Ng, Göta" '``) + will result in a Mailtrap API error that the "'From:' header does not match + the sender's domain." This does not affect other address fields (like ``to`` + or ``reply_to``). It appears to be a limitation of Mailtrap's API, and there + is no general workaround Anymail could apply. To avoid the problem, you must + rework your *From* address to either remove either the comma or be ASCII-only. + +**No delayed sending** + Mailtrap does not support :attr:`~anymail.message.AnymailMessage.send_at`. + +**Attachments require filenames** + Mailtrap requires that all attachments and inline images have filenames. If you + don't supply a filename, Anymail will use ``"attachment"`` as the filename. + +**No click-tracking or open-tracking options** + Mailtrap does not provide a way to control open or click tracking for individual + messages. Anymail's :attr:`~anymail.message.AnymailMessage.track_clicks` and + :attr:`~anymail.message.AnymailMessage.track_opens` settings are unsupported. + (You *can* `exclude specific links from tracking`_ using Mailtrap-proprietary + attributes in your HTML.) + +**No envelope sender overrides** + Mailtrap does not support overriding :attr:`~anymail.message.AnymailMessage.envelope_sender`. + + +.. _exclude specific links from tracking: + https://help.mailtrap.io/article/184-excluding-specific-links-from-tracking + +.. _mailtrap-templates: + +Batch sending/merge and ESP templates +------------------------------------- + +Mailtrap offers both :ref:`ESP stored templates ` +and :ref:`batch sending ` with per-recipient merge data. + +When you send a message with multiple ``to`` addresses, the +:attr:`~anymail.message.AnymailMessage.merge_data`, +:attr:`~anymail.message.AnymailMessage.merge_metadata` +and :attr:`~anymail.message.AnymailMessage.merge_headers` properties +determine how many distinct messages are sent: + +* If the ``merge_...`` properties are *not* set (the default), Anymail + will tell Mailtrap to send a single message, and all recipients will see + the complete list of To addresses. +* If *any* of the ``merge_...`` properties are set---even to an empty `{}` dict, + Anymail will tell Mailtrap to send a separate message for each ``to`` + address, and the recipients won't see the other To addresses. + +You can use a Mailtrap stored template by setting a message's +:attr:`~anymail.message.AnymailMessage.template_id` to the template's +"Template UUID." Find the template UUID in the Templates section of Mailtrap's +dashboard, under the template's details. When a Mailtrap template is used, +your Django code must not provide a message subject or text or html body. + +Supply the template merge data values with Anymail's +normalized :attr:`~anymail.message.AnymailMessage.merge_data` +and :attr:`~anymail.message.AnymailMessage.merge_global_data` +message attributes. + + .. code-block:: python + + message = EmailMessage( + from_email="from@example.com", + to=["alice@example.com", "Bob "], + # omit subject and body (or set to None) to use template content + ... + ) + message.template_id = "11111111-abcd-1234-0000-0123456789ab" # Template UUID + message.merge_data = { + 'alice@example.com': {'name': "Alice", 'order_no': "12345"}, + 'bob@example.com': {'name': "Bob", 'order_no': "54321"}, + } + message.merge_global_data = { + 'ship_date': "May 15", + } + message.send() + + +.. _mailtrap-webhooks: + +Status tracking webhooks +------------------------ + +If you are using Anymail's normalized :ref:`status tracking `, +create a webhook in the Settings section of the Mailtrap dashboard under Webhooks. +See their `Webhooks help`_ article for more information. + +(Note that Mailtrap's Email Sandbox service does not trigger webhook events.) + +In Mailtrap's "Add new webhook" screen, enter: + +* Webhook URL: + + :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mailtrap/tracking/` + + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret + * *yoursite.example.com* is your Django site + +* Payload format: JSON (*not* JSON Lines) + +* Select area: Email Sending + + * Select stream: Transactional (unless you have overridden Anymail's + :setting:`MAILTRAP_API_URL ` to use Mailtrap's + bulk sending API). + * Select domain: the desired sending domain(s) + * Select events to listen to: check all you want to receive + +Mailtrap will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s: +rejected, bounced, deferred, delivered, opened, clicked, complained, unsubscribed. + + +.. _Webhooks help: https://help.mailtrap.io/article/102-webhooks + + +.. _mailtrap-inbound: + +Inbound webhook +--------------- + +Mailtrap's inbound service is currently under development, and APIs are not +yet publicly available. 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..be9d3906 --- /dev/null +++ b/tests/test_mailtrap_backend.py @@ -0,0 +1,999 @@ +from __future__ import annotations + +from base64 import b64encode +from datetime import datetime +from decimal import Decimal +from email.mime.base import MIMEBase +from email.mime.image import MIMEImage + +from django.core import mail +from django.core.exceptions import ImproperlyConfigured +from django.test import SimpleTestCase, override_settings, tag +from django.utils.timezone import timezone + +from anymail.exceptions import ( + AnymailAPIError, + AnymailSerializationError, + AnymailUnsupportedFeature, +) +from anymail.message import AnymailMessage, attach_inline_image_file + +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + decode_att, + sample_image_content, + sample_image_path, +) + + +@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 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( + json_data={ + "success": True, + "message_ids": message_ids, + }, + ) + + +@tag("mailtrap") +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_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, with comma" ', + "Інше ", + ] + self.message.extra_headers = {"X-Other": "Keep"} + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["headers"], + { + # Reply-To must be properly formatted as an address header: + "Reply-To": "reply@example.com," + ' "Other, with comma" ,' + " =?utf-8?b?0IbQvdGI0LU=?= ", + "X-Other": "Keep", + }, + ) + + 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) + + 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_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") + + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["html"], html_content) + + attachments = data["attachments"] + self.assertEqual(len(attachments), 1) + self.assertEqual(attachments[0]["filename"], image_filename) + self.assertEqual(attachments[0]["type"], "image/png") + self.assertEqual(decode_att(attachments[0]["content"]), image_data) + self.assertEqual(attachments[0]["disposition"], "inline") + self.assertEqual(attachments[0]["content_id"], cid) + + def test_attached_images(self): + image_filename = SAMPLE_IMAGE_FILENAME + image_path = sample_image_path(image_filename) + image_data = sample_image_content(image_filename) + + # option 1: attach as a file + self.message.attach_file(image_path) + + # option 2: construct the MIMEImage and attach it directly + image = MIMEImage(image_data) + self.message.attach(image) + + image_data_b64 = b64encode(image_data).decode("ascii") + + self.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, + }, + ], + ) + + 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() + + 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_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( + AnymailUnsupportedFeature, "multiple from emails" + ): + self.message.send() + + def test_api_failure(self): + self.set_mock_response( + status_code=400, + json_data={"success": False, "errors": ["helpful error message"]}, + ) + with self.assertRaisesMessage( + 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_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") +class MailtrapBackendAnymailFeatureTests(MailtrapBackendMockAPITestCase): + """Test backend support for Anymail added features""" + + def test_envelope_sender(self): + 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", "items": 6} + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["custom_variables"], {"user_id": "12345", "items": "6"}) + + def test_send_at(self): + 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 = ["receipt"] + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["category"], "receipt") + + def test_multiple_tags(self): + self.message.tags = ["receipt", "repeat-user"] + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple tags"): + self.message.send() + + @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["category"], "receipt") + + def test_track_opens(self): + self.message.track_opens = True + with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"): + self.message.send() + + 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("/api/send") + data = self.get_api_call_json() + self.assertEqual(data["template_uuid"], "template-uuid") + self.assertEqual( + 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) + + _mock_batch_response = { + "success": True, + "responses": [ + {"success": True, "message_ids": ["message-id-alice-to"]}, + {"success": True, "message_ids": ["message-id-bob-to"]}, + {"success": True, "message_ids": ["message-id-cam-to"]}, + ], + } + + def test_merge_data(self): + self.set_mock_response(json_data=self._mock_batch_response) + message = AnymailMessage( + from_email="from@example.com", + to=["alice@example.com", "Bob ", "cam@example.com"], + template_id="template-uuid", + merge_data={ + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined + "nobody@example.com": {"name": "Not a recipient for this message"}, + }, + merge_global_data={"group": "Users", "site": "ExampleCo"}, + ) + message.send() + + # Use batch send endpoint + self.assert_esp_called("/api/batch") + data = self.get_api_call_json() + + # Common parameters in "base": + self.assertEqual(data["base"]["from"], {"email": "from@example.com"}) + self.assertEqual(data["base"]["template_uuid"], "template-uuid") + self.assertEqual( + data["base"]["template_variables"], {"group": "Users", "site": "ExampleCo"} + ) + self.assertNotIn("subject", data["base"]) # invalid with template_uuid + self.assertNotIn("text", data["base"]) + self.assertNotIn("html", data["base"]) + + # Per-recipient parameters in "requests" array: + self.assertEqual(len(data["requests"]), 3) + self.assertEqual( + data["requests"][0], + { + "to": [{"email": "alice@example.com"}], + # Completely overrides base template_variables + "template_variables": { + "name": "Alice", + "group": "Developers", + "site": "ExampleCo", + }, + }, + ) + self.assertEqual( + data["requests"][1], + { + "to": [{"email": "bob@example.com", "name": "Bob"}], + "template_variables": { + "name": "Bob", + "group": "Users", + "site": "ExampleCo", + }, + }, + ) + self.assertEqual( + data["requests"][2], + { + "to": [{"email": "cam@example.com"}], + # No template_variables (no merge_data for cam, so global base applies) + }, + ) + + recipients = message.anymail_status.recipients + self.assertEqual(recipients["alice@example.com"].status, "queued") + self.assertEqual( + recipients["alice@example.com"].message_id, + "message-id-alice-to", + ) + self.assertEqual(recipients["bob@example.com"].status, "queued") + self.assertEqual( + recipients["bob@example.com"].message_id, + "message-id-bob-to", + ) + self.assertEqual(recipients["cam@example.com"].status, "queued") + self.assertEqual( + recipients["cam@example.com"].message_id, + "message-id-cam-to", + ) + + def test_merge_metadata(self): + self.set_mock_response(json_data=self._mock_batch_response) + self.message.to = [ + "alice@example.com", + "Bob ", + "cam@example.com", + ] + self.message.merge_metadata = { + "alice@example.com": {"order_id": 123, "tier": "premium"}, + "bob@example.com": {"order_id": 678}, + } + self.message.metadata = {"notification_batch": "zx912", "tier": "basic"} + self.message.send() + + self.assert_esp_called("/api/batch") + data = self.get_api_call_json() + self.assertEqual(data["base"]["from"], {"email": "from@example.com"}) + self.assertEqual(data["base"]["subject"], "Subject") + self.assertEqual(data["base"]["text"], "Body") + self.assertEqual( + data["base"]["custom_variables"], + {"notification_batch": "zx912", "tier": "basic"}, + ) + + self.assertEqual(len(data["requests"]), 3) + self.assertEqual( + data["requests"][0], + { + "to": [{"email": "alice@example.com"}], + "custom_variables": { + "order_id": 123, + "tier": "premium", + "notification_batch": "zx912", + }, + }, + ) + self.assertEqual( + data["requests"][1], + { + "to": [{"email": "bob@example.com", "name": "Bob"}], + "custom_variables": { + "order_id": 678, + "notification_batch": "zx912", + "tier": "basic", + }, + }, + ) + self.assertEqual( + data["requests"][2], + { + "to": [{"email": "cam@example.com"}], + # No custom_variables (no merge_data for cam, so global base applies) + }, + ) + + def test_merge_headers(self): + self.set_mock_response(json_data=self._mock_batch_response) + self.message.to = [ + "alice@example.com", + "Bob ", + "cam@example.com", + ] + self.message.extra_headers = { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + } + self.message.merge_headers = { + "alice@example.com": { + "List-Unsubscribe": "", + }, + "bob@example.com": { + "List-Unsubscribe": "", + }, + } + self.message.send() + + self.assert_esp_called("/api/batch") + data = self.get_api_call_json() + self.assertEqual( + data["base"]["headers"], + { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + ) + + self.assertEqual(len(data["requests"]), 3) + self.assertEqual( + data["requests"][0], + { + "to": [{"email": "alice@example.com"}], + "headers": { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + }, + ) + self.assertEqual( + data["requests"][1], + { + "to": [{"email": "bob@example.com", "name": "Bob"}], + "headers": { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + }, + ) + self.assertEqual( + data["requests"][2], + { + "to": [{"email": "cam@example.com"}], + # No headers (no merge_data for cam, so global base applies) + }, + ) + + def test_batch_send_with_cc_and_bcc(self): + self.set_mock_response( + json_data={ + "success": True, + "responses": [ + { + "success": True, + "message_ids": [ + "message-id-alice-to", + "message-id-alice-cc0", + "message-id-alice-cc1", + "message-id-alice-bcc0", + ], + }, + { + "success": True, + "message_ids": [ + "message-id-bob-to", + "message-id-bob-cc0", + "message-id-bob-cc1", + "message-id-bob-bcc0", + ], + }, + ], + } + ) + message = AnymailMessage( + to=["alice@example.com", "Bob "], + cc=["cc0@example.com", "Also CC "], + bcc=["bcc0@example.com"], + merge_metadata={}, # force batch send + ) + message.send() + + self.assert_esp_called("/api/batch") + + # cc and bcc must be copied to each subrequest (cannot be in base) + data = self.get_api_call_json() + self.assertEqual(len(data["requests"]), 2) + self.assertEqual( + data["requests"][0], + { + "to": [{"email": "alice@example.com"}], + "cc": [ + {"email": "cc0@example.com"}, + {"email": "cc1@example.com", "name": "Also CC"}, + ], + "bcc": [{"email": "bcc0@example.com"}], + }, + ) + self.assertEqual( + data["requests"][1], + { + "to": [{"email": "bob@example.com", "name": "Bob"}], + "cc": [ + {"email": "cc0@example.com"}, + {"email": "cc1@example.com", "name": "Also CC"}, + ], + "bcc": [{"email": "bcc0@example.com"}], + }, + ) + + recipients = message.anymail_status.recipients + self.assertEqual(recipients["alice@example.com"].status, "queued") + self.assertEqual( + recipients["alice@example.com"].message_id, + "message-id-alice-to", + ) + self.assertEqual(recipients["bob@example.com"].status, "queued") + self.assertEqual( + recipients["bob@example.com"].message_id, + "message-id-bob-to", + ) + # anymail_status.recipients can't represent separate statuses for batch + # cc and bcc recipients. For Mailtrap, the status will reflect the cc/bcc + # for the last 'to' recipient: + self.assertEqual(recipients["cc0@example.com"].status, "queued") + self.assertEqual( + recipients["cc0@example.com"].message_id, + "message-id-bob-cc0", + ) + self.assertEqual(recipients["cc1@example.com"].status, "queued") + self.assertEqual( + recipients["cc1@example.com"].message_id, + "message-id-bob-cc1", + ) + self.assertEqual(recipients["bcc0@example.com"].status, "queued") + self.assertEqual( + recipients["bcc0@example.com"].message_id, + "message-id-bob-bcc0", + ) + + def test_batch_send_with_mixed_responses(self): + self.set_mock_response( + json_data={ + "success": True, + "responses": [ + { + "success": True, + "message_ids": ["message-id-alice-to"], + }, + {"success": False, "errors": ["address is invalid in 'to' 0"]}, + ], + } + ) + message = AnymailMessage( + to=["alice@example.com", "invalid@address"], + merge_metadata={}, # force batch send + ) + message.send() + + recipients = message.anymail_status.recipients + self.assertEqual(recipients["alice@example.com"].status, "queued") + self.assertEqual( + recipients["alice@example.com"].message_id, + "message-id-alice-to", + ) + self.assertEqual(recipients["invalid@address"].status, "failed") + self.assertIsNone(recipients["invalid@address"].message_id) + + def test_default_omits_options(self): + """Make sure by default we don't send any ESP-specific options. + + 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 = { + "future_mailtrap_option": "some-value", + } + self.message.send() + data = self.get_api_call_json() + 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, {"queued"}) + self.assertEqual( + msg.anymail_status.message_id, + {"id-to1", "id-to2", "id-cc1", "id-cc2", "id-bcc1", "id-bcc2"}, + ) + recipients = msg.anymail_status.recipients + self.assertEqual(recipients["to1@example.com"].status, "queued") + self.assertEqual(recipients["to1@example.com"].message_id, "id-to1") + self.assertEqual(recipients["to2@example.com"].status, "queued") + self.assertEqual(recipients["to2@example.com"].message_id, "id-to2") + self.assertEqual(recipients["cc1@example.com"].status, "queued") + self.assertEqual(recipients["cc1@example.com"].message_id, "id-cc1") + self.assertEqual(recipients["cc2@example.com"].status, "queued") + self.assertEqual(recipients["cc2@example.com"].message_id, "id-cc2") + self.assertEqual(recipients["bcc1@example.com"].status, "queued") + self.assertEqual(recipients["bcc1@example.com"].message_id, "id-bcc1") + self.assertEqual(recipients["bcc2@example.com"].status, "queued") + self.assertEqual(recipients["bcc2@example.com"].message_id, "id-bcc2") + self.assertEqual(msg.anymail_status.esp_response.json(), response_content) + + 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_SANDBOX_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() + + self.assert_esp_called("https://sandbox.api.mailtrap.io/api/send/12345") + self.assertEqual(self.message.anymail_status.status, {"queued"}) + 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_SANDBOX_ID": ""} + ) + def test_sandbox_id_empty_string(self): + """Use transactional API when MAILTRAP_SANDBOX_ID is an empty string.""" + self.message.send() + self.assert_esp_called("https://send.api.mailtrap.io/api/send") + + @override_settings( + ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_SANDBOX_ID": 12345} + ) + def test_sandbox_batch_send(self): + self.set_mock_response( + json_data={ + "success": True, + "responses": [ + # Sandbox returns single message_id per request, + # even with multiple recipients via cc/bcc. + {"success": True, "message_ids": ["sandbox-single-id-1"]}, + {"success": True, "message_ids": ["sandbox-single-id-2"]}, + ], + } + ) + message = AnymailMessage( + "Subject", + "Body", + "from@example.com", + ["Recipient #1 ", "to2@example.com"], + cc=["cc@example.com"], + merge_data={}, # force batch send + ) + message.send() + + self.assert_esp_called("https://sandbox.api.mailtrap.io/api/batch/12345") + self.assertEqual( + message.anymail_status.message_id, + {"sandbox-single-id-1", "sandbox-single-id-2"}, + ) + self.assertEqual( + message.anymail_status.recipients["to1@example.com"].message_id, + "sandbox-single-id-1", + ) + self.assertEqual( + message.anymail_status.recipients["to2@example.com"].message_id, + "sandbox-single-id-2", + ) + self.assertEqual( + # For batch cc and bcc, message_id from the last recipient is used + message.anymail_status.recipients["cc@example.com"].message_id, + "sandbox-single-id-2", + ) + + @override_settings( + ANYMAIL={"MAILTRAP_API_TOKEN": "test-token", "MAILTRAP_SANDBOX_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() + + def test_unexpected_success_false(self): + 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): + 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() + + @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") +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..491e9ddf --- /dev/null +++ b/tests/test_mailtrap_integration.py @@ -0,0 +1,290 @@ +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 + +# Environment variables to run these live integration tests... +# API token for both sets of tests: +ANYMAIL_TEST_MAILTRAP_API_TOKEN = os.getenv("ANYMAIL_TEST_MAILTRAP_API_TOKEN") +# Validated sending domain for transactional API tests: +ANYMAIL_TEST_MAILTRAP_DOMAIN = os.getenv("ANYMAIL_TEST_MAILTRAP_DOMAIN") +# Test inbox id for sandbox API tests: +ANYMAIL_TEST_MAILTRAP_SANDBOX_ID = os.getenv("ANYMAIL_TEST_MAILTRAP_SANDBOX_ID") +# Template id for both sets of tests: +ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID = os.getenv("ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID") + + +@tag("mailtrap", "live") +@unittest.skipUnless( + ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_DOMAIN, + "Set ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_DOMAIN" + " environment variables to run Mailtrap transactional integration tests", +) +@override_settings( + ANYMAIL={ + "MAILTRAP_API_TOKEN": ANYMAIL_TEST_MAILTRAP_API_TOKEN, + }, + EMAIL_BACKEND="anymail.backends.mailtrap.EmailBackend", +) +class MailtrapBackendTransactionalIntegrationTests(AnymailTestMixin, SimpleTestCase): + """ + Mailtrap API integration tests using transactional API + + These tests run against the live Mailtrap Transactional API. + They send real email (to /dev/null mailboxes on the anymail.dev domain). + """ + + def setUp(self): + super().setUp() + from_domain = ANYMAIL_TEST_MAILTRAP_DOMAIN + self.from_email = f"from@{from_domain}" + self.message = AnymailMessage( + "Anymail Mailtrap integration test", + "Text content", + self.from_email, + ["test+to1@anymail.dev"], + ) + self.message.attach_alternative("

HTML content

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

HTML: with link" + "and image: " % cid, + "text/html", + ) + + message.send() + self.assertEqual(message.anymail_status.status, {"queued"}) + self.assertEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].status, "queued" + ) + self.assertEqual( + message.anymail_status.recipients["test+to2@anymail.dev"].status, "queued" + ) + # distinct messages should have different message_ids: + self.assertNotEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].message_id, + message.anymail_status.recipients["test+to2@anymail.dev"].message_id, + ) + + def test_invalid_from(self): + self.message.from_email = "webmaster@localhost" # Django's default From + with self.assertRaises(AnymailAPIError) as cm: + self.message.send() + err = cm.exception + self.assertEqual(err.status_code, 401) + self.assertIn("Unauthorized", str(err)) + + @unittest.skipUnless( + ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, + "Set ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID to test Mailtrap stored templates", + ) + def test_template(self): + message = AnymailMessage( + from_email=self.from_email, + to=["test+to1@anymail.dev", "Second Recipient "], + template_id=ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, + merge_data={ + "test+to1@anymail.dev": {"name": "Recipient 1", "order_no": "12345"}, + "test+to2@anymail.dev": {"order_no": "6789"}, + }, + merge_global_data={"name": "Valued Customer"}, + ) + message.send() + self.assertEqual(message.anymail_status.status, {"queued"}) + + @override_settings(ANYMAIL={"MAILTRAP_API_TOKEN": "Hey, that's not an API token!"}) + def test_invalid_api_token(self): + # Invalid API key generates same error as unvalidated from address + with self.assertRaisesMessage(AnymailAPIError, "Unauthorized"): + self.message.send() + + +@tag("mailtrap", "live") +@unittest.skipUnless( + ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_SANDBOX_ID, + "Set ANYMAIL_TEST_MAILTRAP_API_TOKEN and ANYMAIL_TEST_MAILTRAP_SANDBOX_ID" + " environment variables to run Mailtrap sandbox integration tests", +) +@override_settings( + ANYMAIL={ + "MAILTRAP_API_TOKEN": ANYMAIL_TEST_MAILTRAP_API_TOKEN, + "MAILTRAP_SANDBOX_ID": ANYMAIL_TEST_MAILTRAP_SANDBOX_ID, + }, + EMAIL_BACKEND="anymail.backends.mailtrap.EmailBackend", +) +class MailtrapBackendSandboxIntegrationTests(AnymailTestMixin, SimpleTestCase): + """ + Mailtrap API integration tests using sandbox testing inbox + + These tests run against the live Mailtrap Test API ("sandbox"). + Mail is delivered to the test inbox; no email is sent. + """ + + def setUp(self): + super().setUp() + from_domain = ANYMAIL_TEST_MAILTRAP_DOMAIN or "example.com" + self.from_email = f"from@{from_domain}" + self.message = AnymailMessage( + "Anymail Mailtrap integration test", + "Text content", + self.from_email, + ["test+to1@anymail.dev"], + ) + self.message.attach_alternative("

HTML content

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

HTML: with link" + "and image: " % cid, + "text/html", + ) + + message.send() + self.assertEqual(message.anymail_status.status, {"queued"}) + self.assertEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].status, "queued" + ) + self.assertEqual( + message.anymail_status.recipients["test+to2@anymail.dev"].status, "queued" + ) + # distinct messages should have different message_ids: + self.assertNotEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].message_id, + message.anymail_status.recipients["test+to2@anymail.dev"].message_id, + ) + + @unittest.skipUnless( + ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, + "Set ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID to test Mailtrap stored templates", + ) + def test_template(self): + message = AnymailMessage( + from_email=self.from_email, + to=["test+to1@anymail.dev", "Second Recipient "], + template_id=ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID, + merge_data={ + "test+to1@anymail.dev": {"name": "Recipient 1", "order_no": "12345"}, + "test+to2@anymail.dev": {"order_no": "6789"}, + }, + merge_global_data={"name": "Valued Customer"}, + ) + message.send() + self.assertEqual(message.anymail_status.status, {"queued"}) + + @override_settings( + ANYMAIL={ + "MAILTRAP_API_TOKEN": "Hey, that's not an API token!", + "MAILTRAP_SANDBOX_ID": ANYMAIL_TEST_MAILTRAP_SANDBOX_ID, + } + ) + def test_invalid_api_token(self): + with self.assertRaises(AnymailAPIError) as cm: + self.message.send() + err = cm.exception + self.assertIn("Unauthorized", str(err)) + self.assertEqual(err.status_code, 401) 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