Skip to content

Commit 706fce6

Browse files
committed
Resend: support batch send
Add support for `merge_metadata` and new Resend email/batch API.
1 parent d2c5628 commit 706fce6

File tree

6 files changed

+227
-18
lines changed

6 files changed

+227
-18
lines changed

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ Features
3535

3636
* **Brevo:** Add support for batch sending
3737
(`docs <https://anymail.dev/en/latest/esps/brevo/#batch-sending-merge-and-esp-templates>`__).
38+
* **Resend:** Add support for batch sending
39+
(`docs <https://anymail.dev/en/latest/esps/resend/#batch-sending-merge-and-esp-templates>`__).
3840

3941

4042
v10.2

anymail/backends/resend.py

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from email.header import decode_header, make_header
44
from email.headerregistry import Address
55

6+
from ..exceptions import AnymailRequestsAPIError
67
from ..message import AnymailRecipientStatus
78
from ..utils import (
89
BASIC_NUMERIC_TYPES,
@@ -56,10 +57,24 @@ def build_message_payload(self, message, defaults):
5657
return ResendPayload(message, defaults, self)
5758

5859
def parse_recipient_status(self, response, payload, message):
59-
# Resend provides single message id, no other information.
60-
# Assume "queued".
6160
parsed_response = self.deserialize_json_response(response, payload, message)
62-
message_id = parsed_response["id"]
61+
try:
62+
message_id = parsed_response["id"]
63+
message_ids = None
64+
except (KeyError, TypeError):
65+
# Batch send?
66+
try:
67+
message_id = None
68+
message_ids = [item["id"] for item in parsed_response["data"]]
69+
except (KeyError, TypeError) as err:
70+
raise AnymailRequestsAPIError(
71+
"Invalid Resend API response format",
72+
email_message=message,
73+
payload=payload,
74+
response=response,
75+
backend=self,
76+
) from err
77+
6378
recipient_status = CaseInsensitiveCasePreservingDict(
6479
{
6580
recip.addr_spec: AnymailRecipientStatus(
@@ -68,23 +83,55 @@ def parse_recipient_status(self, response, payload, message):
6883
for recip in payload.recipients
6984
}
7085
)
86+
if message_ids:
87+
# batch send: ids are in same order as to_recipients
88+
for recip, message_id in zip(payload.to_recipients, message_ids):
89+
recipient_status[recip.addr_spec] = AnymailRecipientStatus(
90+
message_id=message_id, status="queued"
91+
)
7192
return dict(recipient_status)
7293

7394

7495
class ResendPayload(RequestsPayload):
7596
def __init__(self, message, defaults, backend, *args, **kwargs):
7697
self.recipients = [] # for parse_recipient_status
98+
self.to_recipients = [] # for parse_recipient_status
99+
self.metadata = {}
100+
self.merge_metadata = {}
77101
headers = kwargs.pop("headers", {})
78102
headers["Authorization"] = "Bearer %s" % backend.api_key
79103
headers["Content-Type"] = "application/json"
80104
headers["Accept"] = "application/json"
81105
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
82106

83107
def get_api_endpoint(self):
108+
if self.is_batch():
109+
return "emails/batch"
84110
return "emails"
85111

86112
def serialize_data(self):
87-
return self.serialize_json(self.data)
113+
payload = self.data
114+
if self.is_batch():
115+
# Burst payload across to addresses
116+
to_emails = self.data.pop("to", [])
117+
payload = []
118+
for to_email, to in zip(to_emails, self.to_recipients):
119+
data = self.data.copy()
120+
data["to"] = [to_email] # formatted for Resend (w/ workarounds)
121+
if to.addr_spec in self.merge_metadata:
122+
# Merge global metadata with any per-recipient metadata.
123+
recipient_metadata = self.metadata.copy()
124+
recipient_metadata.update(self.merge_metadata[to.addr_spec])
125+
if "headers" in data:
126+
data["headers"] = data["headers"].copy()
127+
else:
128+
data["headers"] = {}
129+
data["headers"]["X-Metadata"] = self.serialize_json(
130+
recipient_metadata
131+
)
132+
payload.append(data)
133+
134+
return self.serialize_json(payload)
88135

89136
#
90137
# Payload construction
@@ -147,6 +194,8 @@ def set_recipients(self, recipient_type, emails):
147194
field = recipient_type
148195
self.data[field] = [self._resend_email_address(email) for email in emails]
149196
self.recipients += emails
197+
if recipient_type == "to":
198+
self.to_recipients = emails
150199

151200
def set_subject(self, subject):
152201
self.data["subject"] = subject
@@ -206,6 +255,7 @@ def set_metadata(self, metadata):
206255
self.data.setdefault("headers", {})["X-Metadata"] = self.serialize_json(
207256
metadata
208257
)
258+
self.metadata = metadata # may be needed for batch send in serialize_data
209259

210260
# Resend doesn't support delayed sending
211261
# def set_send_at(self, send_at):
@@ -223,9 +273,16 @@ def set_tags(self, tags):
223273
# (Their template feature is rendered client-side,
224274
# using React in node.js.)
225275
# def set_template_id(self, template_id):
226-
# def set_merge_data(self, merge_data):
227276
# def set_merge_global_data(self, merge_global_data):
228-
# def set_merge_metadata(self, merge_metadata):
277+
278+
def set_merge_data(self, merge_data):
279+
# Empty merge_data is a request to use batch send;
280+
# any other merge_data is unsupported.
281+
if any(recipient_data for recipient_data in merge_data.values()):
282+
self.unsupported_feature("merge_data")
283+
284+
def set_merge_metadata(self, merge_metadata):
285+
self.merge_metadata = merge_metadata # late bound in serialize_data
229286

230287
def set_esp_extra(self, extra):
231288
self.data.update(extra)

docs/esps/esp-feature-matrix.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mail
22
.. rubric:: :ref:`Anymail send options <anymail-send-options>`,,,,,,,,,,,
33
:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes
44
:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes
5-
:attr:`~AnymailMessage.merge_metadata`,No,Yes,No,Yes,Yes,Yes,No,Yes,No,Yes,Yes
5+
:attr:`~AnymailMessage.merge_metadata`,No,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes
66
:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,No,Yes,Yes
77
:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag
88
:attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes

docs/esps/resend.rst

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -176,16 +176,6 @@ anyway---see :ref:`unsupported-features`.
176176
tracking webhook using :ref:`esp_event <resend-esp-event>`. (The linked
177177
sections below include examples.)
178178

179-
**No stored templates or batch sending**
180-
Resend does not currently offer ESP stored templates or merge capabilities,
181-
including Anymail's
182-
:attr:`~anymail.message.AnymailMessage.merge_data`,
183-
:attr:`~anymail.message.AnymailMessage.merge_global_data`,
184-
:attr:`~anymail.message.AnymailMessage.merge_metadata`, and
185-
:attr:`~anymail.message.AnymailMessage.template_id` features.
186-
(Resend's current template feature is only supported in node.js,
187-
using templates that are rendered in their API client.)
188-
189179
**No click/open tracking overrides**
190180
Resend does not support :attr:`~anymail.message.AnymailMessage.track_clicks`
191181
or :attr:`~anymail.message.AnymailMessage.track_opens`. Its
@@ -242,6 +232,47 @@ values directly to Resend's `send-email API`_. Example:
242232
}
243233
244234
235+
.. _resend-templates:
236+
237+
Batch sending/merge and ESP templates
238+
-------------------------------------
239+
240+
.. versionadded:: 10.3
241+
242+
Support for batch sending with
243+
:attr:`~anymail.message.AnymailMessage.merge_metadata`.
244+
245+
Resend supports :ref:`batch sending <batch-send>` (where each *To*
246+
recipient sees only their own email address). It also supports
247+
per-recipient metadata with batch sending.
248+
249+
Set Anymail's normalized :attr:`~anymail.message.AnymailMessage.merge_metadata`
250+
attribute to use Resend's batch-send API:
251+
252+
.. code-block:: python
253+
254+
message = EmailMessage(
255+
256+
from_email="...", subject="...", body="..."
257+
)
258+
message.merge_metadata = {
259+
'[email protected]': {'user_id': "12345"},
260+
'[email protected]': {'user_id': "54321"},
261+
}
262+
263+
Resend does not currently offer :ref:`ESP stored templates <esp-stored-templates>`
264+
or merge capabilities, so does not support Anymail's
265+
:attr:`~anymail.message.AnymailMessage.merge_data`,
266+
:attr:`~anymail.message.AnymailMessage.merge_global_data`, or
267+
:attr:`~anymail.message.AnymailMessage.template_id` message attributes.
268+
(Resend's current template feature is only supported in node.js,
269+
using templates that are rendered in their API client.)
270+
271+
(Setting :attr:`~anymail.message.AnymailMessage.merge_data` to an empty
272+
dict will also invoke batch send, but trying to supply merge data for
273+
any recipient will raise an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error.)
274+
275+
245276
.. _resend-webhooks:
246277

247278
Status tracking webhooks

tests/test_resend_backend.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
AnymailSerializationError,
1515
AnymailUnsupportedFeature,
1616
)
17-
from anymail.message import attach_inline_image_file
17+
from anymail.message import AnymailMessage, attach_inline_image_file
1818

1919
from .mock_requests_backend import (
2020
RequestsBackendMockAPITestCase,
@@ -445,6 +445,94 @@ def test_headers_metadata_tags_interaction(self):
445445
},
446446
)
447447

448+
_mock_batch_response = {
449+
"data": [
450+
{"id": "ae2014de-c168-4c61-8267-70d2662a1ce1"},
451+
{"id": "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb"},
452+
]
453+
}
454+
455+
def test_merge_data(self):
456+
self.message.merge_data = {"[email protected]": {"customer_id": 3}}
457+
with self.assertRaisesMessage(AnymailUnsupportedFeature, "merge_data"):
458+
self.message.send()
459+
460+
def test_empty_merge_data(self):
461+
# `merge_data = {}` triggers batch send
462+
self.set_mock_response(json_data=self._mock_batch_response)
463+
message = AnymailMessage(
464+
from_email="[email protected]",
465+
466+
467+
merge_data={
468+
469+
470+
},
471+
)
472+
message.send()
473+
self.assert_esp_called("/emails/batch")
474+
data = self.get_api_call_json()
475+
self.assertEqual(len(data), 2)
476+
self.assertEqual(data[0]["to"], ["[email protected]"])
477+
self.assertEqual(data[1]["to"], ["Bob <[email protected]>"])
478+
479+
recipients = message.anymail_status.recipients
480+
self.assertEqual(recipients["[email protected]"].status, "queued")
481+
self.assertEqual(
482+
recipients["[email protected]"].message_id,
483+
"ae2014de-c168-4c61-8267-70d2662a1ce1",
484+
)
485+
self.assertEqual(recipients["[email protected]"].status, "queued")
486+
self.assertEqual(
487+
recipients["[email protected]"].message_id,
488+
"faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb",
489+
)
490+
# No message_id for cc/bcc recipients in a batch send
491+
self.assertEqual(recipients["[email protected]"].status, "queued")
492+
self.assertIsNone(recipients["[email protected]"].message_id)
493+
494+
def test_merge_metadata(self):
495+
self.set_mock_response(json_data=self._mock_batch_response)
496+
message = AnymailMessage(
497+
from_email="[email protected]",
498+
499+
merge_metadata={
500+
"[email protected]": {"order_id": 123, "tier": "premium"},
501+
"[email protected]": {"order_id": 678},
502+
},
503+
metadata={"notification_batch": "zx912"},
504+
)
505+
message.send()
506+
507+
# merge_metadata forces batch send API:
508+
self.assert_esp_called("/emails/batch")
509+
510+
data = self.get_api_call_json()
511+
self.assertEqual(len(data), 2)
512+
self.assertEqual(data[0]["to"], ["[email protected]"])
513+
# metadata and merge_metadata[recipient] are combined:
514+
self.assertEqual(
515+
json.loads(data[0]["headers"]["X-Metadata"]),
516+
{"order_id": 123, "tier": "premium", "notification_batch": "zx912"},
517+
)
518+
self.assertEqual(data[1]["to"], ["Bob <[email protected]>"])
519+
self.assertEqual(
520+
json.loads(data[1]["headers"]["X-Metadata"]),
521+
{"order_id": 678, "notification_batch": "zx912"},
522+
)
523+
524+
recipients = message.anymail_status.recipients
525+
self.assertEqual(recipients["[email protected]"].status, "queued")
526+
self.assertEqual(
527+
recipients["[email protected]"].message_id,
528+
"ae2014de-c168-4c61-8267-70d2662a1ce1",
529+
)
530+
self.assertEqual(recipients["[email protected]"].status, "queued")
531+
self.assertEqual(
532+
recipients["[email protected]"].message_id,
533+
"faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb",
534+
)
535+
448536
def test_track_opens(self):
449537
self.message.track_opens = True
450538
with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"):

tests/test_resend_integration.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,37 @@ def test_all_options(self):
8686
len(message.anymail_status.message_id), 0
8787
) # non-empty string
8888

89+
def test_batch_send(self):
90+
# merge_metadata or merge_data will use batch send API
91+
message = AnymailMessage(
92+
subject="Anymail Resend batch sendintegration test",
93+
body="This is the text body",
94+
from_email=self.from_email,
95+
to=["[email protected]", '"Recipient 2" <[email protected]>'],
96+
metadata={"meta1": "simple string", "meta2": 2},
97+
merge_metadata={
98+
"[email protected]": {"meta3": "recipient 1"},
99+
"[email protected]": {"meta3": "recipient 2"},
100+
},
101+
tags=["tag 1", "tag 2"],
102+
)
103+
message.attach_alternative("<p>HTML content</p>", "text/html")
104+
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
105+
106+
message.send()
107+
# Resend always queues:
108+
self.assertEqual(message.anymail_status.status, {"queued"})
109+
recipient_status = message.anymail_status.recipients
110+
self.assertEqual(recipient_status["[email protected]"].status, "queued")
111+
self.assertEqual(recipient_status["[email protected]"].status, "queued")
112+
self.assertRegex(recipient_status["[email protected]"].message_id, r".+")
113+
self.assertRegex(recipient_status["[email protected]"].message_id, r".+")
114+
# Each recipient gets their own message_id:
115+
self.assertNotEqual(
116+
recipient_status["[email protected]"].message_id,
117+
recipient_status["[email protected]"].message_id,
118+
)
119+
89120
@unittest.skip("Resend has stopped responding to bad/missing API keys (12/2023)")
90121
@override_settings(ANYMAIL_RESEND_API_KEY="Hey, that's not an API key!")
91122
def test_invalid_api_key(self):

0 commit comments

Comments
 (0)