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:
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.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
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,
|
||||
)
|
||||
Reference in New Issue
Block a user