From b5ef492466f7d53c386dbef14ccebc9697f6e9e5 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Wed, 25 Oct 2023 12:23:57 -0700 Subject: [PATCH] Resend: new ESP (#341) Add support for Resend.com backend and webhooks. Closes #341 --- .github/workflows/integration-test.yml | 3 + CHANGELOG.rst | 6 + README.rst | 1 + anymail/backends/resend.py | 231 ++++++++++ anymail/urls.py | 6 + anymail/webhooks/resend.py | 195 +++++++++ docs/esps/index.rst | 47 +- docs/esps/resend.rst | 377 ++++++++++++++++ pyproject.toml | 5 +- tests/test_resend_backend.py | 572 +++++++++++++++++++++++++ tests/test_resend_integration.py | 92 ++++ tests/test_resend_webhooks.py | 416 ++++++++++++++++++ tox.ini | 5 +- 13 files changed, 1932 insertions(+), 24 deletions(-) create mode 100644 anymail/backends/resend.py create mode 100644 anymail/webhooks/resend.py create mode 100644 docs/esps/resend.rst create mode 100644 tests/test_resend_backend.py create mode 100644 tests/test_resend_integration.py create mode 100644 tests/test_resend_webhooks.py diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 3c1b0cb8..77fc93e6 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -46,6 +46,7 @@ jobs: - { tox: django41-py310-mandrill, python: "3.10" } - { tox: django41-py310-postal, python: "3.10" } - { tox: django41-py310-postmark, python: "3.10" } + - { tox: django41-py310-resend, python: "3.10" } - { tox: django41-py310-sendgrid, python: "3.10" } - { tox: django41-py310-sendinblue, python: "3.10" } - { tox: django41-py310-sparkpost, python: "3.10" } @@ -87,6 +88,8 @@ jobs: ANYMAIL_TEST_POSTMARK_DOMAIN: ${{ secrets.ANYMAIL_TEST_POSTMARK_DOMAIN }} ANYMAIL_TEST_POSTMARK_SERVER_TOKEN: ${{ secrets.ANYMAIL_TEST_POSTMARK_SERVER_TOKEN }} ANYMAIL_TEST_POSTMARK_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_POSTMARK_TEMPLATE_ID }} + ANYMAIL_TEST_RESEND_API_KEY: ${{ secrets.ANYMAIL_TEST_RESEND_API_KEY }} + ANYMAIL_TEST_RESEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_RESEND_DOMAIN }} ANYMAIL_TEST_SENDGRID_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDGRID_API_KEY }} ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }} ANYMAIL_TEST_SENDGRID_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_SENDGRID_TEMPLATE_ID }} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1a016321..c6fc86ca 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -30,6 +30,12 @@ vNext *unreleased changes* +Features +~~~~~~~~ + +* **Resend**: Add support for this ESP + (`docs `__). + Fixes ~~~~~ diff --git a/README.rst b/README.rst index d747c35c..74e96aa7 100644 --- a/README.rst +++ b/README.rst @@ -34,6 +34,7 @@ Anymail currently supports these ESPs: * **Mandrill** (MailChimp transactional) * **Postal** (self-hosted ESP) * **Postmark** +* **Resend** * **SendGrid** * **SparkPost** diff --git a/anymail/backends/resend.py b/anymail/backends/resend.py new file mode 100644 index 00000000..e424dc3f --- /dev/null +++ b/anymail/backends/resend.py @@ -0,0 +1,231 @@ +import mimetypes +from email.charset import QP, Charset +from email.header import decode_header, make_header +from email.headerregistry import Address + +from ..message import AnymailRecipientStatus +from ..utils import ( + BASIC_NUMERIC_TYPES, + CaseInsensitiveCasePreservingDict, + get_anymail_setting, +) +from .base_requests import AnymailRequestsBackend, RequestsPayload + +# Used to force RFC-2047 encoded word +# in address formatting workaround +QP_CHARSET = Charset("utf-8") +QP_CHARSET.header_encoding = QP + + +class EmailBackend(AnymailRequestsBackend): + """ + Resend (resend.com) API Email Backend + """ + + esp_name = "Resend" + + 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.resend.com/", + ) + if not api_url.endswith("/"): + api_url += "/" + + # Undocumented setting to control workarounds for Resend display-name issues + # (see below). If/when Resend improves their API, you can disable Anymail's + # workarounds by adding `"RESEND_WORKAROUND_DISPLAY_NAME_BUGS": False` + # to your `ANYMAIL` settings. + self.workaround_display_name_bugs = get_anymail_setting( + "workaround_display_name_bugs", + esp_name=esp_name, + kwargs=kwargs, + default=True, + ) + + super().__init__(api_url, **kwargs) + + def build_message_payload(self, message, defaults): + return ResendPayload(message, defaults, self) + + def parse_recipient_status(self, response, payload, message): + # Resend provides single message id, no other information. + # Assume "queued". + parsed_response = self.deserialize_json_response(response, payload, message) + message_id = parsed_response["id"] + recipient_status = CaseInsensitiveCasePreservingDict( + { + recip.addr_spec: AnymailRecipientStatus( + message_id=message_id, status="queued" + ) + for recip in payload.recipients + } + ) + return dict(recipient_status) + + +class ResendPayload(RequestsPayload): + def __init__(self, message, defaults, backend, *args, **kwargs): + self.recipients = [] # for parse_recipient_status + 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): + return "emails" + + def serialize_data(self): + return self.serialize_json(self.data) + + # + # Payload construction + # + + def init_payload(self): + self.data = {} # becomes json + + def _resend_email_address(self, address): + """ + Return EmailAddress address formatted for use with Resend. + + Works around a Resend bug that rejects properly formatted RFC 5322 + addresses that have the display-name enclosed in double quotes (e.g., + any display-name containing a comma), by substituting an RFC 2047 + encoded word. + + This works for all Resend address fields _except_ `from` (see below). + """ + formatted = address.address + if self.backend.workaround_display_name_bugs: + if formatted.startswith('"'): + # Workaround: force RFC-2047 encoded word + formatted = str( + Address( + display_name=QP_CHARSET.header_encode(address.display_name), + addr_spec=address.addr_spec, + ) + ) + return formatted + + def set_from_email(self, email): + # Can't use the address header workaround above for the `from` field: + # self.data["from"] = self._resend_email_address(email) + # When `from` uses RFC-2047 encoding, Resend returns a "security_error" + # status 451, "The email payload contain invalid characters". + formatted = email.address + if self.backend.workaround_display_name_bugs: + if formatted.startswith("=?"): + # Workaround: use an *unencoded* (Unicode str) display-name. + # This allows use of non-ASCII characters (which Resend rejects when + # encoded with RFC 2047). Some punctuation will still result in unusual + # behavior or cause an "invalid `from` field" 422 error, but there's + # nothing we can do about that. + formatted = str( + # email.headerregistry.Address str format uses unencoded Unicode + Address( + # Convert RFC 2047 display name back to Unicode str + display_name=str( + make_header(decode_header(email.display_name)) + ), + addr_spec=email.addr_spec, + ) + ) + self.data["from"] = formatted + + def set_recipients(self, recipient_type, emails): + assert recipient_type in ["to", "cc", "bcc"] + if emails: + field = recipient_type + self.data[field] = [self._resend_email_address(email) for email in emails] + self.recipients += emails + + def set_subject(self, subject): + self.data["subject"] = subject + + def set_reply_to(self, emails): + if emails: + self.data["reply_to"] = [ + self._resend_email_address(email) for email in emails + ] + + def set_extra_headers(self, headers): + # Resend requires header values to be strings (not integers) as of 2023-10-20. + # Stringify ints and floats; anything else is the caller's responsibility. + 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 + + @staticmethod + def make_attachment(attachment): + """Returns Resend attachment dict for attachment""" + filename = attachment.name or "" + if not filename: + # Provide default name with reasonable extension. + # (Resend guesses content type from the filename extension; + # there doesn't seem to be any other way to specify it.) + ext = mimetypes.guess_extension(attachment.content_type) + if ext is not None: + filename = f"attachment{ext}" + att = {"content": attachment.b64content, "filename": filename} + # attachment.inline / attachment.cid not supported + return att + + def set_attachments(self, attachments): + if attachments: + if any(att.content_id for att in attachments): + self.unsupported_feature("inline content-id") + self.data["attachments"] = [ + self.make_attachment(attachment) for attachment in attachments + ] + + def set_metadata(self, metadata): + # Send metadata as json in a custom X-Metadata header. + # (Resend's own "tags" are severely limited in character set) + self.data.setdefault("headers", {})["X-Metadata"] = self.serialize_json( + metadata + ) + + # Resend doesn't support delayed sending + # def set_send_at(self, send_at): + + def set_tags(self, tags): + # Send tags using a custom X-Tags header. + # (Resend's own "tags" are severely limited in character set) + self.data.setdefault("headers", {})["X-Tags"] = self.serialize_json(tags) + + # Resend doesn't support changing click/open tracking per message + # def set_track_clicks(self, track_clicks): + # def set_track_opens(self, track_opens): + + # Resend doesn't support server-rendered templates. + # (Their template feature is rendered client-side, + # using React in node.js.) + # def set_template_id(self, template_id): + # def set_merge_data(self, merge_data): + # def set_merge_global_data(self, merge_global_data): + # def set_merge_metadata(self, merge_metadata): + + def set_esp_extra(self, extra): + self.data.update(extra) diff --git a/anymail/urls.py b/anymail/urls.py index 2952d27c..b35cc5a2 100644 --- a/anymail/urls.py +++ b/anymail/urls.py @@ -13,6 +13,7 @@ from .webhooks.mandrill import MandrillCombinedWebhookView from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView +from .webhooks.resend import ResendTrackingWebhookView from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView from .webhooks.sendinblue import ( SendinBlueInboundWebhookView, @@ -104,6 +105,11 @@ PostmarkTrackingWebhookView.as_view(), name="postmark_tracking_webhook", ), + path( + "resend/tracking/", + ResendTrackingWebhookView.as_view(), + name="resend_tracking_webhook", + ), path( "sendgrid/tracking/", SendGridTrackingWebhookView.as_view(), diff --git a/anymail/webhooks/resend.py b/anymail/webhooks/resend.py new file mode 100644 index 00000000..72f3ba9c --- /dev/null +++ b/anymail/webhooks/resend.py @@ -0,0 +1,195 @@ +import json +from datetime import datetime + +from ..exceptions import ( + AnymailImproperlyInstalled, + AnymailInvalidAddress, + AnymailWebhookValidationFailure, + _LazyError, +) +from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking +from ..utils import get_anymail_setting, parse_single_address +from .base import AnymailBaseWebhookView, AnymailCoreWebhookView + +try: + # Valid webhook signatures with svix library if available + from svix.webhooks import Webhook as SvixWebhook, WebhookVerificationError +except ImportError: + # Otherwise, validating with basic auth is sufficient + # (unless settings specify signature validation, which will then raise this error) + SvixWebhook = _LazyError( + AnymailImproperlyInstalled(missing_package="svix", install_extra="resend") + ) + WebhookVerificationError = object() + + +class SvixWebhookValidationMixin(AnymailCoreWebhookView): + """Mixin to validate Svix webhook signatures""" + + # Consuming classes can override (e.g., to use different secrets + # for inbound and tracking webhooks). + _secret_setting_name = "signing_secret" + + @classmethod + def as_view(cls, **initkwargs): + if not hasattr(cls, cls._secret_setting_name): + # The attribute must exist on the class before View.as_view + # will allow overrides via kwarg + setattr(cls, cls._secret_setting_name, None) + return super().as_view(**initkwargs) + + def __init__(self, **kwargs): + self.signing_secret = get_anymail_setting( + self._secret_setting_name, + esp_name=self.esp_name, + default=None, + kwargs=kwargs, + ) + if self.signing_secret is None: + self._svix_webhook = None + self.warn_if_no_basic_auth = True + else: + # This will raise an import error if svix isn't installed + self._svix_webhook = SvixWebhook(self.signing_secret) + # Basic auth is not required if validating signature + self.warn_if_no_basic_auth = False + super().__init__(**kwargs) + + def validate_request(self, request): + if self._svix_webhook: + # https://docs.svix.com/receiving/verifying-payloads/how + try: + # Note: if signature is valid, Svix also tries to parse + # the json body, so this could raise other errors... + self._svix_webhook.verify(request.body, request.headers) + except WebhookVerificationError as error: + setting_name = f"{self.esp_name}_{self._secret_setting_name}".upper() + raise AnymailWebhookValidationFailure( + f"{self.esp_name} webhook called with incorrect signature" + f" (check Anymail {setting_name} setting)" + ) from error + + +class ResendTrackingWebhookView(SvixWebhookValidationMixin, AnymailBaseWebhookView): + """Handler for Resend.com status tracking webhooks""" + + esp_name = "Resend" + signal = tracking + + def parse_events(self, request): + esp_event = json.loads(request.body.decode("utf-8")) + return [self.esp_to_anymail_event(esp_event, request)] + + # https://resend.com/docs/dashboard/webhooks/event-types + event_types = { + # Map Resend type: Anymail normalized type + "email.sent": EventType.SENT, + "email.delivered": EventType.DELIVERED, + "email.delivery_delayed": EventType.DEFERRED, + "email.complained": EventType.COMPLAINED, + "email.bounced": EventType.BOUNCED, + "email.opened": EventType.OPENED, + "email.clicked": EventType.CLICKED, + } + + def esp_to_anymail_event(self, esp_event, request): + event_type = self.event_types.get(esp_event["type"], EventType.UNKNOWN) + + # event_id: HTTP header `svix-id` is unique for a particular event + # (including across reposts due to errors) + try: + event_id = request.headers["svix-id"] + except KeyError: + event_id = None + + # timestamp: Payload created_at is unique for a particular event. + # (Payload data.created_at is when the message was created, not the event. + # HTTP header `svix-timestamp` changes for each repost of the same event.) + try: + timestamp = datetime.fromisoformat( + # Must convert "Z" to timezone offset for Python 3.10 and earlier. + esp_event["created_at"].replace("Z", "+00:00") + ) + except (KeyError, ValueError): + timestamp = None + + try: + message_id = esp_event["data"]["email_id"] + except (KeyError, TypeError): + message_id = None + + # Resend doesn't provide bounce reasons or SMTP responses, + # but it's possible to distinguish some cases by examining + # the human-readable message text: + try: + bounce_message = esp_event["data"]["bounce"]["message"] + except (KeyError, ValueError): + bounce_message = None + reject_reason = None + else: + if "suppressed sending" in bounce_message: + # "Resend has suppressed sending to this address ..." + reject_reason = RejectReason.BLOCKED + elif "bounce message" in bounce_message: + # "The recipient's email provider sent a hard bounce message, ..." + # "The recipient's email provider sent a general bounce message. ..." + # "The recipient's email provider sent a bounce message because + # the recipient's inbox was full. ..." + reject_reason = RejectReason.BOUNCED + else: + reject_reason = RejectReason.OTHER # unknown + + # Recover tags and metadata from custom headers + metadata = {} + tags = [] + try: + headers = esp_event["data"]["headers"] + except KeyError: + pass + else: + for header in headers: + name = header["name"].lower() + if name == "x-tags": + try: + tags = json.loads(header["value"]) + except (ValueError, TypeError): + pass + elif name == "x-metadata": + try: + metadata = json.loads(header["value"]) + except (ValueError, TypeError): + pass + + # For multi-recipient emails (including cc and bcc), Resend generates events + # for each recipient, but no indication of which recipient an event applies to. + # Just report the first `to` recipient. + try: + first_to = esp_event["data"]["to"][0] + recipient = parse_single_address(first_to).addr_spec + except (KeyError, IndexError, TypeError, AnymailInvalidAddress): + recipient = None + + try: + click_data = esp_event["data"]["click"] + except (KeyError, TypeError): + click_url = None + user_agent = None + else: + click_url = click_data.get("link") + user_agent = click_data.get("userAgent") + + return AnymailTrackingEvent( + event_type=event_type, + timestamp=timestamp, + message_id=message_id, + event_id=event_id, + recipient=recipient, + reject_reason=reject_reason, + description=bounce_message, + mta_response=None, + tags=tags, + metadata=metadata, + click_url=click_url, + user_agent=user_agent, + esp_event=esp_event, + ) diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 420e7d8a..06f8ae9f 100644 --- a/docs/esps/index.rst +++ b/docs/esps/index.rst @@ -20,6 +20,7 @@ and notes about any quirks or limitations: mandrill postal postmark + resend sendgrid sparkpost @@ -28,38 +29,39 @@ Anymail feature support ----------------------- The table below summarizes the Anymail features supported for each ESP. +(Scroll it to the left and right to see all ESPs.) .. currentmodule:: anymail.message .. rst-class:: sticky-left -============================================ ============ ======= ============ =========== ========== =========== ========== ========== ========== =========== -Email Service Provider |Amazon SES| |Brevo| |MailerSend| |Mailgun| |Mailjet| |Mandrill| |Postal| |Postmark| |SendGrid| |SparkPost| -============================================ ============ ======= ============ =========== ========== =========== ========== ========== ========== =========== +============================================ ============ ======= ============ =========== ========== =========== ========== ========== ======== ========== =========== +Email Service Provider |Amazon SES| |Brevo| |MailerSend| |Mailgun| |Mailjet| |Mandrill| |Postal| |Postmark| |Resend| |SendGrid| |SparkPost| +============================================ ============ ======= ============ =========== ========== =========== ========== ========== ======== ========== =========== .. rubric:: :ref:`Anymail send options ` ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -:attr:`~AnymailMessage.envelope_sender` Yes No No Domain only Yes Domain only Yes No No Yes -:attr:`~AnymailMessage.metadata` Yes Yes No Yes Yes Yes No Yes Yes Yes -:attr:`~AnymailMessage.merge_metadata` No No No Yes Yes Yes No Yes Yes Yes -:attr:`~AnymailMessage.send_at` No Yes Yes Yes No Yes No No Yes Yes -:attr:`~AnymailMessage.tags` Yes Yes Yes Yes Max 1 tag Yes Max 1 tag Max 1 tag Yes Max 1 tag -:attr:`~AnymailMessage.track_clicks` No No Yes Yes Yes Yes No Yes Yes Yes -:attr:`~AnymailMessage.track_opens` No No Yes Yes Yes Yes No Yes Yes Yes -:ref:`amp-email` Yes No No Yes No No No No Yes Yes +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +: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 No No Yes Yes Yes No Yes No 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 Yes Yes -:attr:`~AnymailMessage.merge_data` Yes No Yes Yes Yes Yes No Yes Yes Yes -:attr:`~AnymailMessage.merge_global_data` Yes Yes (emulated) (emulated) Yes Yes No Yes Yes Yes +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +:attr:`~AnymailMessage.template_id` Yes Yes Yes Yes Yes Yes No Yes No Yes Yes +:attr:`~AnymailMessage.merge_data` Yes No 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 -|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes +|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes .. rubric:: :ref:`Inbound handling ` ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -|AnymailInboundEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes -============================================ ============ ======= ============ =========== ========== =========== ========== ========== ========== =========== +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +|AnymailInboundEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes Yes No Yes Yes +============================================ ============ ======= ============ =========== ========== =========== ========== ========== ======== ========== =========== Trying to choose an ESP? Please **don't** start with this table. It's far more @@ -75,6 +77,7 @@ meaningless. (And even specific features don't matter if you don't plan to use t .. |Mandrill| replace:: :ref:`mandrill-backend` .. |Postal| replace:: :ref:`postal-backend` .. |Postmark| replace:: :ref:`postmark-backend` +.. |Resend| replace:: :ref:`resend-backend` .. |SendGrid| replace:: :ref:`sendgrid-backend` .. |SparkPost| replace:: :ref:`sparkpost-backend` .. |AnymailTrackingEvent| replace:: :class:`~anymail.signals.AnymailTrackingEvent` diff --git a/docs/esps/resend.rst b/docs/esps/resend.rst new file mode 100644 index 00000000..cb746b35 --- /dev/null +++ b/docs/esps/resend.rst @@ -0,0 +1,377 @@ +.. _resend-backend: + +Resend +====== + +Anymail integrates Django with the `Resend`_ transactional +email service, using their `send-email API`_ endpoint. + +.. versionadded:: 10.2 + +.. _Resend: https://resend.com/ +.. _send-email API: https://resend.com/docs/api-reference/emails/send-email + + +.. _resend-installation: + +Installation +------------ + +Anymail uses the :pypi:`svix` package to validate Resend webhook signatures. +If you will use Anymail's :ref:`status tracking ` webhook +with Resend, and you want to use webhook signature validation, be sure +to include the ``[resend]`` option when you install Anymail: + + .. code-block:: console + + $ python -m pip install 'django-anymail[resend]' + +(Or separately run ``python -m pip install svix``.) + +The svix package pulls in several other dependencies, so its use +is optional in Anymail. See :ref:`resend-webhooks` below for details. +To avoid installing svix with Anymail, just omit the ``[resend]`` option. + + +Settings +-------- + +.. rubric:: EMAIL_BACKEND + +To use Anymail's Resend backend, set: + + .. code-block:: python + + EMAIL_BACKEND = "anymail.backends.resend.EmailBackend" + +in your settings.py. + + +.. setting:: ANYMAIL_RESEND_API_KEY + +.. rubric:: RESEND_API_KEY + +Required for sending. An API key from your `Resend API Keys`_. +Anymail needs only "sending access" permission; "full access" is not recommended. + + .. code-block:: python + + ANYMAIL = { + ... + "RESEND_API_KEY": "re_...", + } + +Anymail will also look for ``RESEND_API_KEY`` at the +root of the settings file if neither ``ANYMAIL["RESEND_API_KEY"]`` +nor ``ANYMAIL_RESEND_API_KEY`` is set. + +.. _Resend API Keys: https://resend.com/api-keys + + +.. setting:: ANYMAIL_RESEND_SIGNING_SECRET + +.. rubric:: RESEND_SIGNING_SECRET + +The Resend 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 Resend `Webhooks settings`_: after adding +a webhook, click into its management page and look for "signing secret" +near the top. + + .. code-block:: python + + ANYMAIL = { + ... + "RESEND_SIGNING_SECRET": "whsec_...", + } + +If you provide this setting, the svix package is required. +See :ref:`resend-installation` above. + + +.. setting:: ANYMAIL_RESEND_API_URL + +.. rubric:: RESEND_API_URL + +The base url for calling the Resend API. + +The default is ``RESEND_API_URL = "https://api.resend.com/"``. +(It's unlikely you would need to change this.) + +.. _Webhooks settings: https://resend.com/webhooks + + +.. _resend-quirks: + +Limitations and quirks +---------------------- + +Resend does not support a few features offered by some other ESPs, +and can have unexpected behavior for some common use cases. + +Anymail normally raises an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` +error when you try to send a message using features that Resend doesn't support. +You can tell Anymail to suppress these errors and send the messages +anyway---see :ref:`unsupported-features`. + +**Restricted characters in ``from_email`` display names** + Resend's API does not accept many email address display names + (a.k.a. "friendly names" or "real names") formatted according + to the relevant standard (:rfc:`5322`). Anymail implements a + workaround for the ``to``, ``cc``, ``bcc`` and ``reply_to`` + fields, but Resend rejects attempts to use this workaround + for ``from_email`` display names. + + These characters will cause problems in a *From* address display name: + + * Double quotes (``"``) and some other punctuation characters + can cause a "Resend API response 422" error complaining of an + "Invalid \`from\` field", or can result in a garbled *From* name + (missing segments, additional punctuation inserted) in the + resulting message. + * A question mark immediately followed by any alphabetic character + (e.g., ``?u``) will cause a "Resend API response 451" security error + complaining that "The email payload contain invalid characters". + (This behavior prevents use of standard :rfc:`2047` encoded words + in *From* display names---which is the workaround Anymail implements + for other address fields.) + + There may be other character combinations that also cause problems. + If you need to include punctuation in a *From* display name, be sure + to verify the results. (The issues were reported to Resend in October, 2023.) + +**Attachment filename determines content type** + Resend determines the content type of an attachment from its filename extension. + + If you try to send an attachment without a filename, Anymail will substitute + "attachment\ *.ext*" using an appropriate *.ext* for the content type. + + If you try to send an attachment whose content type doesn't match its filename + extension, Resend will change the content type to match the extension. + (E.g., the filename "data.txt" will always be sent as "text/plain", + even if you specified a "text/csv" content type.) + +**No inline images** + Resend's API does not provide a mechanism to send inline content + or to specify :mailheader:`Content-ID` for an attachment. + +**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 Resend + using custom email headers. 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.** + + Resend also offers a feature it calls "tags", which allows arbitrary key-value + data to be tracked with a sent message (similar Anymail's + :attr:`~anymail.message.AnymailMessage.metadata`). Resend's native tags + are *not* exposed to recipients, but they have significant restrictions + on character set and length (for both keys and values). + + If you want to use Resend's native tags with Anymail, you can send them + using :ref:`esp_extra `, and retrieve them in a status + tracking webhook using :ref:`esp_event `. (The linked + sections below include examples.) + +**No stored templates or batch sending** + Resend does not currently offer ESP stored templates or merge capabilities, + including Anymail's + :attr:`~anymail.message.AnymailMessage.merge_data`, + :attr:`~anymail.message.AnymailMessage.merge_global_data`, + :attr:`~anymail.message.AnymailMessage.merge_metadata`, and + :attr:`~anymail.message.AnymailMessage.template_id` features. + (Resend's current template feature is only supported in node.js, + using templates that are rendered in their API client.) + +**No click/open tracking overrides** + Resend does not support :attr:`~anymail.message.AnymailMessage.track_clicks` + or :attr:`~anymail.message.AnymailMessage.track_opens`. Its + tracking features can only be configured at the domain level + in Resend's control panel. + +**No delayed sending** + Resend does not support :attr:`~anymail.message.AnymailMessage.send_at`. + +**No envelope sender** + Resend does not support specifying the + :attr:`~anymail.message.AnymailMessage.envelope_sender`. + +**Status tracking does not identify recipient** + If you send a message with multiple recipients (to, cc, and/or bcc), + Resend's status webhooks do not identify which recipient applies + for an event. See the :ref:`note below `. + + +.. _resend-api-rate-limits: + +API rate limits +--------------- +Resend provides `rate limit headers`_ with each API call response. +To access them after a successful send, use (e.g.,) +``message.anymail_status.esp_response.headers["ratelimit-remaining"]``. + +If you exceed a rate limit, you'll get an :exc:`~anymail.exceptions.AnymailAPIError` +with ``error.status_code == 429``, and can determine how many seconds to wait +from ``error.response.headers["retry-after"]``. + +.. _rate limit headers: + https://resend.com/docs/api-reference/introduction#rate-limit + + +.. _resend-esp-extra: + +exp_extra support +----------------- + +Anymail's Resend backend will pass :attr:`~anymail.message.AnymailMessage.esp_extra` +values directly to Resend's `send-email API`_. Example: + + .. code-block:: python + + message = AnymailMessage(...) + message.esp_extra = { + # Use Resend's native "tags" feature + # (be careful about character set restrictions): + "tags": [ + {"name": "Co_Brand", "value": "Acme_Inc"}, + {"name": "Feature_Flag_1", "value": "test_22_a"}, + ], + } + + +.. _resend-webhooks: + +Status tracking webhooks +------------------------ + +Anymail's normalized :ref:`status tracking ` works +with Resend's webhooks. + +Resend implements webhook signing, using the :pypi:`svix` package +for signature validation (see :ref:`resend-installation` above). You have +three options for securing the status tracking webhook: + +* Use Resend's webhook signature validation, by setting + :setting:`RESEND_SIGNING_SECRET ` + (requires the svix package) +* Use Anymail's shared secret validation, by setting + :setting:`WEBHOOK_SECRET ` + (does not require svix) +* Use both + +Signature validation is recommended, unless you do not want to add +svix to your dependencies. + +To configure Anymail status tracking for Resend, +add a new webhook endpoint to your `Resend Webhooks settings`_: + +* 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/resend/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}/resend/tracking/` + +* For "Events to listen", select any or all events you want to track. + +* Click the "Add" button. + +Then, if you are using Resend's webhook signature validation (with svix), +add the webhook signing secret to your Anymail settings: + +* Still on the `Resend Webhooks settings`_ page, click into the + webhook endpoint URL you added above, + and copy the "signing secret" listed near the top of the page. + +* Add that to your settings.py ``ANYMAIL`` settings as + :setting:`RESEND_SIGNING_SECRET `: + + .. code-block:: python + + ANYMAIL = { + # ... + "RESEND_SIGNING_SECRET": "whsec_..." + } + +Resend will report these Anymail +:attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s: +sent, delivered, bounced, deferred, complained, opened, and clicked. + + +.. _resend-tracking-recipient: + +.. note:: + + **Multiple recipients not recommended with tracking** + + If you send a message with multiple recipients (to, cc, and/or bcc), + you will receive separate events (delivered, bounced, opened, etc.) + for *every* recipient. But Resend does not identify *which* recipient + applies for a particular event. + + The :attr:`event.recipient ` + will always be the first ``to`` email, but the event might actually have been + generated by some other recipient. + + To avoid confusion, it's best to send each message to exactly one ``to`` + address, and avoid using cc or bcc. + + +.. _resend-esp-event: + +The status tracking event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` +field will be the parsed Resend webhook payload. For example, if you provided +Resend's native "tags" via :ref:`esp_extra ` when sending, +you can retrieve them in your tracking signal receiver like this: + +.. code-block:: python + + @receiver(tracking) + def handle_tracking(sender, event, esp_name, **kwargs): + ... + resend_tags = event.esp_event.get("tags", {}) + # resend_tags will be a flattened dict (not + # the name/value list used when sending). E.g.: + # {"Co_Brand": "Acme_Inc", "Feature_Flag_1": "test_22_a"} + + +.. _Resend Webhooks settings: https://resend.com/webhooks + + +.. _resend-inbound: + +Inbound +------- + +Resend does not currently support inbound email. + + +.. _resend-troubleshooting: + +Troubleshooting +--------------- + +If Anymail's Resend integration isn't behaving like you expect, +Resend's dashboard includes diagnostic logs that can help +isolate the problem: + +* `Resend Logs page`_ lists every call received by Resend's API +* `Resend Emails page`_ shows every event related to email + sent through Resend +* `Resend Webhooks page`_ shows every attempt by Resend to call + your webhook (click into a webhook endpoint url to see + the logs for that endpoint) + +.. _Resend Emails page: https://resend.com/emails +.. _Resend Logs page: https://resend.com/logs +.. _Resend Webhooks page: https://resend.com/webhooks + +See Anymail's :ref:`troubleshooting` docs for additional suggestions. diff --git a/pyproject.toml b/pyproject.toml index 93ffe857..f0e11508 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,8 @@ authors = [ ] description = """\ Django email backends and webhooks for Amazon SES, Brevo (Sendinblue), - MailerSend, Mailgun, Mailjet, Mandrill, Postal, Postmark, SendGrid, and SparkPost\ + MailerSend, Mailgun, Mailjet, Mandrill, Postal, Postmark, Resend, + SendGrid, and SparkPost\ """ # readme: see tool.hatch.metadata.hooks.custom below keywords = [ @@ -22,6 +23,7 @@ keywords = [ "Amazon SES", "Brevo", "MailerSend", "Mailgun", "Mailjet", "Mandrill", "Postal", "Postmark", + "Resend", "SendGrid", "SendinBlue", "SparkPost", ] classifiers = [ @@ -66,6 +68,7 @@ mailgun = [] mailjet = [] mandrill = [] postmark = [] +resend = ["svix"] sendgrid = [] sendinblue = [] sparkpost = [] diff --git a/tests/test_resend_backend.py b/tests/test_resend_backend.py new file mode 100644 index 00000000..bd4f7659 --- /dev/null +++ b/tests/test_resend_backend.py @@ -0,0 +1,572 @@ +import json +from base64 import b64encode +from decimal import Decimal +from email.mime.base import MIMEBase +from email.mime.image import MIMEImage +from email.utils import formataddr + +from django.core import mail +from django.core.exceptions import ImproperlyConfigured +from django.test import SimpleTestCase, override_settings, tag + +from anymail.exceptions import ( + AnymailAPIError, + AnymailSerializationError, + AnymailUnsupportedFeature, +) +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("resend") +@override_settings( + EMAIL_BACKEND="anymail.backends.resend.EmailBackend", + ANYMAIL={ + "RESEND_API_KEY": "test_api_key", + }, +) +class ResendBackendMockAPITestCase(RequestsBackendMockAPITestCase): + DEFAULT_RAW_RESPONSE = b'{"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}' + + 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("resend") +class ResendBackendStandardEmailTests(ResendBackendMockAPITestCase): + """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("/emails") + 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 + + (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_display_name_workarounds(self): + # Resend's API has a bug that rejects a display-name in double quotes + # (per RFC 5322 section 3.4). Attempting to omit the quotes works, unless + # the display-name also contains a comma. Try to avoid the whole problem + # by using RFC 2047 encoded words for addresses Resend will parse incorrectly. + msg = mail.EmailMessage( + "Subject", + "Message", + formataddr(("Félix Företag, Inc.", "from@example.com")), + [ + '"To, comma" ', + "non–ascii ", + "=?utf-8?q?pre_encoded?= ", + ], + reply_to=['"Reply, comma" '], + ) + msg.send() + data = self.get_api_call_json() + self.assertEqual( + data["from"], + # for `from` field only, avoid RFC 2047 and retain non-ASCII characters: + '"Félix Företag, Inc." ', + ) + self.assertEqual( + data["to"], + [ + "=?utf-8?q?To=2C_comma?= ", + "=?utf-8?b?bm9u4oCTYXNjaWk=?= ", + "=?utf-8?q?pre_encoded?= ", + ], + ) + self.assertEqual( + data["reply_to"], ["=?utf-8?q?Reply=2C_comma?= "] + ) + + @override_settings(ANYMAIL_RESEND_WORKAROUND_DISPLAY_NAME_BUGS=False) + def test_undocumented_workaround_setting(self): + # Same test as above, but workarounds disabled + msg = mail.EmailMessage( + "Subject", + "Message", + '"Félix Företag" ', + [ + '"To, comma" ', + "non–ascii ", + "=?utf-8?q?pre_encoded?= ", + ], + reply_to=['"Reply, comma" '], + ) + msg.send() + data = self.get_api_call_json() + self.assertEqual( + data["from"], + # (Django uses base64 encoded word unless QP is shorter) + "=?utf-8?b?RsOpbGl4IEbDtnJldGFn?= ", + ) + self.assertEqual( + data["to"], + [ + '"To, comma" ', + "=?utf-8?b?bm9u4oCTYXNjaWk=?= ", + "=?utf-8?q?pre_encoded?= ", + ], + ) + self.assertEqual(data["reply_to"], ['"Reply, comma" ']) + + 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 "]) + self.assertEqual(data["reply_to"], ["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 (or they'll cause an "invalid literal" API error) + 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", "Other "], + ) + email.send() + data = self.get_api_call_json() + self.assertEqual( + data["reply_to"], ["reply@example.com", "Other "] + ) + + def test_attachments(self): + text_content = "* Item one\n* Item two\n* Item three" + self.message.attach( + filename="test.txt", content=text_content, mimetype="text/plain" + ) + + # Should guess mimetype if not provided... + png_content = b"PNG\xb4 pretend this is the contents of a png file" + self.message.attach(filename="test.png", content=png_content) + + # Should work with a MIMEBase object (also tests no filename)... + pdf_content = b"PDF\xb4 pretend this is valid pdf data" + mimeattachment = MIMEBase("application", "pdf") + mimeattachment.set_payload(pdf_content) + self.message.attach(mimeattachment) + + self.message.send() + data = self.get_api_call_json() + attachments = data["attachments"] + self.assertEqual(len(attachments), 3) + self.assertEqual(attachments[0]["filename"], "test.txt") + self.assertEqual( + decode_att(attachments[0]["content"]).decode("ascii"), text_content + ) + + self.assertEqual(attachments[1]["filename"], "test.png") + self.assertEqual(decode_att(attachments[1]["content"]), png_content) + + # unnamed attachment given default name with correct extension for content type + self.assertEqual(attachments[2]["filename"], "attachment.pdf") + self.assertEqual(decode_att(attachments[2]["content"]), pdf_content) + + def test_unicode_attachment_correctly_decoded(self): + self.message.attach( + "Une pièce jointe.html", "

\u2019

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

\u2019

".encode("utf-8")).decode( + "ascii" + ), + } + ], + ) + + def test_embedded_images(self): + # Resend's API doesn't have a way to specify content-id + image_filename = SAMPLE_IMAGE_FILENAME + image_path = sample_image_path(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") + + with self.assertRaisesMessage(AnymailUnsupportedFeature, "inline content-id"): + self.message.send() + + def test_attached_images(self): + image_filename = SAMPLE_IMAGE_FILENAME + image_path = sample_image_path(image_filename) + image_data = sample_image_content(image_filename) + + # option 1: attach as a file + self.message.attach_file(image_path) + + # option 2: construct the MIMEImage and attach it directly + image = MIMEImage(image_data) + self.message.attach(image) + + image_data_b64 = b64encode(image_data).decode("ascii") + + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["attachments"], + [ + { + "filename": image_filename, # the named one + "content": image_data_b64, + }, + { + # For unnamed attachments, Anymail constructs a default name + # based on the content_type: + "filename": "attachment.png", + "content": image_data_b64, + }, + ], + ) + + def test_multiple_html_alternatives(self): + # Multiple alternatives not allowed + self.message.attach_alternative("

First html is OK

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

But not second html

", "text/html") + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple html parts"): + self.message.send() + + def test_html_alternative(self): + # Only html alternatives allowed + self.message.attach_alternative("{'not': 'allowed'}", "application/json") + with self.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + def test_alternatives_fail_silently(self): + # Make sure fail_silently is respected + self.message.attach_alternative("{'not': 'allowed'}", "application/json") + sent = self.message.send(fail_silently=True) + self.assert_esp_not_called("API should not be called when send fails silently") + self.assertEqual(sent, 0) + + def test_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("reply_to", 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 = { + "statusCode": 400, + "message": "API key is invalid", + "name": "validation_error", + } + self.set_mock_response(status_code=400, json_data=failure_response) + with self.assertRaisesMessage( + AnymailAPIError, r"Resend API response 400" + ) as cm: + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) + self.assertIn("API key is invalid", str(cm.exception)) + + # Make sure fail_silently is respected + self.set_mock_response(status_code=422, json_data=failure_response) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) + self.assertEqual(sent, 0) + + +@tag("resend") +class ResendBackendAnymailFeatureTests(ResendBackendMockAPITestCase): + """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): + self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC + with self.assertRaisesMessage(AnymailUnsupportedFeature, "send_at"): + self.message.send() + + 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"}', + }, + ) + + def test_track_opens(self): + self.message.track_opens = True + with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"): + self.message.send() + + def test_track_clicks(self): + self.message.track_clicks = True + with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_clicks"): + self.message.send() + + def test_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("tags", data) + + def test_esp_extra(self): + self.message.esp_extra = { + "tags": [{"name": "my_tag", "value": "my_tag_value"}], + } + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["tags"], [{"name": "my_tag", "value": "my_tag_value"}]) + + # 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, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "queued" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ) + self.assertEqual( + msg.anymail_status.esp_response.content, self.DEFAULT_RAW_RESPONSE + ) + + # noinspection PyUnresolvedReferences + def test_send_failed_anymail_status(self): + """If the send fails, anymail_status should contain initial values""" + self.set_mock_response(status_code=500) + sent = self.message.send(fail_silently=True) + self.assertEqual(sent, 0) + self.assertIsNone(self.message.anymail_status.status) + self.assertIsNone(self.message.anymail_status.message_id) + self.assertEqual(self.message.anymail_status.recipients, {}) + self.assertIsNone(self.message.anymail_status.esp_response) + + # noinspection PyUnresolvedReferences + def test_send_unparsable_response(self): + """ + 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 Resend", str(err)) + # original message: + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") + + +@tag("resend") +class ResendBackendRecipientsRefusedTests(ResendBackendMockAPITestCase): + # Resend doesn't check email bounce or complaint lists at time of send -- + # it always just queues the message. You'll need to listen for the "rejected" + # and "failed" events to detect refused recipients. + pass + + +@tag("resend") +class ResendBackendSessionSharingTestCase( + SessionSharingTestCases, ResendBackendMockAPITestCase +): + """Requests session sharing tests""" + + pass # tests are defined in SessionSharingTestCases + + +@tag("resend") +@override_settings(EMAIL_BACKEND="anymail.backends.resend.EmailBackend") +class ResendBackendImproperlyConfiguredTests(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"\bRESEND_API_KEY\b") + self.assertRegex(errmsg, r"\bANYMAIL_RESEND_API_KEY\b") diff --git a/tests/test_resend_integration.py b/tests/test_resend_integration.py new file mode 100644 index 00000000..777993a2 --- /dev/null +++ b/tests/test_resend_integration.py @@ -0,0 +1,92 @@ +import os +import unittest +from email.utils import formataddr + +from django.test import SimpleTestCase, override_settings, tag + +from anymail.exceptions import AnymailAPIError +from anymail.message import AnymailMessage + +from .utils import AnymailTestMixin + +ANYMAIL_TEST_RESEND_API_KEY = os.getenv("ANYMAIL_TEST_RESEND_API_KEY") +ANYMAIL_TEST_RESEND_DOMAIN = os.getenv("ANYMAIL_TEST_RESEND_DOMAIN") + + +@tag("resend", "live") +@unittest.skipUnless( + ANYMAIL_TEST_RESEND_API_KEY and ANYMAIL_TEST_RESEND_DOMAIN, + "Set ANYMAIL_TEST_RESEND_API_KEY and ANYMAIL_TEST_RESEND_DOMAIN " + "environment variables to run Resend integration tests", +) +@override_settings( + ANYMAIL_RESEND_API_KEY=ANYMAIL_TEST_RESEND_API_KEY, + EMAIL_BACKEND="anymail.backends.resend.EmailBackend", +) +class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): + """Resend.com API integration tests + + Resend doesn't have sandbox so these tests run + against the **live** Resend API, using the + environment variable `ANYMAIL_TEST_RESEND_API_KEY` as the API key, + and `ANYMAIL_TEST_RESEND_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_RESEND_DOMAIN + self.message = AnymailMessage( + "Anymail Resend 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 Resend 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") # Resend always 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 Resend 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+to1@anymail.dev", '"Recipient 2, OK?" '], + cc=["test+cc1@anymail.dev", "Copy 2 "], + bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], + reply_to=['"Reply, with comma" ', "reply2@example.com"], + headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3}, + metadata={"meta1": "simple string", "meta2": 2}, + tags=["tag 1", "tag 2"], + ) + 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() + # Resend always queues: + self.assertEqual(message.anymail_status.status, {"queued"}) + self.assertGreater( + len(message.anymail_status.message_id), 0 + ) # non-empty string + + @override_settings(ANYMAIL_RESEND_API_KEY="Hey, that's not an API key!") + def test_invalid_api_key(self): + with self.assertRaisesMessage(AnymailAPIError, "API key is invalid"): + self.message.send() diff --git a/tests/test_resend_webhooks.py b/tests/test_resend_webhooks.py new file mode 100644 index 00000000..77e17e16 --- /dev/null +++ b/tests/test_resend_webhooks.py @@ -0,0 +1,416 @@ +import base64 +import json +from datetime import datetime, timezone +from unittest import skipIf, skipUnless +from unittest.mock import ANY + +from django.test import override_settings, tag + +from anymail.exceptions import AnymailImproperlyInstalled, AnymailInsecureWebhookWarning +from anymail.signals import AnymailTrackingEvent +from anymail.webhooks.resend import ResendTrackingWebhookView + +from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase + +# These tests are run both with and without 'svix' installed. +try: + from svix import Webhook +except ImportError: + SVIX_INSTALLED = False + Webhook = None +else: + SVIX_INSTALLED = True + + +def svix_secret(secret): + return f"whsec_{base64.b64encode(secret.encode('ascii')).decode('ascii')}" + + +TEST_SIGNING_SECRET = svix_secret("TEST_SIGNING_SECRET") if SVIX_INSTALLED else None +TEST_WEBHOOK_MESSAGE_ID = "msg_abcdefghijklmnopqrst12345" + + +class ResendWebhookTestCase(WebhookTestCase): + def client_post_signed(self, url, json_data, svix_id=None, secret=None): + """Return self.client.post(url, serialized json_data) signed with secret""" + svix_id = svix_id or TEST_WEBHOOK_MESSAGE_ID + secret = secret or TEST_SIGNING_SECRET + data = json.dumps(json_data) + headers = { + "svix-id": svix_id, + } + + if SVIX_INSTALLED: + timestamp = datetime.now(tz=timezone.utc) + signature = Webhook(secret).sign( + msg_id=svix_id, timestamp=timestamp, data=data + ) + headers.update( + { + "svix-timestamp": timestamp.timestamp(), + "svix-signature": signature, + } + ) + + return self.client.post( + url, + content_type="application/json", + data=data.encode("utf-8"), + # Django 4.2+ test Client allows headers=headers; + # before that, must convert to HTTP_ args: + **{ + f"HTTP_{header.upper().replace('-', '_')}": value + for header, value in headers.items() + }, + ) + + +@tag("resend") +@override_settings(ANYMAIL={}) # clear WEBHOOK_SECRET from base class +class ResendWebhookSettingsTestCase(ResendWebhookTestCase): + @skipIf(SVIX_INSTALLED, "test covers behavior when 'svix' package missing") + @override_settings(ANYMAIL_RESEND_SIGNING_SECRET=svix_secret("settings secret")) + def test_secret_requires_svix_installed(self): + """If webhook secret is specified, error if svix not available to verify""" + with self.assertRaisesMessage(AnymailImproperlyInstalled, "svix"): + self.client_post_signed("/anymail/resend/tracking/", {"type": "email.sent"}) + + # Test with and without SVIX_INSTALLED + def test_basic_auth_required_without_secret(self): + with self.assertWarns(AnymailInsecureWebhookWarning): + self.client_post_signed("/anymail/resend/tracking/", {"type": "email.sent"}) + + # Test with and without SVIX_INSTALLED + @override_settings(ANYMAIL={"WEBHOOK_SECRET": "username:password"}) + def test_signing_secret_optional_with_basic_auth(self): + """Secret verification is optional if using basic auth""" + response = self.client_post_signed( + "/anymail/resend/tracking/", {"type": "email.sent"} + ) + self.assertEqual(response.status_code, 200) + + @skipUnless(SVIX_INSTALLED, "secret verification requires 'svix' package") + @override_settings(ANYMAIL_RESEND_SIGNING_SECRET=svix_secret("settings secret")) + def test_signing_secret_view_params(self): + """Webhook signing secret can be provided as a view param""" + view_secret = svix_secret("view-level secret") + view = ResendTrackingWebhookView.as_view(signing_secret=view_secret) + view_instance = view.view_class(**view.view_initkwargs) + self.assertEqual(view_instance.signing_secret, view_secret) + + +@tag("resend") +@override_settings(ANYMAIL_RESEND_SIGNING_SECRET=TEST_SIGNING_SECRET) +class ResendWebhookSecurityTestCase(ResendWebhookTestCase, WebhookBasicAuthTestCase): + should_warn_if_no_auth = TEST_SIGNING_SECRET is None + + def call_webhook(self): + return self.client_post_signed( + "/anymail/resend/tracking/", + {"type": "email.sent"}, + secret=TEST_SIGNING_SECRET, + ) + + # Additional tests are in WebhookBasicAuthTestCase + + @skipUnless(SVIX_INSTALLED, "signature verification requires 'svix' package") + def test_verifies_correct_signature(self): + response = self.client_post_signed( + "/anymail/resend/tracking/", + {"type": "email.sent"}, + secret=TEST_SIGNING_SECRET, + ) + self.assertEqual(response.status_code, 200) + + @skipUnless(SVIX_INSTALLED, "signature verification requires 'svix' package") + def test_verifies_missing_signature(self): + response = self.client.post( + "/anymail/resend/tracking/", + content_type="application/json", + data={"type": "email.sent"}, + ) + self.assertEqual(response.status_code, 400) + + @skipUnless(SVIX_INSTALLED, "signature verification requires 'svix' package") + def test_verifies_bad_signature(self): + # This also verifies that the error log references the correct setting to check. + with self.assertLogs() as logs: + response = self.client_post_signed( + "/anymail/resend/tracking/", + {"type": "email.sent"}, + secret=svix_secret("wrong signing key"), + ) + # SuspiciousOperation causes 400 response (even in test client): + self.assertEqual(response.status_code, 400) + self.assertIn("check Anymail RESEND_SIGNING_SECRET", logs.output[0]) + + +@tag("resend") +@override_settings(ANYMAIL_RESEND_SIGNING_SECRET=TEST_SIGNING_SECRET) +class ResendTestCase(ResendWebhookTestCase): + def test_sent_event(self): + raw_event = { + "created_at": "2023-09-28T17:19:43.736Z", + "data": { + "created_at": "2023-09-28T17:19:43.982Z", + "email_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "from": "Sender ", + "headers": [ + {"name": "Reply-To", "value": "reply@example.com"}, + {"name": "X-Tags", "value": '["tag1", "Tag 2"]'}, + { + "name": "X-Metadata", + "value": '{"cohort": "2018-08-B", "user_id": 123456}', + }, + {"name": "Cc", "value": "cc1@example.org, Cc 2 "}, + ], + "subject": "Sending test", + "tags": {"tag1": "Tag_1_value", "tag2": "Tag_2_value"}, + "to": ["Recipient ", "to2@example.org"], + }, + "type": "email.sent", + } + response = self.client_post_signed( + "/anymail/resend/tracking/", + raw_event, + svix_id="msg_2W2D3qXLS5fOaPja1GDg7rF2CwB", + ) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=ResendTrackingWebhookView, + event=ANY, + esp_name="Resend", + ) + event = kwargs["event"] + self.assertIsInstance(event, AnymailTrackingEvent) + self.assertEqual(event.event_type, "sent") + # event.timestamp comes from root-level created_at: + self.assertEqual( + event.timestamp, + # "2023-09-28T17:19:43.736Z" + datetime(2023, 9, 28, 17, 19, 43, microsecond=736000, tzinfo=timezone.utc), + ) + # event.message_id matches the message.anymail_status.message_id when the + # message was sent. It comes from data.email_id: + self.assertEqual(event.message_id, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + # event.event_id is unique for each event, and comes from svix-id header: + self.assertEqual(event.event_id, "msg_2W2D3qXLS5fOaPja1GDg7rF2CwB") + # event.recipient is always the first "to" addr: + self.assertEqual(event.recipient, "to@example.org") + self.assertEqual(event.tags, ["tag1", "Tag 2"]) + self.assertEqual(event.metadata, {"cohort": "2018-08-B", "user_id": 123456}) + self.assertEqual(event.esp_event, raw_event) + + # You can retrieve Resend native tags (which are different from Anymail tags) + # from esp_event: + resend_tags = event.esp_event["data"].get("tags", {}) + self.assertEqual(resend_tags, {"tag1": "Tag_1_value", "tag2": "Tag_2_value"}) + + def test_delivered_event(self): + raw_event = { + "created_at": "2023-09-28T17:19:44.823Z", + "data": { + "created_at": "2023-09-28T17:19:43.982Z", + "email_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "from": "Sender ", + "subject": "Sending test", + "to": ["to@example.org"], + }, + "type": "email.delivered", + } + response = self.client_post_signed("/anymail/resend/tracking/", raw_event) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=ResendTrackingWebhookView, + event=ANY, + esp_name="Resend", + ) + event = kwargs["event"] + self.assertIsInstance(event, AnymailTrackingEvent) + self.assertEqual(event.event_type, "delivered") + self.assertEqual(event.recipient, "to@example.org") + self.assertEqual(event.tags, []) + self.assertEqual(event.metadata, {}) + + def test_hard_bounced_event(self): + raw_event = { + "created_at": "2023-10-02T18:11:26.101Z", + "data": { + "bounce": { + "message": ( + "The recipient's email provider sent a hard bounce message, but" + " didn't specify the reason for the hard bounce. We recommend" + " removing the recipient's email address from your mailing list." + " Sending messages to addresses that produce hard bounces can" + " have a negative impact on your reputation as a sender." + ) + }, + "created_at": "2023-10-02T18:11:25.729Z", + "email_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "from": "Sender ", + "subject": "Sending test", + "to": ["bounced@resend.dev"], + }, + "type": "email.bounced", + } + response = self.client_post_signed("/anymail/resend/tracking/", raw_event) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=ResendTrackingWebhookView, + event=ANY, + esp_name="Resend", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "bounced") + self.assertEqual(event.reject_reason, "bounced") + self.assertRegex( + event.description, + r"^The recipient's email provider sent a hard bounce message.*", + ) + self.assertIsNone(event.mta_response) # raw MTA info not provided + + def test_suppressed_event(self): + raw_event = { + "created_at": "2023-10-01T20:01:01.598Z", + "data": { + "bounce": { + "message": ( + "Resend has suppressed sending to this address because it is" + " on the account-level suppression list. This does not count" + " toward your bounce rate metric" + ) + }, + "created_at": "2023-10-01T20:01:01.339Z", + "email_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "from": "Sender ", + "subject": "Sending test", + "to": ["blocked@example.org"], + }, + "type": "email.bounced", + } + response = self.client_post_signed("/anymail/resend/tracking/", raw_event) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=ResendTrackingWebhookView, + event=ANY, + esp_name="Resend", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "bounced") + self.assertEqual(event.reject_reason, "blocked") + self.assertRegex( + event.description, r"^Resend has suppressed sending to this address.*" + ) + self.assertIsNone(event.mta_response) # raw MTA info not provided + + def test_delivery_delayed_event(self): + # Haven't been able to trigger a real-world version of this event + # (even with SMTP reply 450, status 4.0.0 "temporary failure"). + # This is the sample payload from Resend's docs, but correcting the type + # from "email.delivered_delayed" to "email.delivery_delayed" to match + # docs and configuration UI. + raw_event = { + "type": "email.delivery_delayed", # "email.delivered_delayed", + "created_at": "2023-02-22T23:41:12.126Z", + "data": { + "created_at": "2023-02-22T23:41:11.894719+00:00", + "email_id": "56761188-7520-42d8-8898-ff6fc54ce618", + "from": "Acme ", + "to": ["delivered@resend.dev"], + "subject": "Sending this example", + }, + } + response = self.client_post_signed("/anymail/resend/tracking/", raw_event) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=ResendTrackingWebhookView, + event=ANY, + esp_name="Resend", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "deferred") + self.assertIsNone(event.reject_reason) + self.assertIsNone(event.description) + self.assertIsNone(event.mta_response) # raw MTA info not provided + + def test_complained_event(self): + raw_event = { + "created_at": "2023-10-02T18:10:03.690Z", + "data": { + "created_at": "2023-10-02T18:10:03.241Z", + "email_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "from": "Sender ", + "subject": "Sending test", + "to": ["complained@resend.dev"], + }, + "type": "email.complained", + } + response = self.client_post_signed("/anymail/resend/tracking/", raw_event) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=ResendTrackingWebhookView, + event=ANY, + esp_name="Resend", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "complained") + + def test_opened_event(self): + raw_event = { + "created_at": "2023-09-28T17:20:38.990Z", + "data": { + "created_at": "2023-09-28T17:19:43.982Z", + "email_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "from": "Sender ", + "subject": "Sending test", + "to": ["to@example.org"], + }, + "type": "email.opened", + } + response = self.client_post_signed("/anymail/resend/tracking/", raw_event) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=ResendTrackingWebhookView, + event=ANY, + esp_name="Resend", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "opened") + + def test_clicked_event(self): + raw_event = { + "created_at": "2023-09-28T17:21:35.257Z", + "data": { + "click": { + "ipAddress": "192.168.1.101", + "link": "https://example.com/test", + "timestamp": "2023-09-28T17:21:35.257Z", + "userAgent": "Mozilla/5.0 ...", + }, + "created_at": "2023-09-28T17:19:43.982Z", + "email_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "from": "Sender ", + "subject": "Sending test", + "to": ["to@example.org"], + }, + "type": "email.clicked", + } + response = self.client_post_signed("/anymail/resend/tracking/", raw_event) + self.assertEqual(response.status_code, 200) + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=ResendTrackingWebhookView, + event=ANY, + esp_name="Resend", + ) + event = kwargs["event"] + self.assertEqual(event.event_type, "clicked") + self.assertEqual(event.click_url, "https://example.com/test") + self.assertEqual(event.user_agent, "Mozilla/5.0 ...") diff --git a/tox.ini b/tox.ini index 0eb6ba1c..aa75c3b9 100644 --- a/tox.ini +++ b/tox.ini @@ -27,7 +27,7 @@ envlist = # Django 5.1 dev: Python 3.10+ djangoDev-py{310,311,312}-all # ... then partial installation (limit extras): - django42-py311-{none,amazon_ses,postal} + django42-py311-{none,amazon_ses,postal,resend} # tox requires isolated builds to use pyproject.toml build config: isolated_build = True @@ -51,8 +51,10 @@ extras = # Careful: tox factors (on the left) use underscore; extra names use hyphen.) all,amazon_ses: amazon-ses all,postal: postal + all,resend: resend setenv = # tell runtests.py to limit some test tags based on extras factor + # (resend should work with or without its extras, so it isn't in `none`) none: ANYMAIL_SKIP_TESTS=amazon_ses,postal amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses mailersend: ANYMAIL_ONLY_TEST=mailersend @@ -61,6 +63,7 @@ setenv = mandrill: ANYMAIL_ONLY_TEST=mandrill postal: ANYMAIL_ONLY_TEST=postal postmark: ANYMAIL_ONLY_TEST=postmark + resend: ANYMAIL_ONLY_TEST=resend sendgrid: ANYMAIL_ONLY_TEST=sendgrid sendinblue: ANYMAIL_ONLY_TEST=sendinblue sparkpost: ANYMAIL_ONLY_TEST=sparkpost