diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 77fc93e6..08f12899 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -43,6 +43,7 @@ jobs: - { tox: django41-py310-mailersend, python: "3.10" } - { tox: django41-py310-mailgun, python: "3.10" } - { tox: django41-py310-mailjet, python: "3.10" } + - { tox: django41-py310-mailpace, python: "3.10" } - { tox: django41-py310-mandrill, python: "3.10" } - { tox: django41-py310-postal, python: "3.10" } - { tox: django41-py310-postmark, python: "3.10" } @@ -83,6 +84,8 @@ jobs: ANYMAIL_TEST_MAILJET_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_API_KEY }} ANYMAIL_TEST_MAILJET_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILJET_DOMAIN }} ANYMAIL_TEST_MAILJET_SECRET_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_SECRET_KEY }} + ANYMAIL_TEST_MAILPACE_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILPACE_DOMAIN }} + ANYMAIL_TEST_MAILPACE_SERVER_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILPACE_SERVER_TOKEN }} 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 89ac3f1b..c87feb0f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -35,6 +35,8 @@ Features * **Brevo:** Add support for batch sending (`docs `__). +* **MailPace**: Add support for this ESP + (`docs `__). * **Resend:** Add support for batch sending (`docs `__). diff --git a/README.rst b/README.rst index 74e96aa7..25bf4b58 100644 --- a/README.rst +++ b/README.rst @@ -31,6 +31,7 @@ Anymail currently supports these ESPs: * **MailerSend** * **Mailgun** * **Mailjet** +* **MailPace** * **Mandrill** (MailChimp transactional) * **Postal** (self-hosted ESP) * **Postmark** diff --git a/anymail/backends/mailpace.py b/anymail/backends/mailpace.py new file mode 100644 index 00000000..20163faa --- /dev/null +++ b/anymail/backends/mailpace.py @@ -0,0 +1,202 @@ +from ..exceptions import AnymailRequestsAPIError +from ..message import AnymailRecipientStatus +from ..utils import CaseInsensitiveCasePreservingDict, get_anymail_setting +from .base_requests import AnymailRequestsBackend, RequestsPayload + + +class EmailBackend(AnymailRequestsBackend): + """ + MailPace API Email Backend + """ + + esp_name = "MailPace" + + def __init__(self, **kwargs): + """Init options from Django settings""" + esp_name = self.esp_name + self.server_token = get_anymail_setting( + "server_token", esp_name=esp_name, kwargs=kwargs, allow_bare=True + ) + api_url = get_anymail_setting( + "api_url", + esp_name=esp_name, + kwargs=kwargs, + default="https://app.mailpace.com/api/v1/", + ) + if not api_url.endswith("/"): + api_url += "/" + super().__init__(api_url, **kwargs) + + def build_message_payload(self, message, defaults): + return MailPacePayload(message, defaults, self) + + def raise_for_status(self, response, payload, message): + # We need to handle 400 responses in parse_recipient_status + if response.status_code != 400: + super().raise_for_status(response, payload, message) + + def parse_recipient_status(self, response, payload, message): + # Prepare the dict by setting everything to queued without a message id + unknown_status = AnymailRecipientStatus(message_id=None, status="unknown") + recipient_status = CaseInsensitiveCasePreservingDict( + {recip.addr_spec: unknown_status for recip in payload.to_cc_and_bcc_emails} + ) + + parsed_response = self.deserialize_json_response(response, payload, message) + + status_code = str(response.status_code) + json_response = response.json() + + # Set the status_msg and id based on the status_code + if status_code == "200": + try: + status_msg = parsed_response["status"] + id = parsed_response["id"] + except (KeyError, TypeError) as err: + raise AnymailRequestsAPIError( + "Invalid MailPace API response format", + email_message=None, + payload=payload, + response=response, + backend=self, + ) from err + elif status_code.startswith("4"): + status_msg = "error" + id = None + + if status_msg == "queued": + # Add the message_id to all of the recipients + for recip in payload.to_cc_and_bcc_emails: + recipient_status[recip.addr_spec] = AnymailRecipientStatus( + message_id=id, status="queued" + ) + elif status_msg == "error": + if "errors" in json_response: + for field in ["to", "cc", "bcc"]: + if field in json_response["errors"]: + error_messages = json_response["errors"][field] + for email in payload.to_cc_and_bcc_emails: + for error_message in error_messages: + if ( + "undefined field" in error_message + or "is invalid" in error_message + ): + recipient_status[ + email.addr_spec + ] = AnymailRecipientStatus( + message_id=None, status="invalid" + ) + elif "contains a blocked address" in error_message: + recipient_status[ + email.addr_spec + ] = AnymailRecipientStatus( + message_id=None, status="rejected" + ) + elif ( + "number of email addresses exceeds maximum volume" + in error_message + ): + recipient_status[ + email.addr_spec + ] = AnymailRecipientStatus( + message_id=None, status="invalid" + ) + else: + continue # No errors found in this field; continue to next field + else: + raise AnymailRequestsAPIError( + email_message=message, + payload=payload, + response=response, + backend=self, + ) + + return dict(recipient_status) + + +class MailPacePayload(RequestsPayload): + def __init__(self, message, defaults, backend, *args, **kwargs): + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + self.server_token = backend.server_token # esp_extra can override + self.to_cc_and_bcc_emails = [] + super().__init__(message, defaults, backend, headers=headers, *args, **kwargs) + + def get_api_endpoint(self): + return "send" + + def get_request_params(self, api_url): + params = super().get_request_params(api_url) + params["headers"]["MailPace-Server-Token"] = self.server_token + return params + + def serialize_data(self): + return self.serialize_json(self.data) + + # + # Payload construction + # + + def init_payload(self): + self.data = {} # becomes json + + def set_from_email(self, email): + self.data["from"] = email.address + + def set_recipients(self, recipient_type, emails): + assert recipient_type in ["to", "cc", "bcc"] + if emails: + # Creates to, cc, and bcc in the payload + self.data[recipient_type] = ", ".join([email.address for email in emails]) + self.to_cc_and_bcc_emails += emails + + def set_subject(self, subject): + self.data["subject"] = subject + + def set_reply_to(self, emails): + if emails: + reply_to = ", ".join([email.address for email in emails]) + self.data["replyto"] = reply_to + + def set_extra_headers(self, headers): + if "list-unsubscribe" in headers: + self.data["list_unsubscribe"] = headers.pop("list-unsubscribe") + if headers: + self.unsupported_features("extra_headers (other than List-Unsubscribe)") + + def set_text_body(self, body): + self.data["textbody"] = body + + def set_html_body(self, body): + self.data["htmlbody"] = body + + def make_attachment(self, attachment): + """Returns MailPace attachment dict for attachment""" + att = { + "name": attachment.name or "", + "content": attachment.b64content, + "content_type": attachment.mimetype, + } + if attachment.inline: + att["cid"] = "cid:%s" % attachment.cid + return att + + def set_attachments(self, attachments): + if attachments: + self.data["attachments"] = [ + self.make_attachment(attachment) for attachment in attachments + ] + + def set_tags(self, tags): + if tags: + if len(tags) == 1: + self.data["tags"] = tags[0] + else: + self.data["tags"] = tags + + def set_esp_extra(self, extra): + self.data.update(extra) + # Special handling for 'server_token': + self.server_token = self.data.pop("server_token", self.server_token) diff --git a/anymail/urls.py b/anymail/urls.py index b35cc5a2..5c20df9e 100644 --- a/anymail/urls.py +++ b/anymail/urls.py @@ -10,6 +10,7 @@ ) from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView +from .webhooks.mailpace import MailPaceInboundWebhookView, MailPaceTrackingWebhookView from .webhooks.mandrill import MandrillCombinedWebhookView from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView @@ -50,6 +51,11 @@ MailjetInboundWebhookView.as_view(), name="mailjet_inbound_webhook", ), + path( + "mailpace/inbound/", + MailPaceInboundWebhookView.as_view(), + name="mailpace_inbound_webhook", + ), path( "postal/inbound/", PostalInboundWebhookView.as_view(), @@ -95,6 +101,11 @@ MailjetTrackingWebhookView.as_view(), name="mailjet_tracking_webhook", ), + path( + "mailpace/tracking/", + MailPaceTrackingWebhookView.as_view(), + name="mailpace_tracking_webhook", + ), path( "postal/tracking/", PostalTrackingWebhookView.as_view(), diff --git a/anymail/webhooks/mailpace.py b/anymail/webhooks/mailpace.py new file mode 100644 index 00000000..ae526b42 --- /dev/null +++ b/anymail/webhooks/mailpace.py @@ -0,0 +1,140 @@ +import base64 +import binascii +import json + +from django.utils.dateparse import parse_datetime + +from anymail.exceptions import ( + AnymailImproperlyInstalled, + AnymailWebhookValidationFailure, + _LazyError, +) +from anymail.utils import get_anymail_setting + +try: + from nacl.exceptions import CryptoError, ValueError + from nacl.signing import VerifyKey +except ImportError: + # This will be raised if verification is attempted (and pynacl wasn't found) + VerifyKey = _LazyError( + AnymailImproperlyInstalled(missing_package="pynacl", install_extra="mailpace") + ) + + +from ..inbound import AnymailInboundMessage +from ..signals import ( + AnymailInboundEvent, + AnymailTrackingEvent, + EventType, + RejectReason, + inbound, + tracking, +) +from .base import AnymailBaseWebhookView + + +class MailPaceBaseWebhookView(AnymailBaseWebhookView): + """Base view class for MailPace webhooks""" + + esp_name = "MailPace" + + def parse_events(self, request): + esp_event = json.loads(request.body.decode("utf-8")) + return [self.esp_to_anymail_event(esp_event)] + + +class MailPaceTrackingWebhookView(MailPaceBaseWebhookView): + """Handler for MailPace delivery webhooks""" + + webhook_key = None + + def __init__(self, **kwargs): + self.webhook_key = get_anymail_setting( + "webhook_key", + esp_name=self.esp_name, + kwargs=kwargs, + allow_bare=True, + default=None, + ) + self.warn_if_no_basic_auth = self.webhook_key is None + + super().__init__(**kwargs) + + # Used by base class + signal = tracking + + event_record_types = { + # Map MailPace event RecordType --> Anymail normalized event type + "email.queued": EventType.QUEUED, + "email.delivered": EventType.DELIVERED, + "email.deferred": EventType.DEFERRED, + "email.bounced": EventType.BOUNCED, + "email.spam": EventType.REJECTED, + } + + # MailPace doesn't send a signature for inbound webhooks, yet + # When/if MailPace does this, move this to the parent class + def validate_request(self, request): + if self.webhook_key: + try: + signature_base64 = request.headers["X-MailPace-Signature"] + signature = base64.b64decode(signature_base64) + except (KeyError, binascii.Error) as error: + raise AnymailWebhookValidationFailure( + "MailPace webhook called with invalid or missing signature" + ) from error + + verify_key_base64 = self.webhook_key + + verify_key = VerifyKey(base64.b64decode(verify_key_base64)) + + message = request.body + + try: + verify_key.verify(message, signature) + except (CryptoError, ValueError): + raise AnymailWebhookValidationFailure( + "MailPace webhook called with incorrect signature" + ) + + def esp_to_anymail_event(self, esp_event): + event_type = self.event_record_types.get(esp_event["event"], EventType.UNKNOWN) + payload = esp_event["payload"] + + reject_reason = ( + RejectReason.SPAM + if event_type == EventType.REJECTED + else RejectReason.BOUNCED + if event_type == EventType.BOUNCED + else None + ) + tags = payload.get("tags", []) + + return AnymailTrackingEvent( + event_type=event_type, + timestamp=parse_datetime(payload["created_at"]), + event_id=payload["id"], + message_id=payload["message_id"], + recipient=payload["to"], + tags=tags, + reject_reason=reject_reason, + ) + + +class MailPaceInboundWebhookView(MailPaceBaseWebhookView): + """Handler for MailPace inbound webhook""" + + signal = inbound + + def esp_to_anymail_event(self, esp_event): + # Use Raw MIME based on guidance here: + # https://github.com/anymail/django-anymail/blob/main/ADDING_ESPS.md + message = AnymailInboundMessage.parse_raw_mime(esp_event.get("raw", None)) + + return AnymailInboundEvent( + event_type=EventType.INBOUND, + timestamp=None, + event_id=esp_event.get("id", None), + esp_event=esp_event, + message=message, + ) diff --git a/docs/esps/esp-feature-matrix.csv b/docs/esps/esp-feature-matrix.csv index d6c579fa..d49b289b 100644 --- a/docs/esps/esp-feature-matrix.csv +++ b/docs/esps/esp-feature-matrix.csv @@ -1,19 +1,19 @@ -Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend` -.. rubric:: :ref:`Anymail send options `,,,,,,,,,,, -:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes -:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_metadata`,No,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes -:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,No,Yes,Yes -:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag -:attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes -:attr:`~AnymailMessage.track_opens`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes -:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes -.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,, -:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes -:attr:`~AnymailMessage.merge_data`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes -:attr:`~AnymailMessage.merge_global_data`,Yes,Yes,(emulated),(emulated),Yes,Yes,No,Yes,No,Yes,Yes -.. rubric:: :ref:`Status ` and :ref:`event tracking `,,,,,,,,,,, -:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes -:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes -.. rubric:: :ref:`Inbound handling `,,,,,,,,,,, -:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes +Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mailpace-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend` +.. rubric:: :ref:`Anymail send options `,,,,,,,,,,,, +:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,No,Domain only,Yes,No,No,No,Yes +:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,No,Yes,No,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_metadata`,No,Yes,No,Yes,Yes,No,Yes,No,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,No,Yes,No,No,No,Yes,Yes +:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag +:attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,No,Yes,No,Yes,No,Yes,Yes +:attr:`~AnymailMessage.track_opens`,No,No,Yes,Yes,Yes,No,Yes,No,Yes,No,Yes,Yes +:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,No,Yes,Yes +.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,, +:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,No,Yes,Yes +:attr:`~AnymailMessage.merge_data`,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,No,Yes,Yes +:attr:`~AnymailMessage.merge_global_data`,Yes,Yes,(emulated),(emulated),Yes,No,Yes,No,Yes,No,Yes,Yes +.. rubric:: :ref:`Status ` and :ref:`event tracking `,,,,,,,,,,,, +:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes +:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes +.. rubric:: :ref:`Inbound handling `,,,,,,,,,,,, +:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 19a54c65..789ce374 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 + mailpace mandrill postal postmark diff --git a/docs/esps/mailpace.rst b/docs/esps/mailpace.rst new file mode 100644 index 00000000..cf550160 --- /dev/null +++ b/docs/esps/mailpace.rst @@ -0,0 +1,226 @@ +.. _mailpace-backend: + +MailPace +========== + +Anymail integrates Django with the `MailPace`_ transactional +email service, using their `send API`_ endpoint. + +.. versionadded:: 10.3 + +.. _MailPace: https://mailpace.com/ +.. _send API: https://docs.mailpace.com/reference/send + + +.. _mailpace-installation: + +Installation +------------ + +Anymail uses the :pypi:`PyNaCl` package to validate MailPace webhook signatures. +If you will use Anymail's :ref:`status tracking ` webhook +with MailPace, and you want to use webhook signature validation, be sure +to include the ``[mailpace]`` option when you install Anymail: + + .. code-block:: console + + $ python -m pip install 'django-anymail[mailpace]' + +(Or separately run ``python -m pip install pynacl``.) + +The PyNaCl package pulls in several other dependencies, so its use +is optional in Anymail. See :ref:`mailpace-webhooks` below for details. +To avoid installing PyNaCl with Anymail, just omit the ``[mailpace]`` option. + + +Settings +-------- + +.. rubric:: EMAIL_BACKEND + +To use Anymail's MailPace backend, set: + + .. code-block:: python + + EMAIL_BACKEND = "anymail.backends.mailpace.EmailBackend" + +in your settings.py. + + +.. setting:: ANYMAIL_MAILPACE_API_KEY + +.. rubric:: MAILPACE_API_KEY + +Required for sending. A domain specific API key from the MailPace app `MailPace app`_. + + .. code-block:: python + + ANYMAIL = { + ... + "MAILPACE_API_KEY": "...", + } + +Anymail will also look for ``MAILPACE_API_KEY`` at the +root of the settings file if neither ``ANYMAIL["MAILPACE_API_KEY"]`` +nor ``ANYMAIL_MAILPACE_API_KEY`` is set. + +.. _MailPace API Keys: https://app.mailpace.com/ + +.. setting:: MAILPACE_WEBHOOK_KEY + +.. rubric:: MAILPACE_WEBHOOK_KEY + +The MailPace webhook signing secret used to verify webhook posts. +Recommended if you are using activity tracking, otherwise not necessary. +(This is separate from Anymail's :setting:`WEBHOOK_SECRET ` setting.) + +Find this in your MailPace App `MailPace app`_ by opening your domain, +selecting webhooks, and look for the "Public Key Verification" section. + + .. code-block:: python + + ANYMAIL = { + ... + "MAILPACE_WEBHOOK_KEY": "...", + } + +If you provide this setting, the PyNaCl package is required. +See :ref:`mailpace-installation` above. + + +.. setting:: ANYMAIL_MAILPACE_API_URL + +.. rubric:: MAILPACE_API_URL + +The base url for calling the MailPace API. + +The default is ``MAILPACE_API_URL = "https://app.mailpace.com/api/v1/send"``. +(It's unlikely you would need to change this.) + +.. _MailPace app: https://app.mailpace.com/ + + +.. _mailpace-quirks: + +Limitations and quirks +---------------------- + +- MailPace does not, and will not ever support open tracking or click tracking. + (You can still use Anymail's :ref:`status tracking ` which uses webhooks for tracking delivery) + +.. _mailpace-webhooks: + +Status tracking webhooks +------------------------ + +Anymail's normalized :ref:`status tracking ` works +with MailPace's webhooks. + +MailPace implements webhook signing, using the :pypi:`PyNaCl` package +for signature validation (see :ref:`mailpace-installation` above). You have +three options for securing the status tracking webhook: + +* Use MailPace's webhook signature validation, by setting + :setting:`MAILPACE_WEBHOOK_KEY ` + (requires the PyNaCl package) +* Use Anymail's shared secret validation, by setting + :setting:`WEBHOOK_SECRET ` + (does not require PyNaCl) +* Use both + +Signature validation is recommended, unless you do not want to add +PyNaCl to your dependencies. + +To configure Anymail status tracking for MailPace, +add a new webhook endpoint to domain in the `MailPace app`_: + +* For the "Endpoint URL", enter one of these + (where *yoursite.example.com* is your Django site). + + If are *not* using Anymail's shared webhook secret: + + :samp:`https://{yoursite.example.com}/anymail/mailpace/tracking/` + + Or if you *are* using Anymail's :setting:`WEBHOOK_SECRET `, + include the *random:random* shared secret in the URL: + + :samp:`https://{random}:{random}@{yoursite.example.com}/mailpace/tracking/` + +* For "Events", select any or all events you want to track. + +* Click the "Add Endpoint" button. + +Then, if you are using MailPace's webhook signature validation (with PyNaCl), +add the webhook signing secret to your Anymail settings: + +* Still on the Webhooks page, scroll down to the "Public Key Verification" section. + +* Add that key to your settings.py ``ANYMAIL`` settings as + :setting:`MAILPACE_WEBHOOK_KEY `: + + .. code-block:: python + + ANYMAIL = { + # ... + "MAILPACE_WEBHOOK_KEY": "..." + } + +MailPace will report these Anymail +:attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s: +queued, delivered, deferred, bounced, and spam. + + +.. _mailpace-tracking-recipient: + +.. note:: + + **Multiple recipients not recommended with tracking** + + If you send a message with multiple recipients (to, cc, and/or bcc), + you will only receive one event (delivered, deferred, etc.) + per email. MailPace does not send send different events for each + recipient. + + To avoid confusion, it's best to send each message to exactly one ``to`` + address, and avoid using cc or bcc. + + +.. _mailpace-esp-event: + +The status tracking event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` +field will be the parsed MailPace webhook payload. + +.. _mailpace-inbound: + +Inbound +------- + +If you want to receive email from Mailgun through Anymail's normalized :ref:`inbound ` +handling, set up a new Inbound route in the MailPace app points to Anymail's inbound webhook. + +Use this url as the route's "forward" destination: + + :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mailpace/inbound/` + + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret + * *yoursite.example.com* is your Django site + +MailPace sends the Raw MIME message by default, and that is what Anymail uses to process the inbound email. + +.. _mailpace-troubleshooting: + +Troubleshooting +--------------- + +If Anymail's MailPace integration isn't behaving like you expect, +MailPace's dashboard includes information that can help +isolate the problem, for each Domain you have: + +* MailPace Outbound Emails lists every email accepted by MailPace for delivery +* MailPace Webhooks page shows every attempt by MailPace to call + your webhook +* MailPace Inbound page shows every inbound email received and every attempt + by MailPace to forward it to your Anymail inbound endpoint + + +See Anymail's :ref:`troubleshooting` docs for additional suggestions. diff --git a/pyproject.toml b/pyproject.toml index ee1fb7ba..3b5b3db0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ authors = [ ] description = """\ Django email backends and webhooks for Amazon SES, Brevo (Sendinblue), - MailerSend, Mailgun, Mailjet, Mandrill, Postal, Postmark, Resend, + MailerSend, Mailgun, Mailjet, MailPace, Mandrill, Postal, Postmark, Resend, SendGrid, and SparkPost\ """ # readme: see tool.hatch.metadata.hooks.custom below @@ -21,7 +21,7 @@ keywords = [ "Django", "email", "email backend", "ESP", "transactional mail", "Amazon SES", "Brevo", - "MailerSend", "Mailgun", "Mailjet", "Mandrill", + "MailerSend", "Mailgun", "Mailjet", "MailPace", "Mandrill", "Postal", "Postmark", "Resend", "SendGrid", "SendinBlue", "SparkPost", @@ -68,6 +68,7 @@ amazon-ses = ["boto3"] mailersend = [] mailgun = [] mailjet = [] +mailpace = ["pynacl"] mandrill = [] postmark = [] resend = ["svix"] diff --git a/tests/test_mailpace_backend.py b/tests/test_mailpace_backend.py new file mode 100644 index 00000000..ac3311ea --- /dev/null +++ b/tests/test_mailpace_backend.py @@ -0,0 +1,296 @@ +from base64 import b64encode + +from django.core import mail +from django.core.exceptions import ImproperlyConfigured +from django.test import SimpleTestCase, override_settings, tag + +from anymail.exceptions import ( + AnymailAPIError, + AnymailRecipientsRefused, + AnymailRequestsAPIError, +) +from anymail.message import 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("mailpace") +@override_settings( + EMAIL_BACKEND="anymail.backends.mailpace.EmailBackend", + ANYMAIL={"MAILPACE_SERVER_TOKEN": "test_server_token"}, +) +class MailPaceBackendMockAPITestCase(RequestsBackendMockAPITestCase): + DEFAULT_RAW_RESPONSE = b"""{ + "id": 123, + "status": "queued" + }""" + + 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("mailpace") +class MailPaceBackendStandardEmailTests(MailPaceBackendMockAPITestCase): + """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"], + fail_silently=False, + ) + self.assert_esp_called("https://app.mailpace.com/api/v1/send") + headers = self.get_api_call_headers() + self.assertEqual(headers["MailPace-Server-Token"], "test_server_token") + data = self.get_api_call_json() + self.assertEqual(data["subject"], "Subject here") + self.assertEqual(data["textbody"], "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 + + (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"], + ) + 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 "], + headers={ + "Reply-To": "another@example.com", + }, + ) + email.send() + data = self.get_api_call_json() + self.assertEqual(data["subject"], "Subject") + self.assertEqual(data["textbody"], "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 ") + self.assertEqual(data["replyto"], "another@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["textbody"], text_content) + self.assertEqual(data["htmlbody"], 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("textBody", data) + self.assertEqual(data["htmlbody"], html_content) + + def test_reply_to(self): + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + reply_to=["reply@example.com", "Other "], + ) + email.send() + data = self.get_api_call_json() + self.assertEqual( + data["replyto"], "reply@example.com, Other " + ) + + def test_sending_attachment(self): + """Test sending attachments""" + email = mail.EmailMessage( + "Subject", + "content", + "from@example.com", + ["to@example.com"], + attachments=[ + ("file.txt", "file content", "text/plain"), + ], + ) + email.send() + data = self.get_api_call_json() + self.assertEqual( + data["attachments"], + [ + { + "name": "file.txt", + "content": b64encode(b"file content").decode("ascii"), + "content_type": "text/plain", + } + ], + ) + + 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["htmlbody"], html_content) + + attachments = data["attachments"] + self.assertEqual(len(attachments), 1) + self.assertEqual(attachments[0]["name"], image_filename) + self.assertEqual(attachments[0]["content_type"], "image/png") + self.assertEqual(decode_att(attachments[0]["content"]), image_data) + self.assertEqual(attachments[0]["cid"], "cid:%s" % cid) + + def test_tag(self): + self.message.tags = ["receipt"] + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["tags"], "receipt") + + def test_tags(self): + self.message.tags = ["receipt", "repeat-user"] + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["tags"], ["receipt", "repeat-user"]) + + def test_invalid_response(self): + """AnymailAPIError raised for non-json response""" + self.set_mock_response(raw=b"not json") + with self.assertRaises(AnymailRequestsAPIError): + self.message.send() + + def test_invalid_success_response(self): + """AnymailRequestsAPIError raised for success response with invalid json""" + self.set_mock_response(raw=b"{}") # valid json, but not a MailPace response + with self.assertRaises(AnymailRequestsAPIError): + self.message.send() + + def test_response_blocked_error(self): + """AnymailRecipientsRefused raised for error response with MailPace blocked address""" + self.set_mock_response( + raw=b"""{ + "errors": { + "to": ["contains a blocked address"] + } + }""", + status_code=400, + ) + with self.assertRaises(AnymailRecipientsRefused): + self.message.send() + + def test_response_maximum_address_error(self): + """AnymailAPIError raised for error response with MailPace maximum address""" + self.set_mock_response( + raw=b"""{ + "errors": { + "to": ["number of email addresses exceeds maximum volume"] + } + }""", + status_code=400, + ) + with self.assertRaises(AnymailRecipientsRefused): + self.message.send() + + +@tag("mailpace") +class MailPaceBackendRecipientsRefusedTests(MailPaceBackendMockAPITestCase): + """ + Should raise AnymailRecipientsRefused when any recipients are rejected or invalid + """ + + def test_recipients_invalid(self): + self.set_mock_response( + status_code=400, + raw=b"""{"errors":{"to":["is invalid"]}}""", + ) + msg = mail.EmailMessage( + "Subject", "Body", "from@example.com", ["Invalid@LocalHost"] + ) + with self.assertRaises(AnymailRecipientsRefused): + msg.send() + status = msg.anymail_status + self.assertEqual(status.recipients["Invalid@LocalHost"].status, "invalid") + + def test_from_email_invalid(self): + self.set_mock_response( + status_code=400, + raw=b"""{"error":"Email from address not parseable"}""", + ) + msg = mail.EmailMessage( + "Subject", "Body", "invalid@localhost", ["to@example.com"] + ) + with self.assertRaises(AnymailAPIError): + msg.send() + + +@tag("mailpace") +class MailPaceBackendSessionSharingTestCase( + SessionSharingTestCases, MailPaceBackendMockAPITestCase +): + """Requests session sharing tests""" + + pass # tests are defined in SessionSharingTestCases + + +@tag("mailpace") +@override_settings(EMAIL_BACKEND="anymail.backends.mailpace.EmailBackend") +class MailPaceBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): + """Test ESP backend without required settings in place""" + + def test_missing_api_key(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"\bMAILPACE_SERVER_TOKEN\b") + self.assertRegex(errmsg, r"\bANYMAIL_MAILPACE_SERVER_TOKEN\b") diff --git a/tests/test_mailpace_inbound.py b/tests/test_mailpace_inbound.py new file mode 100644 index 00000000..d948b4ab --- /dev/null +++ b/tests/test_mailpace_inbound.py @@ -0,0 +1,187 @@ +import json +from base64 import b64encode +from textwrap import dedent +from unittest.mock import ANY + +from django.test import tag + +from anymail.signals import AnymailInboundEvent +from anymail.webhooks.mailpace import MailPaceInboundWebhookView + +from .utils import sample_email_content, sample_image_content +from .webhook_cases import WebhookTestCase + + +@tag("mailpace") +class MailPaceInboundTestCase(WebhookTestCase): + def test_inbound_basics(self): + # Only raw is used by Anymail + mailpace_payload = { + "from": "Person A ", + "headers": ["Received: from localhost...", "DKIM-Signature: v=1 a=rsa...;"], + "messageId": "<3baf4caf-948a-41e6-bc5c-2e99058e6461@mailer.mailpace.com>", + "raw": dedent( + """\ + From: A tester + Date: Thu, 12 Oct 2017 18:03:30 -0700 + Message-ID: + Subject: Raw MIME test + To: test@inbound.example.com + MIME-Version: 1.0 + Content-Type: multipart/alternative; boundary="boundary1" + + --boundary1 + Content-Type: text/plain; charset="UTF-8" + Content-Transfer-Encoding: quoted-printable + + It's a body=E2=80=A6 + + --boundary1 + Content-Type: text/html; charset="UTF-8" + Content-Transfer-Encoding: quoted-printable + +
It's a body=E2=80=A6
", + "subject": "Email Subject", + "cc": "Person C ", + "bcc": "Person D ", + "inReplyTo": "<3baf4caf-948a-41e6-bc5c-2e99058e6461@mailer.mailpace.com>", + "replyTo": "bounces+abcd@test.com", + "html": "

Email Contents Here

", + "text": "Text Email Contents", + "attachments": [ + { + "filename": "example.pdf", + "content_type": "application/pdf", + "content": "base64_encoded_content_of_the_attachment", + }, + ], + } + + response = self.client.post( + "/anymail/mailpace/inbound/", + content_type="application/json", + data=json.dumps(mailpace_payload), + ) + + self.assertEqual(response.status_code, 200) + + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=MailPaceInboundWebhookView, + event=ANY, + esp_name="MailPace", + ) + + event = kwargs["event"] + + self.assertIsInstance(event, AnymailInboundEvent) + self.assertEqual(event.event_type, "inbound") + + message = event.message + + self.assertEqual(message.to[0].address, "test@inbound.example.com") + self.assertEqual(message["from"], "A tester ") + self.assertEqual(message.subject, "Raw MIME test") + + self.assertEqual(len(message._headers), 7) + + def test_inbound_attachments(self): + image_content = sample_image_content() + email_content = sample_email_content() + raw_mime = dedent( + """\ + MIME-Version: 1.0 + From: from@example.org + Subject: Attachments + To: test@inbound.example.com + Content-Type: multipart/mixed; boundary="boundary0" + + --boundary0 + Content-Type: multipart/related; boundary="boundary1" + + --boundary1 + Content-Type: text/html; charset="UTF-8" + +
This is the HTML body. It has an inline image: .
+ + --boundary1 + Content-Type: image/png + Content-Disposition: inline; filename="image.png" + Content-ID: + Content-Transfer-Encoding: base64 + + {image_content_base64} + --boundary1-- + --boundary0 + Content-Type: text/plain; charset="UTF-8" + Content-Disposition: attachment; filename="test.txt" + + test attachment + --boundary0 + Content-Type: message/rfc822; charset="US-ASCII" + Content-Disposition: attachment + X-Comment: (the only valid transfer encodings for message/* are 7bit, 8bit, and binary) + + {email_content} + --boundary0-- + """ # NOQA: E501 + ).format( + image_content_base64=b64encode(image_content).decode("ascii"), + email_content=email_content.decode("ascii"), + ) + + # Only raw is used by Anymail + mailpace_payload = { + "from": "Person A ", + "headers": ["Received: from localhost...", "DKIM-Signature: v=1 a=rsa...;"], + "messageId": "<3baf4caf-948a-41e6-bc5c-2e99058e6461@mailer.mailpace.com>", + "raw": raw_mime, + "to": "Person B ", + "subject": "Email Subject", + "cc": "Person C ", + "bcc": "Person D ", + "inReplyTo": "<3baf4caf-948a-41e6-bc5c-2e99058e6461@mailer.mailpace.com>", + "replyTo": "bounces+abcd@test.com", + "html": "

Email Contents Here

", + "text": "Text Email Contents", + "attachments": [ + { + "filename": "example.pdf", + "content_type": "application/pdf", + "content": "base64_encoded_content_of_the_attachment", + }, + ], + } + + response = self.client.post( + "/anymail/mailpace/inbound/", + content_type="application/json", + data=json.dumps(mailpace_payload), + ) + + self.assertEqual(response.status_code, 200) + + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=MailPaceInboundWebhookView, + event=ANY, + esp_name="MailPace", + ) + + event = kwargs["event"] + + self.assertIsInstance(event, AnymailInboundEvent) + + message = event.message + + self.assertEqual(message.to[0].address, "test@inbound.example.com") + + self.assertEqual(len(message._headers), 5) + self.assertEqual(len(message.attachments), 2) + attachment = message.attachments[0] + self.assertEqual(attachment.get_filename(), "test.txt") diff --git a/tests/test_mailpace_integration.py b/tests/test_mailpace_integration.py new file mode 100644 index 00000000..125541ea --- /dev/null +++ b/tests/test_mailpace_integration.py @@ -0,0 +1,113 @@ +import os +import unittest +from email.headerregistry import Address + +from django.test import SimpleTestCase, override_settings, tag + +from anymail.exceptions import AnymailAPIError +from anymail.message import AnymailMessage + +from .utils import AnymailTestMixin, sample_image_path + +ANYMAIL_TEST_MAILPACE_SERVER_TOKEN = os.getenv("ANYMAIL_TEST_MAILPACE_SERVER_TOKEN") +ANYMAIL_TEST_MAILPACE_DOMAIN = os.getenv("ANYMAIL_TEST_MAILPACE_DOMAIN") + + +@tag("mailpace", "live") +@unittest.skipUnless( + ANYMAIL_TEST_MAILPACE_SERVER_TOKEN and ANYMAIL_TEST_MAILPACE_DOMAIN, + "Set ANYMAIL_TEST_MAILPACE_SERVER_TOKEN and ANYMAIL_TEST_MAILPACE_DOMAIN" + " environment variables to run MailPace integration tests", +) +@override_settings( + ANYMAIL_MAILPACE_SERVER_TOKEN=ANYMAIL_TEST_MAILPACE_SERVER_TOKEN, + EMAIL_BACKEND="anymail.backends.mailpace.EmailBackend", +) +class MailPaceBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): + """ + MailPace API integration tests + + These tests run against the **live** MailPace API, using the + environment variable `ANYMAIL_TEST_MAILPACE_SERVER_TOKEN` as the API key, + and `ANYMAIL_TEST_MAILPACE_DOMAIN` to construct sender addresses. + If those variables are not set, these tests won't run. + """ + + def setUp(self): + super().setUp() + self.from_email = str( + Address(username="from", domain=ANYMAIL_TEST_MAILPACE_DOMAIN) + ) + self.message = AnymailMessage( + "Anymail MailPace 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 MailPace 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(message_id, 0) # integer MailPace reference ID + # 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 MailPace all-options integration test", + body="This is the text body", + from_email=str( + Address( + display_name="Test From, with comma", + username="sender", + domain=ANYMAIL_TEST_MAILPACE_DOMAIN, + ) + ), + to=[ + "test+to1@anymail.dev", + '"Recipient 2, with comma" ', + ], + 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": ""}, + tags=["tag 1", "tag 2"], + ) + 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" + ) + + def test_invalid_from(self): + self.message.from_email = "webmaster@localhost" # Django's default From + with self.assertRaisesMessage( + AnymailAPIError, "does not match domain in From field (localhost)" + ): + self.message.send() + + @override_settings(ANYMAIL_MAILPACE_SERVER_TOKEN="Hey, that's not a server token!") + def test_invalid_server_token(self): + with self.assertRaisesMessage(AnymailAPIError, "Invalid API Token"): + self.message.send() diff --git a/tests/test_mailpace_webhooks.py b/tests/test_mailpace_webhooks.py new file mode 100644 index 00000000..682c677b --- /dev/null +++ b/tests/test_mailpace_webhooks.py @@ -0,0 +1,235 @@ +import json +import unittest +from base64 import b64encode +from unittest.mock import ANY + +from django.test import tag + +from anymail.signals import AnymailTrackingEvent +from anymail.webhooks.mailpace import MailPaceTrackingWebhookView + +from .utils_mailpace import ( + ClientWithMailPaceBasicAuth, + ClientWithMailPaceSignature, + make_key, +) +from .webhook_cases import WebhookTestCase + +# These tests are triggered both with and without 'pynacl' installed +try: + from nacl.signing import SigningKey + + PYNACL_INSTALLED = bool(SigningKey) +except ImportError: + PYNACL_INSTALLED = False + + +@tag("mailpace") +@unittest.skipUnless( + PYNACL_INSTALLED, "Install Pynacl to run MailPace Webhook Signature Tests" +) +class MailPaceWebhookSecurityTestCase(WebhookTestCase): + client_class = ClientWithMailPaceSignature + + def setUp(self): + super().setUp() + self.client.set_private_key(make_key()) + + def test_failed_signature_check(self): + response = self.client.post( + "/anymail/mailpace/tracking/", + content_type="application/json", + data=json.dumps({"some": "data"}), + headers={"X-MailPace-Signature": b64encode("invalid".encode("utf-8"))}, + ) + self.assertEqual(response.status_code, 400) + + response = self.client.post( + "/anymail/mailpace/tracking/", + content_type="application/json", + data=json.dumps({"some": "data"}), + headers={"X-MailPace-Signature": "garbage"}, + ) + self.assertEqual(response.status_code, 400) + + response = self.client.post( + "/anymail/mailpace/tracking/", + content_type="application/json", + data=json.dumps({"some": "data"}), + headers={"X-MailPace-Signature": ""}, + ) + self.assertEqual(response.status_code, 400) + + +@unittest.skipIf(PYNACL_INSTALLED, "Pynacl is not available, fallback to basic auth") +class MailPaceWebhookBasicAuthTestCase(WebhookTestCase): + client_class = ClientWithMailPaceBasicAuth + + def setUp(self): + super().setUp() + + def test_queued_event(self): + raw_event = { + "event": "email.queued", + "payload": { + "status": "queued", + "id": 1, + "domain_id": 1, + "created_at": "2021-11-16T14:50:15.445Z", + "updated_at": "2021-11-16T14:50:15.445Z", + "from": "sender@example.com", + "to": "queued@example.com", + "htmlbody": "string", + "textbody": "string", + "cc": "string", + "bcc": "string", + "subject": "string", + "replyto": "string", + "message_id": "string", + "list_unsubscribe": "string", + "tags": ["string", "string"], + }, + } + response = self.client.post( + "/anymail/mailpace/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailPaceTrackingWebhookView, + event=ANY, + esp_name="MailPace", + ) + event = kwargs["event"] + self.assertIsInstance(event, AnymailTrackingEvent) + self.assertEqual(event.event_type, "queued") + self.assertEqual(event.message_id, "string") + self.assertEqual(event.recipient, "queued@example.com") + + +@tag("mailpace") +@unittest.skipUnless(PYNACL_INSTALLED, "Install Pynacl to run MailPace Webhook Tests") +class MailPaceDeliveryTestCase(WebhookTestCase): + client_class = ClientWithMailPaceSignature + + def setUp(self): + super().setUp() + self.client.set_private_key(make_key()) + + def test_queued_event(self): + raw_event = { + "event": "email.queued", + "payload": { + "status": "queued", + "id": 1, + "domain_id": 1, + "created_at": "2021-11-16T14:50:15.445Z", + "updated_at": "2021-11-16T14:50:15.445Z", + "from": "sender@example.com", + "to": "queued@example.com", + "htmlbody": "string", + "textbody": "string", + "cc": "string", + "bcc": "string", + "subject": "string", + "replyto": "string", + "message_id": "string", + "list_unsubscribe": "string", + "tags": ["string", "string"], + }, + } + response = self.client.post( + "/anymail/mailpace/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailPaceTrackingWebhookView, + event=ANY, + esp_name="MailPace", + ) + event = kwargs["event"] + self.assertIsInstance(event, AnymailTrackingEvent) + self.assertEqual(event.event_type, "queued") + self.assertEqual(event.message_id, "string") + self.assertEqual(event.recipient, "queued@example.com") + + def test_delivered_event_no_tags(self): + raw_event = { + "event": "email.delivered", + "payload": { + "status": "delivered", + "id": 1, + "domain_id": 1, + "created_at": "2021-11-16T14:50:15.445Z", + "updated_at": "2021-11-16T14:50:15.445Z", + "from": "sender@example.com", + "to": "queued@example.com", + "htmlbody": "string", + "textbody": "string", + "cc": "string", + "bcc": "string", + "subject": "string", + "replyto": "string", + "message_id": "string", + "list_unsubscribe": "string", + }, + } + response = self.client.post( + "/anymail/mailpace/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailPaceTrackingWebhookView, + event=ANY, + esp_name="MailPace", + ) + event = kwargs["event"] + self.assertIsInstance(event, AnymailTrackingEvent) + self.assertEqual(event.event_type, "delivered") + self.assertEqual(event.tags, []) + + def test_rejected_event_reason(self): + raw_event = { + "event": "email.spam", + "payload": { + "status": "spam", + "id": 1, + "domain_id": 1, + "created_at": "2021-11-16T14:50:15.445Z", + "updated_at": "2021-11-16T14:50:15.445Z", + "from": "sender@example.com", + "to": "queued@example.com", + "htmlbody": "string", + "textbody": "string", + "cc": "string", + "bcc": "string", + "subject": "string", + "replyto": "string", + "message_id": "string", + "list_unsubscribe": "string", + }, + } + response = self.client.post( + "/anymail/mailpace/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailPaceTrackingWebhookView, + event=ANY, + esp_name="MailPace", + ) + event = kwargs["event"] + self.assertIsInstance(event, AnymailTrackingEvent) + self.assertEqual(event.event_type, "rejected") + self.assertEqual(event.reject_reason, "spam") diff --git a/tests/utils_mailpace.py b/tests/utils_mailpace.py new file mode 100644 index 00000000..996012f7 --- /dev/null +++ b/tests/utils_mailpace.py @@ -0,0 +1,77 @@ +from base64 import b64encode + +from django.test import override_settings + +from anymail.exceptions import AnymailImproperlyInstalled, _LazyError + +try: + from nacl.signing import SigningKey +except ImportError: + # This will be raised if signing is attempted (and pynacl wasn't found) + SigningKey = _LazyError( + AnymailImproperlyInstalled(missing_package="pynacl", install_extra="mailpace") + ) + +from tests.utils import ClientWithCsrfChecks + + +def make_key(): + """Generate key, for testing only""" + return SigningKey.generate() + + +def derive_public_webhook_key(private_key): + """Derive public key from private key, in base64 as per MailPace spec""" + verify_key_bytes = private_key.verify_key.encode() + return b64encode(verify_key_bytes).decode() + + +# Returns a signature, as a byte string that has been Base64 encoded +# As per MailPace docs +def sign(private_key, message): + """Sign message with private key""" + signature_bytes = private_key.sign(message).signature + return b64encode(signature_bytes).decode("utf-8") + + +class _ClientWithMailPaceSignature(ClientWithCsrfChecks): + private_key = None + + def set_private_key(self, private_key): + self.private_key = private_key + + def post(self, *args, **kwargs): + data = kwargs.get("data", "").encode("utf-8") + + headers = kwargs.setdefault("headers", {}) + if "X-MailPace-Signature" not in headers: + signature = sign(self.private_key, data) + headers["X-MailPace-Signature"] = signature + + webhook_key = derive_public_webhook_key(self.private_key) + with override_settings( + ANYMAIL={ + "MAILPACE_WEBHOOK_KEY": webhook_key, + "WEBHOOK_SECRET": "username:password", + } + ): + # Django 4.2+ test Client allows headers=headers; + # before that, must convert to HTTP_ args: + return super().post( + *args, + **kwargs, + **{ + f"HTTP_{header.upper().replace('-', '_')}": value + for header, value in headers.items() + }, + ) + + +class _ClientWithMailPaceBasicAuth(ClientWithCsrfChecks): + def post(self, *args, **kwargs): + with override_settings(ANYMAIL={"WEBHOOK_SECRET": "username:password"}): + return super().post(*args, **kwargs) + + +ClientWithMailPaceSignature = _ClientWithMailPaceSignature +ClientWithMailPaceBasicAuth = _ClientWithMailPaceBasicAuth diff --git a/tox.ini b/tox.ini index c550d314..41b87fca 100644 --- a/tox.ini +++ b/tox.ini @@ -52,6 +52,7 @@ extras = # (Only ESPs with extra dependencies need to be listed here. # Careful: tox factors (on the left) use underscore; extra names use hyphen.) all,amazon_ses: amazon-ses + all,mailpace: mailpace all,postal: postal all,resend: resend setenv = @@ -62,6 +63,7 @@ setenv = mailersend: ANYMAIL_ONLY_TEST=mailersend mailgun: ANYMAIL_ONLY_TEST=mailgun mailjet: ANYMAIL_ONLY_TEST=mailjet + mailpace: ANYMAIL_ONLY_TEST=mailpace mandrill: ANYMAIL_ONLY_TEST=mandrill postal: ANYMAIL_ONLY_TEST=postal postmark: ANYMAIL_ONLY_TEST=postmark