Skip to content

Commit b5ef492

Browse files
authored
Resend: new ESP (#341)
Add support for Resend.com backend and webhooks. Closes #341
1 parent 823a161 commit b5ef492

File tree

13 files changed

+1932
-24
lines changed

13 files changed

+1932
-24
lines changed

.github/workflows/integration-test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
- { tox: django41-py310-mandrill, python: "3.10" }
4747
- { tox: django41-py310-postal, python: "3.10" }
4848
- { tox: django41-py310-postmark, python: "3.10" }
49+
- { tox: django41-py310-resend, python: "3.10" }
4950
- { tox: django41-py310-sendgrid, python: "3.10" }
5051
- { tox: django41-py310-sendinblue, python: "3.10" }
5152
- { tox: django41-py310-sparkpost, python: "3.10" }
@@ -87,6 +88,8 @@ jobs:
8788
ANYMAIL_TEST_POSTMARK_DOMAIN: ${{ secrets.ANYMAIL_TEST_POSTMARK_DOMAIN }}
8889
ANYMAIL_TEST_POSTMARK_SERVER_TOKEN: ${{ secrets.ANYMAIL_TEST_POSTMARK_SERVER_TOKEN }}
8990
ANYMAIL_TEST_POSTMARK_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_POSTMARK_TEMPLATE_ID }}
91+
ANYMAIL_TEST_RESEND_API_KEY: ${{ secrets.ANYMAIL_TEST_RESEND_API_KEY }}
92+
ANYMAIL_TEST_RESEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_RESEND_DOMAIN }}
9093
ANYMAIL_TEST_SENDGRID_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDGRID_API_KEY }}
9194
ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }}
9295
ANYMAIL_TEST_SENDGRID_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_SENDGRID_TEMPLATE_ID }}

CHANGELOG.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ vNext
3030

3131
*unreleased changes*
3232

33+
Features
34+
~~~~~~~~
35+
36+
* **Resend**: Add support for this ESP
37+
(`docs <https://anymail.dev/en/latest/esps/resend/>`__).
38+
3339
Fixes
3440
~~~~~
3541

README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Anymail currently supports these ESPs:
3434
* **Mandrill** (MailChimp transactional)
3535
* **Postal** (self-hosted ESP)
3636
* **Postmark**
37+
* **Resend**
3738
* **SendGrid**
3839
* **SparkPost**
3940

anymail/backends/resend.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import mimetypes
2+
from email.charset import QP, Charset
3+
from email.header import decode_header, make_header
4+
from email.headerregistry import Address
5+
6+
from ..message import AnymailRecipientStatus
7+
from ..utils import (
8+
BASIC_NUMERIC_TYPES,
9+
CaseInsensitiveCasePreservingDict,
10+
get_anymail_setting,
11+
)
12+
from .base_requests import AnymailRequestsBackend, RequestsPayload
13+
14+
# Used to force RFC-2047 encoded word
15+
# in address formatting workaround
16+
QP_CHARSET = Charset("utf-8")
17+
QP_CHARSET.header_encoding = QP
18+
19+
20+
class EmailBackend(AnymailRequestsBackend):
21+
"""
22+
Resend (resend.com) API Email Backend
23+
"""
24+
25+
esp_name = "Resend"
26+
27+
def __init__(self, **kwargs):
28+
"""Init options from Django settings"""
29+
esp_name = self.esp_name
30+
self.api_key = get_anymail_setting(
31+
"api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
32+
)
33+
api_url = get_anymail_setting(
34+
"api_url",
35+
esp_name=esp_name,
36+
kwargs=kwargs,
37+
default="https://api.resend.com/",
38+
)
39+
if not api_url.endswith("/"):
40+
api_url += "/"
41+
42+
# Undocumented setting to control workarounds for Resend display-name issues
43+
# (see below). If/when Resend improves their API, you can disable Anymail's
44+
# workarounds by adding `"RESEND_WORKAROUND_DISPLAY_NAME_BUGS": False`
45+
# to your `ANYMAIL` settings.
46+
self.workaround_display_name_bugs = get_anymail_setting(
47+
"workaround_display_name_bugs",
48+
esp_name=esp_name,
49+
kwargs=kwargs,
50+
default=True,
51+
)
52+
53+
super().__init__(api_url, **kwargs)
54+
55+
def build_message_payload(self, message, defaults):
56+
return ResendPayload(message, defaults, self)
57+
58+
def parse_recipient_status(self, response, payload, message):
59+
# Resend provides single message id, no other information.
60+
# Assume "queued".
61+
parsed_response = self.deserialize_json_response(response, payload, message)
62+
message_id = parsed_response["id"]
63+
recipient_status = CaseInsensitiveCasePreservingDict(
64+
{
65+
recip.addr_spec: AnymailRecipientStatus(
66+
message_id=message_id, status="queued"
67+
)
68+
for recip in payload.recipients
69+
}
70+
)
71+
return dict(recipient_status)
72+
73+
74+
class ResendPayload(RequestsPayload):
75+
def __init__(self, message, defaults, backend, *args, **kwargs):
76+
self.recipients = [] # for parse_recipient_status
77+
headers = kwargs.pop("headers", {})
78+
headers["Authorization"] = "Bearer %s" % backend.api_key
79+
headers["Content-Type"] = "application/json"
80+
headers["Accept"] = "application/json"
81+
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
82+
83+
def get_api_endpoint(self):
84+
return "emails"
85+
86+
def serialize_data(self):
87+
return self.serialize_json(self.data)
88+
89+
#
90+
# Payload construction
91+
#
92+
93+
def init_payload(self):
94+
self.data = {} # becomes json
95+
96+
def _resend_email_address(self, address):
97+
"""
98+
Return EmailAddress address formatted for use with Resend.
99+
100+
Works around a Resend bug that rejects properly formatted RFC 5322
101+
addresses that have the display-name enclosed in double quotes (e.g.,
102+
any display-name containing a comma), by substituting an RFC 2047
103+
encoded word.
104+
105+
This works for all Resend address fields _except_ `from` (see below).
106+
"""
107+
formatted = address.address
108+
if self.backend.workaround_display_name_bugs:
109+
if formatted.startswith('"'):
110+
# Workaround: force RFC-2047 encoded word
111+
formatted = str(
112+
Address(
113+
display_name=QP_CHARSET.header_encode(address.display_name),
114+
addr_spec=address.addr_spec,
115+
)
116+
)
117+
return formatted
118+
119+
def set_from_email(self, email):
120+
# Can't use the address header workaround above for the `from` field:
121+
# self.data["from"] = self._resend_email_address(email)
122+
# When `from` uses RFC-2047 encoding, Resend returns a "security_error"
123+
# status 451, "The email payload contain invalid characters".
124+
formatted = email.address
125+
if self.backend.workaround_display_name_bugs:
126+
if formatted.startswith("=?"):
127+
# Workaround: use an *unencoded* (Unicode str) display-name.
128+
# This allows use of non-ASCII characters (which Resend rejects when
129+
# encoded with RFC 2047). Some punctuation will still result in unusual
130+
# behavior or cause an "invalid `from` field" 422 error, but there's
131+
# nothing we can do about that.
132+
formatted = str(
133+
# email.headerregistry.Address str format uses unencoded Unicode
134+
Address(
135+
# Convert RFC 2047 display name back to Unicode str
136+
display_name=str(
137+
make_header(decode_header(email.display_name))
138+
),
139+
addr_spec=email.addr_spec,
140+
)
141+
)
142+
self.data["from"] = formatted
143+
144+
def set_recipients(self, recipient_type, emails):
145+
assert recipient_type in ["to", "cc", "bcc"]
146+
if emails:
147+
field = recipient_type
148+
self.data[field] = [self._resend_email_address(email) for email in emails]
149+
self.recipients += emails
150+
151+
def set_subject(self, subject):
152+
self.data["subject"] = subject
153+
154+
def set_reply_to(self, emails):
155+
if emails:
156+
self.data["reply_to"] = [
157+
self._resend_email_address(email) for email in emails
158+
]
159+
160+
def set_extra_headers(self, headers):
161+
# Resend requires header values to be strings (not integers) as of 2023-10-20.
162+
# Stringify ints and floats; anything else is the caller's responsibility.
163+
self.data.setdefault("headers", {}).update(
164+
{
165+
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
166+
for k, v in headers.items()
167+
}
168+
)
169+
170+
def set_text_body(self, body):
171+
self.data["text"] = body
172+
173+
def set_html_body(self, body):
174+
if "html" in self.data:
175+
# second html body could show up through multiple alternatives,
176+
# or html body + alternative
177+
self.unsupported_feature("multiple html parts")
178+
self.data["html"] = body
179+
180+
@staticmethod
181+
def make_attachment(attachment):
182+
"""Returns Resend attachment dict for attachment"""
183+
filename = attachment.name or ""
184+
if not filename:
185+
# Provide default name with reasonable extension.
186+
# (Resend guesses content type from the filename extension;
187+
# there doesn't seem to be any other way to specify it.)
188+
ext = mimetypes.guess_extension(attachment.content_type)
189+
if ext is not None:
190+
filename = f"attachment{ext}"
191+
att = {"content": attachment.b64content, "filename": filename}
192+
# attachment.inline / attachment.cid not supported
193+
return att
194+
195+
def set_attachments(self, attachments):
196+
if attachments:
197+
if any(att.content_id for att in attachments):
198+
self.unsupported_feature("inline content-id")
199+
self.data["attachments"] = [
200+
self.make_attachment(attachment) for attachment in attachments
201+
]
202+
203+
def set_metadata(self, metadata):
204+
# Send metadata as json in a custom X-Metadata header.
205+
# (Resend's own "tags" are severely limited in character set)
206+
self.data.setdefault("headers", {})["X-Metadata"] = self.serialize_json(
207+
metadata
208+
)
209+
210+
# Resend doesn't support delayed sending
211+
# def set_send_at(self, send_at):
212+
213+
def set_tags(self, tags):
214+
# Send tags using a custom X-Tags header.
215+
# (Resend's own "tags" are severely limited in character set)
216+
self.data.setdefault("headers", {})["X-Tags"] = self.serialize_json(tags)
217+
218+
# Resend doesn't support changing click/open tracking per message
219+
# def set_track_clicks(self, track_clicks):
220+
# def set_track_opens(self, track_opens):
221+
222+
# Resend doesn't support server-rendered templates.
223+
# (Their template feature is rendered client-side,
224+
# using React in node.js.)
225+
# def set_template_id(self, template_id):
226+
# def set_merge_data(self, merge_data):
227+
# def set_merge_global_data(self, merge_global_data):
228+
# def set_merge_metadata(self, merge_metadata):
229+
230+
def set_esp_extra(self, extra):
231+
self.data.update(extra)

anymail/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .webhooks.mandrill import MandrillCombinedWebhookView
1414
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
1515
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
16+
from .webhooks.resend import ResendTrackingWebhookView
1617
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
1718
from .webhooks.sendinblue import (
1819
SendinBlueInboundWebhookView,
@@ -104,6 +105,11 @@
104105
PostmarkTrackingWebhookView.as_view(),
105106
name="postmark_tracking_webhook",
106107
),
108+
path(
109+
"resend/tracking/",
110+
ResendTrackingWebhookView.as_view(),
111+
name="resend_tracking_webhook",
112+
),
107113
path(
108114
"sendgrid/tracking/",
109115
SendGridTrackingWebhookView.as_view(),

0 commit comments

Comments
 (0)