From 50096c6c23779936bae308dc087e45827aabc690 Mon Sep 17 00:00:00 2001 From: mailkite-outreach-bot Date: Mon, 3 Aug 2026 18:59:20 +0300 Subject: [PATCH] Add MailKite ESP backend Add a MailKite (mailkite.dev) backend. MailKite is an inbound-first developer email platform: it sends transactional mail via POST /v1/send (a JSON API) and also *receives* email, delivering the parsed body plus an SPF/DKIM/DMARC auth verdict to a signed webhook. The backend implements the send side, modeled on the existing Resend backend (MailKite's send API is JSON/shape-compatible), and supports the Anymail features MailKite's API exposes that Resend's does not: - per-message trackOpens / trackClicks overrides - server-side templates: templateId + templateData (merge_global_data) - batch sending (POST /v1/send/batch) with per-recipient merge_metadata and merge_headers; per-recipient failures surface as "rejected" - metadata and tags via X-Metadata / X-Tags headers - scheduled sending (scheduledAt) Not wired up (documented as limitations on the ESP page): - outbound tracking webhooks (MailKite does not emit them) - inbound signals (MailKite delivers inbound mail directly to the user's own webhook URL, in its own payload format) - inline attachments (MailKite's send API has no Content-ID field) - multiple reply_to (MailKite takes a single replyTo string) Includes: - anymail/backends/mailkite.py - tests/test_mailkite_backend.py (45 mocked tests) - tests/test_mailkite_integration.py (live tests, skipped without keys) - docs/esps/mailkite.rst - README, pyproject, feature matrix, and changelog entries All backend tests pass; pre-commit (black/isort/flake8/doc8/pygrep) clean. --- CHANGELOG.rst | 16 + README.rst | 1 + anymail/backends/mailkite.py | 303 +++++++++++++ docs/esps/esp-feature-matrix.csv | 42 +- docs/esps/index.rst | 1 + docs/esps/mailkite.rst | 215 +++++++++ pyproject.toml | 5 +- tests/test_mailkite_backend.py | 700 +++++++++++++++++++++++++++++ tests/test_mailkite_integration.py | 161 +++++++ 9 files changed, 1421 insertions(+), 23 deletions(-) create mode 100644 anymail/backends/mailkite.py create mode 100644 docs/esps/mailkite.rst create mode 100644 tests/test_mailkite_backend.py create mode 100644 tests/test_mailkite_integration.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index be641fb6..5dc6d85f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,22 @@ Release history ^^^^^^^^^^^^^^^ .. This extra heading level keeps the ToC from becoming unmanageably long +Unreleased +---------- + +New ESPs +~~~~~~~~ + +* **MailKite:** Add support for this ESP. MailKite is an inbound-first developer + email platform -- it sends transactional mail via a single JSON API, and also + *receives* email (parsed body plus an authentication verdict, delivered as a + signed webhook). The backend supports per-message open/click tracking + overrides, server-side templates with merge tags (``template_id`` and + ``merge_global_data``), scheduled sending (``send_at``), and batch sending with + per-recipient ``merge_metadata`` and ``merge_headers``. It does not wire up + outbound tracking webhooks (MailKite does not emit them) or inbound signals + (MailKite delivers inbound mail directly to your own webhook URL). + v15.1 ----- diff --git a/README.rst b/README.rst index b87b38f0..ef175c34 100644 --- a/README.rst +++ b/README.rst @@ -32,6 +32,7 @@ Anymail currently supports these ESPs: * **MailerSend** * **Mailgun** (Sinch transactional email) * **Mailjet** (Sinch transactional email) +* **MailKite** (inbound-first email platform) * **Mailtrap** * **Mandrill** (MailChimp transactional email) * **Postal** (self-hosted ESP) diff --git a/anymail/backends/mailkite.py b/anymail/backends/mailkite.py new file mode 100644 index 00000000..d303eb21 --- /dev/null +++ b/anymail/backends/mailkite.py @@ -0,0 +1,303 @@ +import mimetypes + +from ..exceptions import AnymailRequestsAPIError +from ..message import AnymailRecipientStatus +from ..utils import ( + BASIC_NUMERIC_TYPES, + CaseInsensitiveCasePreservingDict, + get_anymail_setting, +) +from .base_requests import AnymailRequestsBackend, RequestsPayload + + +class EmailBackend(AnymailRequestsBackend): + """ + MailKite (mailkite.dev) API Email Backend + + MailKite is an inbound-first email platform: it sends transactional mail via + ``POST /v1/send`` (a single-recipient or multi-recipient JSON API) and *receives* + mail as a signed webhook. This backend implements the send side. (MailKite's + inbound webhooks deliver received mail to your own endpoint and are not wired + through Anymail's inbound signals -- see the MailKite docs page.) + """ + + esp_name = "MailKite" + + def __init__(self, **kwargs): + """Init options from Django settings""" + esp_name = self.esp_name + self.api_key = get_anymail_setting( + "api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True + ) + api_url = get_anymail_setting( + "api_url", + esp_name=esp_name, + kwargs=kwargs, + default="https://api.mailkite.dev/", + ) + if not api_url.endswith("/"): + api_url += "/" + + super().__init__(api_url, **kwargs) + + def build_message_payload(self, message, defaults): + return MailKitePayload(message, defaults, self) + + def parse_recipient_status(self, response, payload, message): + parsed_response = self.deserialize_json_response(response, payload, message) + + # Batch send: { "results": [ { "to", "id", "status", "error"?, "code"? }, ... ] } + if ( + payload.is_batch() + and isinstance(parsed_response, dict) + and "results" in parsed_response + ): + # Build a map of echoed recipient address -> result, so we can match + # MailKite's per-recipient outcome back to Anymail's addr_spec keys. + results_by_addr = {} + for result in parsed_response["results"]: + addr = str(result.get("to", "")) + # Normalise to the addr_spec (user@domain) for matching. + results_by_addr[addr.lower()] = result + + recipient_status = CaseInsensitiveCasePreservingDict() + for recip in payload.to_recipients: + result = results_by_addr.get(recip.addr_spec.lower()) + if result is None: + # Couldn't match the echoed address; assume accepted. + status = "queued" + elif str(result.get("status")) == "failed": + status = "rejected" + else: # "sent" or "scheduled" -- both mean MailKite accepted it + status = "queued" + recipient_status[recip.addr_spec] = AnymailRecipientStatus( + message_id=result.get("id") if result else None, + status=status, + ) + # cc/bcc recipients aren't returned individually; mark them accepted. + for recip in payload.recipients: + if recip.addr_spec not in recipient_status: + recipient_status[recip.addr_spec] = AnymailRecipientStatus( + message_id=None, status="queued" + ) + return dict(recipient_status) + + # Single send: { "id": "...", "status": "sent" | "scheduled" } + try: + message_id = parsed_response["id"] + except (KeyError, TypeError) as err: + raise AnymailRequestsAPIError( + "Invalid MailKite API response format", + email_message=message, + payload=payload, + response=response, + backend=self, + ) from err + + recipient_status = CaseInsensitiveCasePreservingDict( + { + recip.addr_spec: AnymailRecipientStatus( + message_id=message_id, status="queued" + ) + for recip in payload.recipients + } + ) + return dict(recipient_status) + + +class MailKitePayload(RequestsPayload): + def __init__(self, message, defaults, backend, *args, **kwargs): + self.recipients = [] # for parse_recipient_status + self.to_recipients = [] # for parse_recipient_status + self.metadata = {} + self.merge_metadata = {} + self.merge_headers = {} + headers = kwargs.pop("headers", {}) + headers["Authorization"] = "Bearer %s" % backend.api_key + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/json" + super().__init__(message, defaults, backend, headers=headers, *args, **kwargs) + + def get_api_endpoint(self): + if self.is_batch(): + return "v1/send/batch" + return "v1/send" + + def serialize_data(self): + if not self.is_batch(): + return self.serialize_json(self.data) + + # Batch send: MailKite's batch API takes a shared base message plus a + # `recipients[]` array of { to, headers?, templateData? } per-recipient + # overrides. Build that from the flat payload Anymail assembled. + to_emails = self.data.pop("to", []) + shared = dict(self.data) + # MailKite's batch endpoint has no cc/bcc/replyTo fields -- each + # recipient gets their own message addressed only to them. + shared.pop("cc", None) + shared.pop("bcc", None) + shared.pop("replyTo", None) + + recipients = [] + for to_email, recip in zip(to_emails, self.to_recipients): + recipient = {"to": to_email} + if recip.addr_spec in self.merge_metadata: + # Combine global metadata with this recipient's overrides, and + # ship it as a per-recipient X-Metadata header (per-recipient + # headers win over shared ones in MailKite's batch API). + recipient_metadata = self.metadata.copy() + recipient_metadata.update(self.merge_metadata[recip.addr_spec]) + recipient.setdefault("headers", {})["X-Metadata"] = self.serialize_json( + recipient_metadata + ) + if recip.addr_spec in self.merge_headers: + recipient.setdefault("headers", {}).update( + self.merge_headers[recip.addr_spec] + ) + recipients.append(recipient) + + payload = dict(shared) + payload["recipients"] = recipients + return self.serialize_json(payload) + + # + # Payload construction + # + + def init_payload(self): + self.data = {} # becomes json + + def set_from_email(self, email): + self.data["from"] = email.format(idna_encode=self.backend.idna_encode) + + def set_recipients(self, recipient_type, emails): + assert recipient_type in ["to", "cc", "bcc"] + if emails: + field = recipient_type + self.data[field] = [ + email.format(idna_encode=self.backend.idna_encode) for email in emails + ] + self.recipients += emails + if recipient_type == "to": + self.to_recipients = emails + + def set_subject(self, subject): + self.data["subject"] = subject + + def set_reply_to(self, emails): + # MailKite's API takes a single reply-to address (a string). + # Only the first is used; multiple reply_to addresses are unsupported. + if emails: + if len(emails) > 1: + self.unsupported_feature("multiple reply_to addresses") + self.data["replyTo"] = emails[0].format( + idna_encode=self.backend.idna_encode + ) + + def set_extra_headers(self, headers): + # MailKite requires header values to be strings. + self.data.setdefault("headers", {}).update( + { + k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v + for k, v in headers.items() + } + ) + + 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 make_attachment(self, attachment): + """Returns MailKite attachment dict for attachment""" + # MailKite's send API does not expose a Content-ID field, so it can't + # place inline images -- raise rather than silently send them as regular + # attachments. + if attachment.inline: + self.unsupported_feature("inline attachments (Content-ID)") + + filename = attachment.name or "" + if not filename: + # No name provided. Generate a default name with a reasonable extension. + ext = mimetypes.guess_extension(attachment.mimetype) + if ext: + filename = "attachment%s" % ext + else: + self.unsupported_feature( + "unnamed attachments of type %s" % attachment.mimetype + ) + att = { + "content": attachment.b64content, + "filename": filename, + "contentType": attachment.content_type, + } + return att + + def set_attachments(self, attachments): + if attachments: + self.data["attachments"] = [ + self.make_attachment(attachment) for attachment in attachments + ] + + def set_metadata(self, metadata): + # MailKite has no dedicated metadata field. Send it as json in a custom + # X-Metadata header (custom headers are visible to recipients via "show + # original" -- don't put secrets in metadata). + self.data.setdefault("headers", {})["X-Metadata"] = self.serialize_json( + metadata + ) + self.metadata = metadata # may be needed for batch send in serialize_data + + def set_send_at(self, send_at): + try: + # MailKite accepts ISO 8601 (and natural language / ms-epoch). Preserve + # the offset if present; truncate microseconds to seconds. + send_at = send_at.isoformat( + timespec="milliseconds" if send_at.microsecond else "seconds" + ) + except AttributeError: + # User is responsible for formatting their own string (or POSIX timestamp) + pass + self.data["scheduledAt"] = send_at + + def set_tags(self, tags): + # Send tags using a custom X-Tags header. + self.data.setdefault("headers", {})["X-Tags"] = self.serialize_json(tags) + + def set_track_clicks(self, track_clicks): + # MailKite supports per-message click-tracking overrides (unlike Resend). + self.data["trackClicks"] = bool(track_clicks) + + def set_track_opens(self, track_opens): + # MailKite supports per-message open-tracking overrides (unlike Resend). + self.data["trackOpens"] = bool(track_opens) + + def set_template_id(self, template_id): + # MailKite renders server-side templates (saved or base templates) and + # fills {{merge_tags}} in subject/html/text. + self.data["templateId"] = template_id + + def set_merge_global_data(self, merge_global_data): + # Global merge values, filled into the template / subject / html / text. + self.data["templateData"] = merge_global_data + + def set_merge_data(self, merge_data): + # Empty merge_data is a request to use batch send (each To recipient gets + # their own message, no per-recipient variables). Any actual per-recipient + # merge data is unsupported. + if any(recipient_data for recipient_data in merge_data.values()): + self.unsupported_feature("merge_data") + + def set_merge_metadata(self, merge_metadata): + self.merge_metadata = merge_metadata # late bound in serialize_data + + def set_merge_headers(self, merge_headers): + self.merge_headers = merge_headers # late bound in serialize_data + + def set_esp_extra(self, extra): + self.data.update(extra) diff --git a/docs/esps/esp-feature-matrix.csv b/docs/esps/esp-feature-matrix.csv index 00dcb458..12520b49 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:`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,Yes,No,Yes,Yes,No +Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mailkite-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,Unsupported,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,No,Domain only,Yes,No,No,No,No,Yes,No +:attr:`~AnymailMessage.merge_headers`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes [#caveats]_,Yes,No,No,Yes,Yes,No,Yes,Yes [#caveats]_,Yes [#caveats]_ +:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_metadata`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes [#caveats]_,Yes,Yes,No,Yes,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,Yes,No,No,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,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 [#nocontrol]_,Yes,No,Yes,No,No,Yes,Yes,Yes +:attr:`~AnymailMessage.track_opens`,No [#nocontrol]_,No [#nocontrol]_,Yes,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,No,Yes,Yes,Yes +.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,,,,, +:attr:`~AnymailMessage.template_id`,Yes,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,Yes,No,Yes,No,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_global_data`,Yes [#caveats]_,Yes,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,Yes +:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Not yet,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,No,Yes,Yes,Yes,Yes,No,Yes,Yes,No diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 22f1964d..bc1d4c6b 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 + mailkite mailtrap mandrill postal diff --git a/docs/esps/mailkite.rst b/docs/esps/mailkite.rst new file mode 100644 index 00000000..e7519488 --- /dev/null +++ b/docs/esps/mailkite.rst @@ -0,0 +1,215 @@ +.. _mailkite-backend: + +MailKite +======== + +Anymail integrates Django with the `MailKite`_ developer email platform, using +their `send API`_ endpoint. + +MailKite is *inbound-first*: as well as sending transactional mail via a single +API, it **receives** email and delivers the parsed body plus an authentication +verdict to your own endpoint as one signed webhook. This backend covers the send +side. (MailKite's inbound webhooks deliver received mail to a URL you control, +and are not wired through Anymail's :ref:`inbound ` signals -- see +:ref:`mailkite-inbound` below.) + +.. _MailKite: https://mailkite.dev/ +.. _send API: https://mailkite.dev/docs + + +.. _mailkite-installation: + +Installation +------------ + +Anymail's MailKite backend uses only Anymail's regular ``requests`` +dependency -- there is nothing extra to install:: + + $ python -m pip install django-anymail + +(There is no ``[mailkite]`` extra.) + + +Settings +-------- + +.. rubric:: EMAIL_BACKEND + +To use Anymail's MailKite backend, set: + + .. code-block:: python + + EMAIL_BACKEND = "anymail.backends.mailkite.EmailBackend" + +in your settings.py. + + +.. setting:: ANYMAIL_MAILKITE_API_KEY + +.. rubric:: MAILKITE_API_KEY + +Required for sending. An API key from your MailKite account +(``mk_live_...``). Create one in the MailKite dashboard. + + .. code-block:: python + + ANYMAIL = { + ... + "MAILKITE_API_KEY": "mk_live_...", + } + +Anymail will also look for ``MAILKITE_API_KEY`` at the +root of the settings file if neither ``ANYMAIL["MAILKITE_API_KEY"]`` +nor ``ANYMAIL_MAILKITE_API_KEY`` is set. + + +.. setting:: ANYMAIL_MAILKITE_API_URL + +.. rubric:: MAILKITE_API_URL + +The base url for calling the MailKite API. + +The default is ``MAILKITE_API_URL = "https://api.mailkite.dev/"``. +(It's unlikely you would need to change this.) + + +.. _mailkite-quirks: + +Limitations and quirks +---------------------- + +MailKite supports most Anymail features, but there are a few things to know. + +Anymail normally raises an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` +error when you try to send a message using features MailKite doesn't support. +You can tell Anymail to suppress these errors and send the messages +anyway---see :ref:`unsupported-features`. + +**Single reply-to address only** + MailKite's send API takes a single ``replyTo`` address. If you set more than + one ``reply_to`` address on a message, Anymail raises + :exc:`~anymail.exceptions.AnymailUnsupportedFeature`. (Most apps use at most + one reply-to address.) + +**No inline attachments (Content-ID)** + MailKite's send API does not expose a Content-ID field, so it can't place + inline images. Trying to send an inline attachment raises + :exc:`~anymail.exceptions.AnymailUnsupportedFeature`. + +**Anymail tags and metadata are exposed to recipient** + Anymail implements its normalized :attr:`~anymail.message.AnymailMessage.tags` + and :attr:`~anymail.message.AnymailMessage.metadata` features for MailKite + using custom email headers (``X-Tags`` and ``X-Metadata``). That means they + can be visible to recipients via their email app's "show original message" + (or similar) command. **Do not include sensitive data in tags or metadata.** + +**No envelope sender** + MailKite does not support specifying the + :attr:`~anymail.message.AnymailMessage.envelope_sender`. + +**No outbound status-tracking webhooks** + MailKite does not currently emit outbound tracking events (delivered, opened, + clicked, bounced) as webhooks, so Anymail's :ref:`status tracking + ` signals are not available for MailKite. (You can still set + per-message :attr:`~anymail.message.AnymailMessage.track_opens` and + :attr:`~anymail.message.AnymailMessage.track_clicks`; MailKite records opens + and clicks in its own dashboard.) + + +.. _mailkite-esp-extra: + +esp_extra support +----------------- + +Anymail's MailKite backend will pass +:attr:`~anymail.message.AnymailMessage.esp_extra` values directly into the +MailKite `send API`_ body. For example, to force a per-message tracking +override: + + .. code-block:: python + + message = AnymailMessage(...) + message.esp_extra = { + "trackOpens": True, + "trackClicks": True, + } + + +.. _mailkite-templates: + +ESP templates and merge +----------------------- + +MailKite renders **server-side templates** and fills ``{{merge_tags}}`` in the +subject, html, and text. Use Anymail's normalized +:attr:`~anymail.message.AnymailMessage.template_id` and +:attr:`~anymail.message.AnymailMessage.merge_global_data`: + + .. code-block:: python + + message = EmailMessage( + to=["alice@example.com"], + from_email="...", # subject/body come from the template + ) + message.template_id = "tpl_welcome" # or a base template id + message.merge_global_data = {"name": "Ann", "plan": "Pro"} + + +Batch sending +------------- + +MailKite supports :ref:`batch sending ` (where each *To* recipient +sees only their own email address). Set Anymail's normalized +:attr:`~anymail.message.AnymailMessage.merge_metadata` or +:attr:`~anymail.message.AnymailMessage.merge_headers`, or an empty +:attr:`~anymail.message.AnymailMessage.merge_data`, to use MailKite's batch +endpoint: + + .. code-block:: python + + message = EmailMessage( + to=["alice@example.com", "Bob "], + from_email="...", subject="...", body="..." + ) + message.merge_metadata = { + 'alice@example.com': {'user_id': "12345"}, + 'bob@example.com': {'user_id': "54321"}, + } + +Each recipient gets their own message id, and MailKite reports per-recipient +success or failure (e.g. a suppressed recipient surfaces as ``rejected`` in the +recipient status). + +MailKite's batch endpoint does not accept ``cc``/``bcc`` (each recipient gets a +message addressed only to them). Per-recipient template variables +(:attr:`~anymail.message.AnymailMessage.merge_data`) are not supported -- use a +template with :attr:`~anymail.message.AnymailMessage.merge_global_data`, or +:attr:`~anymail.message.AnymailMessage.merge_metadata` for per-recipient data. + + +.. _mailkite-inbound: + +Inbound +------- + +MailKite's headline feature is **inbound** email: point your domain's MX at +MailKite and it delivers each received message -- parsed body plus an SPF/DKIM/ +DMARC authentication verdict -- to a webhook URL you control, signed with HMAC. + +That inbound flow delivers to *your* endpoint, in MailKite's own payload format, +and is **not** wired through Anymail's :ref:`inbound ` signals. Handle +it directly in your own Django view. (See the MailKite `send API`_ docs index +for the inbound webhook reference.) + + +.. _mailkite-troubleshooting: + +Troubleshooting +--------------- + +If Anymail's MailKite integration isn't behaving like you expect, the MailKite +dashboard shows every message (inbound and outbound), its provider response, and +the full webhook delivery history -- useful for isolating whether a send was +accepted, delivered, or suppressed. + +See Anymail's :ref:`troubleshooting` docs for additional suggestions. diff --git a/pyproject.toml b/pyproject.toml index a54a3aa1..406b4e40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ authors = [ ] description = """\ Django email backends and webhooks for Amazon SES, Brevo, - MailerSend, Mailgun, Mailjet, Mailtrap, Mandrill, Postal, Postmark, Resend, + MailerSend, Mailgun, Mailjet, MailKite, Mailtrap, Mandrill, Postal, Postmark, Resend, Scaleway TEM, SendGrid, SparkPost, and Unisender Go (EmailBackend, transactional email tracking and inbound email signals)\ """ @@ -26,7 +26,7 @@ keywords = [ "Amazon SES", "AWS SES", "Simple Email Service", "Brevo", "SendinBlue", "MailerSend", - "Mailgun", "Mailjet", "Sinch", + "Mailgun", "Mailjet", "MailKite", "Sinch", "Mailtrap", "Mandrill", "MailChimp", "Postal", @@ -78,6 +78,7 @@ brevo = [] mailersend = [] mailgun = [] mailjet = [] +mailkite = [] mailtrap = [] mandrill = [] postal = ["cryptography"] diff --git a/tests/test_mailkite_backend.py b/tests/test_mailkite_backend.py new file mode 100644 index 00000000..aa0c9129 --- /dev/null +++ b/tests/test_mailkite_backend.py @@ -0,0 +1,700 @@ +import json +from datetime import date, datetime +from decimal import Decimal + +from django.core import mail +from django.test import SimpleTestCase, tag +from django.utils.timezone import ( + get_fixed_timezone, + override as override_current_timezone, +) + +from anymail.exceptions import ( + AnymailAPIError, + AnymailConfigurationError, + AnymailSerializationError, + AnymailUnsupportedFeature, +) +from anymail.message import AnymailMessage, attach_inline_image + +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import ( + AnymailTestMixin, + create_text_attachment, + decode_att, + ignore_fail_silently_warning, + override_settings, + sample_image_content, +) + + +@tag("mailkite") +@override_settings( + MAILERS={ + "default": { + "BACKEND": "anymail.backends.mailkite.EmailBackend", + "OPTIONS": {"api_key": "test_api_key"}, + }, + }, +) +class MailKiteBackendMockAPITestCase(RequestsBackendMockAPITestCase): + DEFAULT_RAW_RESPONSE = b'{"id": "msg_aaaaaaaaaaaaaaaaaaaa", "status": "sent"}' + + def setUp(self): + super().setUp() + # Simple message useful for many tests + self.message = mail.EmailMultiAlternatives( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) + + +@tag("mailkite") +class MailKiteBackendStandardEmailTests(MailKiteBackendMockAPITestCase): + """Test backend support for Django standard email features""" + + 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"], + ) + self.assert_esp_called("/v1/send") + headers = self.get_api_call_headers() + self.assertEqual(headers["Authorization"], "Bearer test_api_key") + data = self.get_api_call_json() + self.assertEqual(data["subject"], "Subject here") + self.assertEqual(data["text"], "Here is the message.") + self.assertEqual(data["from"], "from@sender.example.com") + self.assertEqual(data["to"], ["to@example.com"]) + + def test_name_addr(self): + """Make sure RFC2822 name-addr format (with display-name) is allowed""" + msg = mail.EmailMessage( + "Subject", + "Message", + "From Name ", + ["Recipient #1 ", "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "bcc2@example.com"], + ) + msg.send() + data = self.get_api_call_json() + self.assertEqual(data["from"], "From Name ") + self.assertEqual( + data["to"], ["Recipient #1 ", "to2@example.com"] + ) + self.assertEqual( + data["cc"], ["Carbon Copy ", "cc2@example.com"] + ) + self.assertEqual( + data["bcc"], ["Blind Copy ", "bcc2@example.com"] + ) + + def test_email_message(self): + 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 "], + reply_to=["another@example.com"], + headers={ + "X-MyHeader": "my value", + }, + ) + email.send() + data = self.get_api_call_json() + self.assertEqual(data["subject"], "Subject") + self.assertEqual(data["text"], "Body goes here") + self.assertEqual(data["from"], "from@example.com") + self.assertEqual(data["to"], ["to1@example.com", "Also To "]) + self.assertEqual( + data["bcc"], ["bcc1@example.com", "Also BCC "] + ) + self.assertEqual(data["cc"], ["cc1@example.com", "Also CC "]) + # MailKite takes a single replyTo string: + self.assertEqual(data["replyTo"], "another@example.com") + self.assertCountEqual( + data["headers"], + {"X-MyHeader": "my value"}, + ) + + 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() + # header values must be strings (MailKite requires string header values) + self.assertEqual(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): + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + reply_to=["reply@example.com"], + ) + email.send() + data = self.get_api_call_json() + self.assertEqual(data["replyTo"], "reply@example.com") + + def test_multiple_reply_to_unsupported(self): + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + reply_to=["reply@example.com", "Other "], + ) + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple reply_to"): + email.send() + + def test_non_ascii_headers(self): + # MailKite correctly encodes non-ASCII display-names and other headers + # (but requires IDNA encoding for non-ASCII domain names). + email = mail.EmailMessage( + from_email='"Odesílatel, z adresy" ', + to=['"Příjemce, na adresu" '], + subject="Předmět e-mailu", + reply_to=['"Odpověď, adresa" '], + headers={"X-Extra": "Další"}, + body="Prostý text", + ) + email.send() + data = self.get_api_call_json() + self.assertEqual( + data["from"], '"Odesílatel, z adresy" ' + ) + self.assertEqual( + data["to"], ['"Příjemce, na adresu" '] + ) + self.assertEqual(data["subject"], "Předmět e-mailu") + self.assertEqual( + data["replyTo"], '"Odpověď, adresa" ' + ) + self.assertEqual(data["headers"], {"X-Extra": "Další"}) + + def test_attachments(self): + text_content = "pièce jointe\n" + self.message.attach( + create_text_attachment("pièce jointe\n", charset="iso-8859-1") + ) + self.message.attach("émoticône.img", b";-)", "image/x-emoticon") + self.message.send() + data = self.get_api_call_json() + + attachments = data["attachments"] + self.assertEqual(len(attachments), 2) + + self.assertEqual( + attachments[0]["contentType"], 'text/plain; charset="iso-8859-1"' + ) + self.assertEqual(attachments[0]["filename"], "attachment.txt") # generated + self.assertEqual( + decode_att(attachments[0]["content"]).decode("iso-8859-1"), text_content + ) + + self.assertEqual(attachments[1]["contentType"], "image/x-emoticon") + self.assertEqual(attachments[1]["filename"], "émoticône.img") + self.assertEqual(decode_att(attachments[1]["content"]), b";-)") + + def test_inline_attachment_unsupported(self): + # MailKite's send API has no Content-ID field, so it can't place inline + # images -- raise rather than silently send them as regular attachments. + image_data = sample_image_content() + attach_inline_image(self.message, image_data, "test.png") + with self.assertRaisesMessage(AnymailUnsupportedFeature, "inline attachments"): + self.message.send() + + def test_missing_attachment_filename_unknown_type(self): + self.message.attach(None, "data", "text/x-unknown-type") + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "unnamed attachments of type text/x-unknown-type" + ): + self.message.send() + + 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.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + @ignore_fail_silently_warning() + 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_suppress_empty_address_lists(self): + """Empty to, cc, bcc, and reply_to shouldn't generate empty fields""" + self.message.send() + data = self.get_api_call_json() + self.assertNotIn("cc", data) + self.assertNotIn("bcc", data) + self.assertNotIn("replyTo", data) + + # Test empty `to`--but send requires at least one recipient somewhere (like cc) + self.message.to = [] + self.message.cc = ["cc@example.com"] + self.message.send() + data = self.get_api_call_json() + self.assertNotIn("to", data) + + def test_api_failure(self): + failure_response = { + "error": "invalid_api_key", + "message": "The API key is invalid or has been revoked.", + } + self.set_mock_response(status_code=401, json_data=failure_response) + with self.assertRaisesMessage( + AnymailAPIError, r"MailKite API response 401" + ) as cm: + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) + self.assertIn("API key is invalid", str(cm.exception)) + + @ignore_fail_silently_warning() + def test_api_failure_fail_silently(self): + # Make sure fail_silently is respected + failure_response = { + "error": "invalid_api_key", + "message": "The API key is invalid or has been revoked.", + } + self.set_mock_response(status_code=401, json_data=failure_response) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) + self.assertEqual(sent, 0) + + +@tag("mailkite") +class MailKiteBackendAnymailFeatureTests(MailKiteBackendMockAPITestCase): + """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( + json.loads(data["headers"]["X-Metadata"]), + {"user_id": "12345", "items": 6}, + ) + + def test_send_at(self): + utc_plus_6 = get_fixed_timezone(6 * 60) + utc_minus_8 = get_fixed_timezone(-8 * 60) + + with override_current_timezone(utc_plus_6): + # Timezone-naive datetime assumed to be Django current_timezone + self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 123456) + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["scheduledAt"], "2022-10-11T12:13:14.123+06:00") + + # Timezone-aware datetime converted to UTC: + self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8) + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["scheduledAt"], "2016-03-04T05:06:07-08:00") + + # Date-only treated as midnight in current timezone + self.message.send_at = date(2022, 10, 22) + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["scheduledAt"], "2022-10-22T00:00:00+06:00") + + # POSIX timestamp + self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["scheduledAt"], "2022-05-06T07:08:09+00:00") + + # String passed unchanged (this is *not* portable between ESPs) + self.message.send_at = "2013-11-12T01:02:03Z" + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["scheduledAt"], "2013-11-12T01:02:03Z") + + def test_tags(self): + self.message.tags = ["receipt", "reorder test 12"] + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + json.loads(data["headers"]["X-Tags"]), + ["receipt", "reorder test 12"], + ) + + def test_headers_metadata_tags_interaction(self): + # Test three features that use custom headers don't clobber each other + self.message.extra_headers = {"X-Custom": "custom value"} + self.message.metadata = {"user_id": "12345"} + self.message.tags = ["receipt", "reorder test 12"] + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["headers"], + { + "X-Custom": "custom value", + "X-Tags": '["receipt", "reorder test 12"]', + "X-Metadata": '{"user_id": "12345"}', + }, + ) + + # --- Features MailKite supports that Resend does not --- + + def test_track_opens(self): + self.message.track_opens = True + self.message.send() + data = self.get_api_call_json() + self.assertIs(data["trackOpens"], True) + + def test_track_clicks(self): + self.message.track_clicks = True + self.message.send() + data = self.get_api_call_json() + self.assertIs(data["trackClicks"], True) + + def test_template_id(self): + self.message.template_id = "tpl_welcome" + self.message.merge_global_data = {"name": "Ann", "plan": "pro"} + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["templateId"], "tpl_welcome") + self.assertEqual(data["templateData"], {"name": "Ann", "plan": "pro"}) + + _mock_batch_response = { + "results": [ + {"to": "alice@example.com", "id": "msg_aaaa", "status": "sent"}, + {"to": "bob@example.com", "id": "msg_bbbb", "status": "sent"}, + ], + "sent": 2, + "scheduled": 0, + "failed": 0, + } + + def test_merge_data(self): + self.message.merge_data = {"to@example.com": {"customer_id": 3}} + with self.assertRaisesMessage(AnymailUnsupportedFeature, "merge_data"): + self.message.send() + + def test_empty_merge_data(self): + # `merge_data = {}` triggers batch send + self.set_mock_response(json_data=self._mock_batch_response) + message = AnymailMessage( + from_email="from@example.com", + to=["alice@example.com", "Bob "], + merge_data={ + "alice@example.com": {}, + "bob@example.com": {}, + }, + ) + message.send() + self.assert_esp_called("/v1/send/batch") + data = self.get_api_call_json() + # MailKite batch shape: shared fields + recipients[] + self.assertEqual(data["from"], "from@example.com") + self.assertEqual(len(data["recipients"]), 2) + self.assertEqual(data["recipients"][0]["to"], "alice@example.com") + self.assertEqual(data["recipients"][1]["to"], "Bob ") + + recipients = message.anymail_status.recipients + self.assertEqual(recipients["alice@example.com"].status, "queued") + self.assertEqual(recipients["alice@example.com"].message_id, "msg_aaaa") + self.assertEqual(recipients["bob@example.com"].status, "queued") + self.assertEqual(recipients["bob@example.com"].message_id, "msg_bbbb") + + def test_merge_metadata(self): + self.set_mock_response(json_data=self._mock_batch_response) + message = AnymailMessage( + from_email="from@example.com", + to=["alice@example.com", "Bob "], + merge_metadata={ + "alice@example.com": {"order_id": 123, "tier": "premium"}, + "bob@example.com": {"order_id": 678}, + }, + metadata={"notification_batch": "zx912"}, + ) + message.send() + + # merge_metadata forces batch send API: + self.assert_esp_called("/v1/send/batch") + + data = self.get_api_call_json() + self.assertEqual(data["from"], "from@example.com") + self.assertEqual(len(data["recipients"]), 2) + self.assertEqual(data["recipients"][0]["to"], "alice@example.com") + # metadata and merge_metadata[recipient] are combined per-recipient: + self.assertEqual( + json.loads(data["recipients"][0]["headers"]["X-Metadata"]), + {"order_id": 123, "tier": "premium", "notification_batch": "zx912"}, + ) + self.assertEqual(data["recipients"][1]["to"], "Bob ") + self.assertEqual( + json.loads(data["recipients"][1]["headers"]["X-Metadata"]), + {"order_id": 678, "notification_batch": "zx912"}, + ) + # Shared base metadata is *also* shipped at the top level (per-recipient + # headers override it for each recipient): + self.assertEqual( + json.loads(data["headers"]["X-Metadata"]), + {"notification_batch": "zx912"}, + ) + + recipients = message.anymail_status.recipients + self.assertEqual(recipients["alice@example.com"].status, "queued") + self.assertEqual(recipients["alice@example.com"].message_id, "msg_aaaa") + self.assertEqual(recipients["bob@example.com"].status, "queued") + self.assertEqual(recipients["bob@example.com"].message_id, "msg_bbbb") + + def test_merge_headers(self): + self.set_mock_response(json_data=self._mock_batch_response) + message = AnymailMessage( + from_email="from@example.com", + to=["alice@example.com", "Bob "], + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + merge_headers={ + "alice@example.com": { + "List-Unsubscribe": "", + }, + "bob@example.com": { + "List-Unsubscribe": "", + }, + }, + ) + message.send() + + # merge_headers forces batch send API: + self.assert_esp_called("/v1/send/batch") + + data = self.get_api_call_json() + # Shared headers stay at the top level... + self.assertEqual( + data["headers"], + { + "List-Unsubscribe": "", + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + ) + # ...and each recipient overrides the per-recipient header: + self.assertEqual(len(data["recipients"]), 2) + self.assertEqual(data["recipients"][0]["to"], "alice@example.com") + self.assertEqual( + data["recipients"][0]["headers"], + {"List-Unsubscribe": ""}, + ) + self.assertEqual(data["recipients"][1]["to"], "Bob ") + self.assertEqual( + data["recipients"][1]["headers"], + {"List-Unsubscribe": ""}, + ) + + def test_batch_recipient_rejected(self): + # A batch can partially succeed: MailKite reports a per-recipient failure + # in results[], and Anymail should surface it as a "rejected" recipient. + self.set_mock_response( + json_data={ + "results": [ + {"to": "alice@example.com", "id": "msg_aaaa", "status": "sent"}, + { + "to": "bob@example.com", + "status": "failed", + "error": "recipient suppressed", + "code": "recipient_suppressed", + }, + ], + "sent": 1, + "scheduled": 0, + "failed": 1, + } + ) + message = AnymailMessage( + from_email="from@example.com", + to=["alice@example.com", "bob@example.com"], + merge_data={"alice@example.com": {}, "bob@example.com": {}}, + ) + message.send() + recipients = message.anymail_status.recipients + self.assertEqual(recipients["alice@example.com"].status, "queued") + self.assertEqual(recipients["alice@example.com"].message_id, "msg_aaaa") + self.assertEqual(recipients["bob@example.com"].status, "rejected") + self.assertIsNone(recipients["bob@example.com"].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("headers", data) + self.assertNotIn("attachments", data) + self.assertNotIn("scheduledAt", data) + self.assertNotIn("trackOpens", data) + self.assertNotIn("trackClicks", data) + self.assertNotIn("templateId", data) + self.assertNotIn("templateData", data) + + def test_esp_extra(self): + self.message.esp_extra = { + "trackOpens": False, + "headers": {"X-Extra": "from esp_extra"}, + } + self.message.send() + data = self.get_api_call_json() + self.assertIs(data["trackOpens"], False) + self.assertEqual(data["headers"]["X-Extra"], "from esp_extra") + + # noinspection PyUnresolvedReferences + def test_send_attaches_anymail_status(self): + """The anymail_status should be attached to the message when it is sent""" + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["Recipient "], + ) + sent = msg.send() + self.assertEqual(sent, 1) + self.assertEqual(msg.anymail_status.status, {"queued"}) + self.assertEqual(msg.anymail_status.message_id, "msg_aaaaaaaaaaaaaaaaaaaa") + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "queued" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, + "msg_aaaaaaaaaaaaaaaaaaaa", + ) + self.assertEqual( + msg.anymail_status.esp_response.content, self.DEFAULT_RAW_RESPONSE + ) + + # noinspection PyUnresolvedReferences + @ignore_fail_silently_warning() + 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): + """ + If the send succeeds, but a non-JSON API response, should raise an API exception + """ + 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_json_serialization_errors(self): + """Try to provide more information about non-json-serializable data""" + self.message.metadata = {"price": Decimal("19.99")} # yeah, don't do this + with self.assertRaises(AnymailSerializationError) as cm: + self.message.send() + print(self.get_api_call_json()) + err = cm.exception + self.assertIsInstance(err, TypeError) # compatibility with json.dumps + # our added context: + self.assertIn("Don't know how to send this data to MailKite", str(err)) + # original message: + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") + + +@tag("mailkite") +class MailKiteBackendRecipientsRefusedTests(MailKiteBackendMockAPITestCase): + # MailKite checks recipient suppression at send time for batch sends and + # reports it per-recipient, but for a single send it accepts and queues. So + # there's no up-front refused-recipient handling to test here (the refused + # case for batch is covered in MailKiteBackendAnymailFeatureTests). + pass + + +@tag("mailkite") +class MailKiteBackendSessionSharingTestCase( + SessionSharingTestCases, MailKiteBackendMockAPITestCase +): + """Requests session sharing tests""" + + pass # tests are defined in SessionSharingTestCases + + +@tag("mailkite") +@override_settings( + MAILERS={"default": {"BACKEND": "anymail.backends.mailkite.EmailBackend"}} +) +class MailKiteBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): + """Test ESP backend without required settings in place""" + + def test_missing_api_key(self): + with self.assertRaisesRegex( + AnymailConfigurationError, + r"'api_key'|\bMAILKITE_API_KEY.*ANYMAIL_MAILKITE_API_KEY", + ): + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) diff --git a/tests/test_mailkite_integration.py b/tests/test_mailkite_integration.py new file mode 100644 index 00000000..c08fa424 --- /dev/null +++ b/tests/test_mailkite_integration.py @@ -0,0 +1,161 @@ +import os +import unittest +from email.utils import formataddr + +from django.test import SimpleTestCase, tag + +from anymail.message import AnymailMessage + +from .utils import AnymailTestMixin, override_settings + +ANYMAIL_TEST_MAILKITE_API_KEY = os.getenv("ANYMAIL_TEST_MAILKITE_API_KEY") +ANYMAIL_TEST_MAILKITE_DOMAIN = os.getenv("ANYMAIL_TEST_MAILKITE_DOMAIN") + + +@tag("mailkite", "live") +@unittest.skipUnless( + ANYMAIL_TEST_MAILKITE_API_KEY and ANYMAIL_TEST_MAILKITE_DOMAIN, + "Set ANYMAIL_TEST_MAILKITE_API_KEY and ANYMAIL_TEST_MAILKITE_DOMAIN " + "environment variables to run MailKite integration tests", +) +@override_settings( + MAILERS={ + "default": { + "BACKEND": "anymail.backends.mailkite.EmailBackend", + "OPTIONS": {"api_key": ANYMAIL_TEST_MAILKITE_API_KEY}, + }, + }, +) +class MailKiteBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): + """MailKite API integration tests + + MailKite doesn't have a sandbox, so these tests run against the **live** + MailKite API, using the environment variable ``ANYMAIL_TEST_MAILKITE_API_KEY`` + as the API key, and ``ANYMAIL_TEST_MAILKITE_DOMAIN`` to construct sender + addresses. If those variables are not set, these tests won't run. + + """ + + def setUp(self): + super().setUp() + self.from_email = "from@%s" % ANYMAIL_TEST_MAILKITE_DOMAIN + self.message = AnymailMessage( + "Anymail MailKite integration test", + "Text content", + self.from_email, + ["test+anymail@anymail.dev"], + ) + self.message.attach_alternative("

HTML content

", "text/html") + + def test_simple_send(self): + # Example of getting the MailKite 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+anymail@anymail.dev"].status + message_id = anymail_status.recipients["test+anymail@anymail.dev"].message_id + + self.assertEqual(sent_status, "queued") # MailKite accepts and queues + 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 MailKite all-options integration test", + body="This is the text body", + # Verify workarounds for address formatting issues: + from_email=formataddr(("Test «Från», med komma", self.from_email)), + to=[ + "test+anymail@anymail.dev", + '"Recipient 2, OK?" ', + ], + cc=[ + "test+anymail-cc1@anymail.dev", + "Copy 2 ", + ], + bcc=[ + "test+anymail-bcc1@anymail.dev", + "Blind Copy 2 ", + ], + reply_to=['"Reply, with comma" '], + headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3}, + metadata={"meta1": "simple string", "meta2": 2}, + tags=["tag 1", "tag 2"], + track_opens=True, + track_clicks=True, + ) + message.attach_alternative("

HTML content

", "text/html") + + message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") + message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") + + message.send() + # MailKite accepts and queues: + self.assertEqual(message.anymail_status.status, {"queued"}) + self.assertGreater(len(message.anymail_status.message_id), 0) + + def test_template_send(self): + # MailKite renders server-side templates and fills {{merge_tags}}. + # Uses a base template id if ANYMAIL_TEST_MAILKITE_TEMPLATE is set, + # otherwise skips. + template_id = os.getenv("ANYMAIL_TEST_MAILKITE_TEMPLATE") + if not template_id: + self.skipTest("Set ANYMAIL_TEST_MAILKITE_TEMPLATE to run this test") + message = AnymailMessage( + from_email=self.from_email, + to=["test+anymail@anymail.dev"], + template_id=template_id, + merge_global_data={"name": "Anymail"}, + ) + message.send() + self.assertEqual(message.anymail_status.status, {"queued"}) + + def test_batch_send(self): + # merge_metadata will use the batch send API + message = AnymailMessage( + subject="Anymail MailKite batch send integration test", + body="This is the text body", + from_email=self.from_email, + to=[ + "test+anymail@anymail.dev", + '"Recipient 2" ', + ], + metadata={"meta1": "simple string", "meta2": 2}, + merge_metadata={ + "test+anymail@anymail.dev": {"meta3": "recipient 1"}, + "test+anymail2@anymail.dev": {"meta3": "recipient 2"}, + }, + tags=["tag 1", "tag 2"], + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + merge_headers={ + "test+anymail@anymail.dev": { + "List-Unsubscribe": "", + }, + "test+anymail2@anymail.dev": { + "List-Unsubscribe": "", + }, + }, + ) + message.attach_alternative("

HTML content

", "text/html") + + message.send() + # MailKite accepts and queues: + self.assertEqual(message.anymail_status.status, {"queued"}) + recipient_status = message.anymail_status.recipients + self.assertEqual(recipient_status["test+anymail@anymail.dev"].status, "queued") + self.assertEqual(recipient_status["test+anymail2@anymail.dev"].status, "queued") + self.assertRegex(recipient_status["test+anymail@anymail.dev"].message_id, r".+") + self.assertRegex( + recipient_status["test+anymail2@anymail.dev"].message_id, r".+" + ) + # Each recipient gets their own message_id: + self.assertNotEqual( + recipient_status["test+anymail@anymail.dev"].message_id, + recipient_status["test+anymail2@anymail.dev"].message_id, + )