Resend: new ESP (#341)

Add support for Resend.com backend and webhooks.

Closes #341
This commit is contained in:
Mike Edmunds
2023-10-25 12:23:57 -07:00
committed by GitHub
parent 823a161927
commit b5ef492466
13 changed files with 1932 additions and 24 deletions

231
anymail/backends/resend.py Normal file
View 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)

View File

@@ -13,6 +13,7 @@ from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookV
from .webhooks.mandrill import MandrillCombinedWebhookView
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
from .webhooks.resend import ResendTrackingWebhookView
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
from .webhooks.sendinblue import (
SendinBlueInboundWebhookView,
@@ -104,6 +105,11 @@ urlpatterns = [
PostmarkTrackingWebhookView.as_view(),
name="postmark_tracking_webhook",
),
path(
"resend/tracking/",
ResendTrackingWebhookView.as_view(),
name="resend_tracking_webhook",
),
path(
"sendgrid/tracking/",
SendGridTrackingWebhookView.as_view(),

195
anymail/webhooks/resend.py Normal file
View 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,
)