mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
Resend: new ESP (#341)
Add support for Resend.com backend and webhooks. Closes #341
This commit is contained in:
3
.github/workflows/integration-test.yml
vendored
3
.github/workflows/integration-test.yml
vendored
@@ -46,6 +46,7 @@ jobs:
|
|||||||
- { tox: django41-py310-mandrill, python: "3.10" }
|
- { tox: django41-py310-mandrill, python: "3.10" }
|
||||||
- { tox: django41-py310-postal, python: "3.10" }
|
- { tox: django41-py310-postal, python: "3.10" }
|
||||||
- { tox: django41-py310-postmark, 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-sendgrid, python: "3.10" }
|
||||||
- { tox: django41-py310-sendinblue, python: "3.10" }
|
- { tox: django41-py310-sendinblue, python: "3.10" }
|
||||||
- { tox: django41-py310-sparkpost, 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_DOMAIN: ${{ secrets.ANYMAIL_TEST_POSTMARK_DOMAIN }}
|
||||||
ANYMAIL_TEST_POSTMARK_SERVER_TOKEN: ${{ secrets.ANYMAIL_TEST_POSTMARK_SERVER_TOKEN }}
|
ANYMAIL_TEST_POSTMARK_SERVER_TOKEN: ${{ secrets.ANYMAIL_TEST_POSTMARK_SERVER_TOKEN }}
|
||||||
ANYMAIL_TEST_POSTMARK_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_POSTMARK_TEMPLATE_ID }}
|
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_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDGRID_API_KEY }}
|
||||||
ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }}
|
ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }}
|
||||||
ANYMAIL_TEST_SENDGRID_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_SENDGRID_TEMPLATE_ID }}
|
ANYMAIL_TEST_SENDGRID_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_SENDGRID_TEMPLATE_ID }}
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ vNext
|
|||||||
|
|
||||||
*unreleased changes*
|
*unreleased changes*
|
||||||
|
|
||||||
|
Features
|
||||||
|
~~~~~~~~
|
||||||
|
|
||||||
|
* **Resend**: Add support for this ESP
|
||||||
|
(`docs <https://anymail.dev/en/latest/esps/resend/>`__).
|
||||||
|
|
||||||
Fixes
|
Fixes
|
||||||
~~~~~
|
~~~~~
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ Anymail currently supports these ESPs:
|
|||||||
* **Mandrill** (MailChimp transactional)
|
* **Mandrill** (MailChimp transactional)
|
||||||
* **Postal** (self-hosted ESP)
|
* **Postal** (self-hosted ESP)
|
||||||
* **Postmark**
|
* **Postmark**
|
||||||
|
* **Resend**
|
||||||
* **SendGrid**
|
* **SendGrid**
|
||||||
* **SparkPost**
|
* **SparkPost**
|
||||||
|
|
||||||
|
|||||||
231
anymail/backends/resend.py
Normal file
231
anymail/backends/resend.py
Normal file
@@ -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)
|
||||||
@@ -13,6 +13,7 @@ from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookV
|
|||||||
from .webhooks.mandrill import MandrillCombinedWebhookView
|
from .webhooks.mandrill import MandrillCombinedWebhookView
|
||||||
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
|
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
|
||||||
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
|
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
|
||||||
|
from .webhooks.resend import ResendTrackingWebhookView
|
||||||
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
|
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
|
||||||
from .webhooks.sendinblue import (
|
from .webhooks.sendinblue import (
|
||||||
SendinBlueInboundWebhookView,
|
SendinBlueInboundWebhookView,
|
||||||
@@ -104,6 +105,11 @@ urlpatterns = [
|
|||||||
PostmarkTrackingWebhookView.as_view(),
|
PostmarkTrackingWebhookView.as_view(),
|
||||||
name="postmark_tracking_webhook",
|
name="postmark_tracking_webhook",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"resend/tracking/",
|
||||||
|
ResendTrackingWebhookView.as_view(),
|
||||||
|
name="resend_tracking_webhook",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"sendgrid/tracking/",
|
"sendgrid/tracking/",
|
||||||
SendGridTrackingWebhookView.as_view(),
|
SendGridTrackingWebhookView.as_view(),
|
||||||
|
|||||||
195
anymail/webhooks/resend.py
Normal file
195
anymail/webhooks/resend.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -20,6 +20,7 @@ and notes about any quirks or limitations:
|
|||||||
mandrill
|
mandrill
|
||||||
postal
|
postal
|
||||||
postmark
|
postmark
|
||||||
|
resend
|
||||||
sendgrid
|
sendgrid
|
||||||
sparkpost
|
sparkpost
|
||||||
|
|
||||||
@@ -28,38 +29,39 @@ Anymail feature support
|
|||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
The table below summarizes the Anymail features supported for each ESP.
|
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
|
.. currentmodule:: anymail.message
|
||||||
|
|
||||||
.. rst-class:: sticky-left
|
.. 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 <anymail-send-options>`
|
.. rubric:: :ref:`Anymail send options <anymail-send-options>`
|
||||||
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
:attr:`~AnymailMessage.envelope_sender` Yes No No Domain only Yes Domain only Yes No No 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
|
: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 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 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 Max 1 tag
|
: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 Yes Yes
|
: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 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 Yes Yes
|
:ref:`amp-email` Yes No No Yes No No No No No Yes Yes
|
||||||
|
|
||||||
.. rubric:: :ref:`templates-and-merge`
|
.. rubric:: :ref:`templates-and-merge`
|
||||||
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
:attr:`~AnymailMessage.template_id` Yes Yes Yes Yes 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 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 Yes Yes
|
:attr:`~AnymailMessage.merge_global_data` Yes Yes (emulated) (emulated) Yes Yes No Yes No Yes Yes
|
||||||
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`
|
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`
|
||||||
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
:attr:`~AnymailMessage.anymail_status` 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
|
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes
|
||||||
.. rubric:: :ref:`Inbound handling <inbound>`
|
.. rubric:: :ref:`Inbound handling <inbound>`
|
||||||
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
|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
|
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`
|
.. |Mandrill| replace:: :ref:`mandrill-backend`
|
||||||
.. |Postal| replace:: :ref:`postal-backend`
|
.. |Postal| replace:: :ref:`postal-backend`
|
||||||
.. |Postmark| replace:: :ref:`postmark-backend`
|
.. |Postmark| replace:: :ref:`postmark-backend`
|
||||||
|
.. |Resend| replace:: :ref:`resend-backend`
|
||||||
.. |SendGrid| replace:: :ref:`sendgrid-backend`
|
.. |SendGrid| replace:: :ref:`sendgrid-backend`
|
||||||
.. |SparkPost| replace:: :ref:`sparkpost-backend`
|
.. |SparkPost| replace:: :ref:`sparkpost-backend`
|
||||||
.. |AnymailTrackingEvent| replace:: :class:`~anymail.signals.AnymailTrackingEvent`
|
.. |AnymailTrackingEvent| replace:: :class:`~anymail.signals.AnymailTrackingEvent`
|
||||||
|
|||||||
377
docs/esps/resend.rst
Normal file
377
docs/esps/resend.rst
Normal file
@@ -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 <event-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 <ANYMAIL_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 <resend-esp-extra>`, and retrieve them in a status
|
||||||
|
tracking webhook using :ref:`esp_event <resend-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-tracking-recipient>`.
|
||||||
|
|
||||||
|
|
||||||
|
.. _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 <event-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 <ANYMAIL_RESEND_SIGNING_SECRET>`
|
||||||
|
(requires the svix package)
|
||||||
|
* Use Anymail's shared secret validation, by setting
|
||||||
|
:setting:`WEBHOOK_SECRET <ANYMAIL_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 <ANYMAIL_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 <ANYMAIL_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 <anymail.signals.AnymailTrackingEvent.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 <resend-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.
|
||||||
@@ -13,7 +13,8 @@ authors = [
|
|||||||
]
|
]
|
||||||
description = """\
|
description = """\
|
||||||
Django email backends and webhooks for Amazon SES, Brevo (Sendinblue),
|
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
|
# readme: see tool.hatch.metadata.hooks.custom below
|
||||||
keywords = [
|
keywords = [
|
||||||
@@ -22,6 +23,7 @@ keywords = [
|
|||||||
"Amazon SES", "Brevo",
|
"Amazon SES", "Brevo",
|
||||||
"MailerSend", "Mailgun", "Mailjet", "Mandrill",
|
"MailerSend", "Mailgun", "Mailjet", "Mandrill",
|
||||||
"Postal", "Postmark",
|
"Postal", "Postmark",
|
||||||
|
"Resend",
|
||||||
"SendGrid", "SendinBlue", "SparkPost",
|
"SendGrid", "SendinBlue", "SparkPost",
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -66,6 +68,7 @@ mailgun = []
|
|||||||
mailjet = []
|
mailjet = []
|
||||||
mandrill = []
|
mandrill = []
|
||||||
postmark = []
|
postmark = []
|
||||||
|
resend = ["svix"]
|
||||||
sendgrid = []
|
sendgrid = []
|
||||||
sendinblue = []
|
sendinblue = []
|
||||||
sparkpost = []
|
sparkpost = []
|
||||||
|
|||||||
572
tests/test_resend_backend.py
Normal file
572
tests/test_resend_backend.py
Normal file
@@ -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 <from@example.com>",
|
||||||
|
["Recipient #1 <to1@example.com>", "to2@example.com"],
|
||||||
|
cc=["Carbon Copy <cc1@example.com>", "cc2@example.com"],
|
||||||
|
bcc=["Blind Copy <bcc1@example.com>", "bcc2@example.com"],
|
||||||
|
)
|
||||||
|
msg.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(data["from"], "From Name <from@example.com>")
|
||||||
|
self.assertEqual(
|
||||||
|
data["to"], ["Recipient #1 <to1@example.com>", "to2@example.com"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
data["cc"], ["Carbon Copy <cc1@example.com>", "cc2@example.com"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
data["bcc"], ["Blind Copy <bcc1@example.com>", "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" <to1@example.com>',
|
||||||
|
"non–ascii <to2@example.com>",
|
||||||
|
"=?utf-8?q?pre_encoded?= <to3@example.com>",
|
||||||
|
],
|
||||||
|
reply_to=['"Reply, comma" <reply1@example.com>'],
|
||||||
|
)
|
||||||
|
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." <from@example.com>',
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
data["to"],
|
||||||
|
[
|
||||||
|
"=?utf-8?q?To=2C_comma?= <to1@example.com>",
|
||||||
|
"=?utf-8?b?bm9u4oCTYXNjaWk=?= <to2@example.com>",
|
||||||
|
"=?utf-8?q?pre_encoded?= <to3@example.com>",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
data["reply_to"], ["=?utf-8?q?Reply=2C_comma?= <reply1@example.com>"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@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" <from@example.com>',
|
||||||
|
[
|
||||||
|
'"To, comma" <to1@example.com>',
|
||||||
|
"non–ascii <to2@example.com>",
|
||||||
|
"=?utf-8?q?pre_encoded?= <to3@example.com>",
|
||||||
|
],
|
||||||
|
reply_to=['"Reply, comma" <reply1@example.com>'],
|
||||||
|
)
|
||||||
|
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?= <from@example.com>",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
data["to"],
|
||||||
|
[
|
||||||
|
'"To, comma" <to1@example.com>',
|
||||||
|
"=?utf-8?b?bm9u4oCTYXNjaWk=?= <to2@example.com>",
|
||||||
|
"=?utf-8?q?pre_encoded?= <to3@example.com>",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(data["reply_to"], ['"Reply, comma" <reply1@example.com>'])
|
||||||
|
|
||||||
|
def test_email_message(self):
|
||||||
|
email = mail.EmailMessage(
|
||||||
|
"Subject",
|
||||||
|
"Body goes here",
|
||||||
|
"from@example.com",
|
||||||
|
["to1@example.com", "Also To <to2@example.com>"],
|
||||||
|
bcc=["bcc1@example.com", "Also BCC <bcc2@example.com>"],
|
||||||
|
cc=["cc1@example.com", "Also CC <cc2@example.com>"],
|
||||||
|
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 <to2@example.com>"])
|
||||||
|
self.assertEqual(
|
||||||
|
data["bcc"], ["bcc1@example.com", "Also BCC <bcc2@example.com>"]
|
||||||
|
)
|
||||||
|
self.assertEqual(data["cc"], ["cc1@example.com", "Also CC <cc2@example.com>"])
|
||||||
|
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 = "<p>This is an <strong>important</strong> message.</p>"
|
||||||
|
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 = "<p>This is an <strong>important</strong> message.</p>"
|
||||||
|
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 <reply2@example.com>"],
|
||||||
|
)
|
||||||
|
email.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(
|
||||||
|
data["reply_to"], ["reply@example.com", "Other <reply2@example.com>"]
|
||||||
|
)
|
||||||
|
|
||||||
|
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", "<p>\u2019</p>", mimetype="text/html"
|
||||||
|
)
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(
|
||||||
|
data["attachments"],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"filename": "Une pièce jointe.html",
|
||||||
|
"content": b64encode("<p>\u2019</p>".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 = (
|
||||||
|
'<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % 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("<p>First html is OK</p>", "text/html")
|
||||||
|
self.message.attach_alternative("<p>But not second html</p>", "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 <to1@example.com>"],
|
||||||
|
)
|
||||||
|
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")
|
||||||
92
tests/test_resend_integration.py
Normal file
92
tests/test_resend_integration.py
Normal file
@@ -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("<p>HTML content</p>", "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?" <test+to2@anymail.dev>'],
|
||||||
|
cc=["test+cc1@anymail.dev", "Copy 2 <test+cc2@anymail.dev>"],
|
||||||
|
bcc=["test+bcc1@anymail.dev", "Blind Copy 2 <test+bcc2@anymail.dev>"],
|
||||||
|
reply_to=['"Reply, with comma" <reply@example.com>', "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("<p>HTML content</p>", "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()
|
||||||
416
tests/test_resend_webhooks.py
Normal file
416
tests/test_resend_webhooks.py
Normal file
@@ -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 <from@example.com>",
|
||||||
|
"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 <cc2@example.org>"},
|
||||||
|
],
|
||||||
|
"subject": "Sending test",
|
||||||
|
"tags": {"tag1": "Tag_1_value", "tag2": "Tag_2_value"},
|
||||||
|
"to": ["Recipient <to@example.org>", "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 <from@example.com>",
|
||||||
|
"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 <from@example.com>",
|
||||||
|
"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 <from@example.com>",
|
||||||
|
"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 <onboarding@resend.dev>",
|
||||||
|
"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 <from@example.com>",
|
||||||
|
"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 <from@example.com>",
|
||||||
|
"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 <from@example.com>",
|
||||||
|
"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 ...")
|
||||||
5
tox.ini
5
tox.ini
@@ -27,7 +27,7 @@ envlist =
|
|||||||
# Django 5.1 dev: Python 3.10+
|
# Django 5.1 dev: Python 3.10+
|
||||||
djangoDev-py{310,311,312}-all
|
djangoDev-py{310,311,312}-all
|
||||||
# ... then partial installation (limit extras):
|
# ... 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:
|
# tox requires isolated builds to use pyproject.toml build config:
|
||||||
isolated_build = True
|
isolated_build = True
|
||||||
|
|
||||||
@@ -51,8 +51,10 @@ extras =
|
|||||||
# Careful: tox factors (on the left) use underscore; extra names use hyphen.)
|
# Careful: tox factors (on the left) use underscore; extra names use hyphen.)
|
||||||
all,amazon_ses: amazon-ses
|
all,amazon_ses: amazon-ses
|
||||||
all,postal: postal
|
all,postal: postal
|
||||||
|
all,resend: resend
|
||||||
setenv =
|
setenv =
|
||||||
# tell runtests.py to limit some test tags based on extras factor
|
# 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
|
none: ANYMAIL_SKIP_TESTS=amazon_ses,postal
|
||||||
amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses
|
amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses
|
||||||
mailersend: ANYMAIL_ONLY_TEST=mailersend
|
mailersend: ANYMAIL_ONLY_TEST=mailersend
|
||||||
@@ -61,6 +63,7 @@ setenv =
|
|||||||
mandrill: ANYMAIL_ONLY_TEST=mandrill
|
mandrill: ANYMAIL_ONLY_TEST=mandrill
|
||||||
postal: ANYMAIL_ONLY_TEST=postal
|
postal: ANYMAIL_ONLY_TEST=postal
|
||||||
postmark: ANYMAIL_ONLY_TEST=postmark
|
postmark: ANYMAIL_ONLY_TEST=postmark
|
||||||
|
resend: ANYMAIL_ONLY_TEST=resend
|
||||||
sendgrid: ANYMAIL_ONLY_TEST=sendgrid
|
sendgrid: ANYMAIL_ONLY_TEST=sendgrid
|
||||||
sendinblue: ANYMAIL_ONLY_TEST=sendinblue
|
sendinblue: ANYMAIL_ONLY_TEST=sendinblue
|
||||||
sparkpost: ANYMAIL_ONLY_TEST=sparkpost
|
sparkpost: ANYMAIL_ONLY_TEST=sparkpost
|
||||||
|
|||||||
Reference in New Issue
Block a user