Skip to content

Commit

Permalink
Resend: new ESP (#341)
Browse files Browse the repository at this point in the history
Add support for Resend.com backend and webhooks.

Closes #341
  • Loading branch information
medmunds authored Oct 25, 2023
1 parent 823a161 commit b5ef492
Show file tree
Hide file tree
Showing 13 changed files with 1,932 additions and 24 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
- { tox: django41-py310-mandrill, python: "3.10" }
- { tox: django41-py310-postal, python: "3.10" }
- { tox: django41-py310-postmark, python: "3.10" }
- { tox: django41-py310-resend, python: "3.10" }
- { tox: django41-py310-sendgrid, python: "3.10" }
- { tox: django41-py310-sendinblue, python: "3.10" }
- { tox: django41-py310-sparkpost, python: "3.10" }
Expand Down Expand Up @@ -87,6 +88,8 @@ jobs:
ANYMAIL_TEST_POSTMARK_DOMAIN: ${{ secrets.ANYMAIL_TEST_POSTMARK_DOMAIN }}
ANYMAIL_TEST_POSTMARK_SERVER_TOKEN: ${{ secrets.ANYMAIL_TEST_POSTMARK_SERVER_TOKEN }}
ANYMAIL_TEST_POSTMARK_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_POSTMARK_TEMPLATE_ID }}
ANYMAIL_TEST_RESEND_API_KEY: ${{ secrets.ANYMAIL_TEST_RESEND_API_KEY }}
ANYMAIL_TEST_RESEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_RESEND_DOMAIN }}
ANYMAIL_TEST_SENDGRID_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDGRID_API_KEY }}
ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }}
ANYMAIL_TEST_SENDGRID_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_SENDGRID_TEMPLATE_ID }}
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ vNext

*unreleased changes*

Features
~~~~~~~~

* **Resend**: Add support for this ESP
(`docs <https://anymail.dev/en/latest/esps/resend/>`__).

Fixes
~~~~~

Expand Down
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Anymail currently supports these ESPs:
* **Mandrill** (MailChimp transactional)
* **Postal** (self-hosted ESP)
* **Postmark**
* **Resend**
* **SendGrid**
* **SparkPost**

Expand Down
231 changes: 231 additions & 0 deletions anymail/backends/resend.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions anymail/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .webhooks.mandrill import MandrillCombinedWebhookView
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
from .webhooks.resend import ResendTrackingWebhookView
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
from .webhooks.sendinblue import (
SendinBlueInboundWebhookView,
Expand Down Expand Up @@ -104,6 +105,11 @@
PostmarkTrackingWebhookView.as_view(),
name="postmark_tracking_webhook",
),
path(
"resend/tracking/",
ResendTrackingWebhookView.as_view(),
name="resend_tracking_webhook",
),
path(
"sendgrid/tracking/",
SendGridTrackingWebhookView.as_view(),
Expand Down
Loading

0 comments on commit b5ef492

Please sign in to comment.