Skip to content

Commit

Permalink
Resend: support batch send
Browse files Browse the repository at this point in the history
Add support for `merge_metadata`
and new Resend email/batch API.
  • Loading branch information
medmunds committed Feb 20, 2024
1 parent d2c5628 commit 706fce6
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 18 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Features

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


v10.2
Expand Down
69 changes: 63 additions & 6 deletions anymail/backends/resend.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from email.header import decode_header, make_header
from email.headerregistry import Address

from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import (
BASIC_NUMERIC_TYPES,
Expand Down Expand Up @@ -56,10 +57,24 @@ 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"]
try:
message_id = parsed_response["id"]
message_ids = None
except (KeyError, TypeError):
# Batch send?
try:
message_id = None
message_ids = [item["id"] for item in parsed_response["data"]]
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError(
"Invalid Resend API response format",
email_message=message,
payload=payload,
response=response,
backend=self,
) from err

recipient_status = CaseInsensitiveCasePreservingDict(
{
recip.addr_spec: AnymailRecipientStatus(
Expand All @@ -68,23 +83,55 @@ def parse_recipient_status(self, response, payload, message):
for recip in payload.recipients
}
)
if message_ids:
# batch send: ids are in same order as to_recipients
for recip, message_id in zip(payload.to_recipients, message_ids):
recipient_status[recip.addr_spec] = AnymailRecipientStatus(
message_id=message_id, status="queued"
)
return dict(recipient_status)


class ResendPayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
self.recipients = [] # for parse_recipient_status
self.to_recipients = [] # for parse_recipient_status
self.metadata = {}
self.merge_metadata = {}
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):
if self.is_batch():
return "emails/batch"
return "emails"

def serialize_data(self):
return self.serialize_json(self.data)
payload = self.data
if self.is_batch():
# Burst payload across to addresses
to_emails = self.data.pop("to", [])
payload = []
for to_email, to in zip(to_emails, self.to_recipients):
data = self.data.copy()
data["to"] = [to_email] # formatted for Resend (w/ workarounds)
if to.addr_spec in self.merge_metadata:
# Merge global metadata with any per-recipient metadata.
recipient_metadata = self.metadata.copy()
recipient_metadata.update(self.merge_metadata[to.addr_spec])
if "headers" in data:
data["headers"] = data["headers"].copy()
else:
data["headers"] = {}
data["headers"]["X-Metadata"] = self.serialize_json(
recipient_metadata
)
payload.append(data)

return self.serialize_json(payload)

#
# Payload construction
Expand Down Expand Up @@ -147,6 +194,8 @@ def set_recipients(self, recipient_type, emails):
field = recipient_type
self.data[field] = [self._resend_email_address(email) for email in emails]
self.recipients += emails
if recipient_type == "to":
self.to_recipients = emails

def set_subject(self, subject):
self.data["subject"] = subject
Expand Down Expand Up @@ -206,6 +255,7 @@ def set_metadata(self, metadata):
self.data.setdefault("headers", {})["X-Metadata"] = self.serialize_json(
metadata
)
self.metadata = metadata # may be needed for batch send in serialize_data

# Resend doesn't support delayed sending
# def set_send_at(self, send_at):
Expand All @@ -223,9 +273,16 @@ def set_tags(self, tags):
# (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_merge_data(self, merge_data):
# Empty merge_data is a request to use batch send;
# any other merge_data is unsupported.
if any(recipient_data for recipient_data in merge_data.values()):
self.unsupported_feature("merge_data")

def set_merge_metadata(self, merge_metadata):
self.merge_metadata = merge_metadata # late bound in serialize_data

def set_esp_extra(self, extra):
self.data.update(extra)
2 changes: 1 addition & 1 deletion docs/esps/esp-feature-matrix.csv
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mail
.. rubric:: :ref:`Anymail send options <anymail-send-options>`,,,,,,,,,,,
:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes
:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes
:attr:`~AnymailMessage.merge_metadata`,No,Yes,No,Yes,Yes,Yes,No,Yes,No,Yes,Yes
:attr:`~AnymailMessage.merge_metadata`,No,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes
:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,No,Yes,Yes
:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag
:attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes
Expand Down
51 changes: 41 additions & 10 deletions docs/esps/resend.rst
Original file line number Diff line number Diff line change
Expand Up @@ -176,16 +176,6 @@ anyway---see :ref:`unsupported-features`.
tracking webhook using :ref:`esp_event <resend-esp-event>`. (The linked
sections below include examples.)

**No stored templates or batch sending**
Resend does not currently offer ESP stored templates or merge capabilities,
including Anymail's
:attr:`~anymail.message.AnymailMessage.merge_data`,
:attr:`~anymail.message.AnymailMessage.merge_global_data`,
:attr:`~anymail.message.AnymailMessage.merge_metadata`, and
:attr:`~anymail.message.AnymailMessage.template_id` features.
(Resend's current template feature is only supported in node.js,
using templates that are rendered in their API client.)

**No click/open tracking overrides**
Resend does not support :attr:`~anymail.message.AnymailMessage.track_clicks`
or :attr:`~anymail.message.AnymailMessage.track_opens`. Its
Expand Down Expand Up @@ -242,6 +232,47 @@ values directly to Resend's `send-email API`_. Example:
}
.. _resend-templates:

Batch sending/merge and ESP templates
-------------------------------------

.. versionadded:: 10.3

Support for batch sending with
:attr:`~anymail.message.AnymailMessage.merge_metadata`.

Resend supports :ref:`batch sending <batch-send>` (where each *To*
recipient sees only their own email address). It also supports
per-recipient metadata with batch sending.

Set Anymail's normalized :attr:`~anymail.message.AnymailMessage.merge_metadata`
attribute to use Resend's batch-send API:

.. code-block:: python
message = EmailMessage(
to=["[email protected]", "Bob <[email protected]>"],
from_email="...", subject="...", body="..."
)
message.merge_metadata = {
'[email protected]': {'user_id': "12345"},
'[email protected]': {'user_id': "54321"},
}
Resend does not currently offer :ref:`ESP stored templates <esp-stored-templates>`
or merge capabilities, so does not support Anymail's
:attr:`~anymail.message.AnymailMessage.merge_data`,
:attr:`~anymail.message.AnymailMessage.merge_global_data`, or
:attr:`~anymail.message.AnymailMessage.template_id` message attributes.
(Resend's current template feature is only supported in node.js,
using templates that are rendered in their API client.)

(Setting :attr:`~anymail.message.AnymailMessage.merge_data` to an empty
dict will also invoke batch send, but trying to supply merge data for
any recipient will raise an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error.)


.. _resend-webhooks:

Status tracking webhooks
Expand Down
90 changes: 89 additions & 1 deletion tests/test_resend_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
AnymailSerializationError,
AnymailUnsupportedFeature,
)
from anymail.message import attach_inline_image_file
from anymail.message import AnymailMessage, attach_inline_image_file

from .mock_requests_backend import (
RequestsBackendMockAPITestCase,
Expand Down Expand Up @@ -445,6 +445,94 @@ def test_headers_metadata_tags_interaction(self):
},
)

_mock_batch_response = {
"data": [
{"id": "ae2014de-c168-4c61-8267-70d2662a1ce1"},
{"id": "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb"},
]
}

def test_merge_data(self):
self.message.merge_data = {"[email protected]": {"customer_id": 3}}
with self.assertRaisesMessage(AnymailUnsupportedFeature, "merge_data"):
self.message.send()

def test_empty_merge_data(self):
# `merge_data = {}` triggers batch send
self.set_mock_response(json_data=self._mock_batch_response)
message = AnymailMessage(
from_email="[email protected]",
to=["[email protected]", "Bob <[email protected]>"],
cc=["[email protected]"],
merge_data={
"[email protected]": {},
"[email protected]": {},
},
)
message.send()
self.assert_esp_called("/emails/batch")
data = self.get_api_call_json()
self.assertEqual(len(data), 2)
self.assertEqual(data[0]["to"], ["[email protected]"])
self.assertEqual(data[1]["to"], ["Bob <[email protected]>"])

recipients = message.anymail_status.recipients
self.assertEqual(recipients["[email protected]"].status, "queued")
self.assertEqual(
recipients["[email protected]"].message_id,
"ae2014de-c168-4c61-8267-70d2662a1ce1",
)
self.assertEqual(recipients["[email protected]"].status, "queued")
self.assertEqual(
recipients["[email protected]"].message_id,
"faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb",
)
# No message_id for cc/bcc recipients in a batch send
self.assertEqual(recipients["[email protected]"].status, "queued")
self.assertIsNone(recipients["[email protected]"].message_id)

def test_merge_metadata(self):
self.set_mock_response(json_data=self._mock_batch_response)
message = AnymailMessage(
from_email="[email protected]",
to=["[email protected]", "Bob <[email protected]>"],
merge_metadata={
"[email protected]": {"order_id": 123, "tier": "premium"},
"[email protected]": {"order_id": 678},
},
metadata={"notification_batch": "zx912"},
)
message.send()

# merge_metadata forces batch send API:
self.assert_esp_called("/emails/batch")

data = self.get_api_call_json()
self.assertEqual(len(data), 2)
self.assertEqual(data[0]["to"], ["[email protected]"])
# metadata and merge_metadata[recipient] are combined:
self.assertEqual(
json.loads(data[0]["headers"]["X-Metadata"]),
{"order_id": 123, "tier": "premium", "notification_batch": "zx912"},
)
self.assertEqual(data[1]["to"], ["Bob <[email protected]>"])
self.assertEqual(
json.loads(data[1]["headers"]["X-Metadata"]),
{"order_id": 678, "notification_batch": "zx912"},
)

recipients = message.anymail_status.recipients
self.assertEqual(recipients["[email protected]"].status, "queued")
self.assertEqual(
recipients["[email protected]"].message_id,
"ae2014de-c168-4c61-8267-70d2662a1ce1",
)
self.assertEqual(recipients["[email protected]"].status, "queued")
self.assertEqual(
recipients["[email protected]"].message_id,
"faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb",
)

def test_track_opens(self):
self.message.track_opens = True
with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"):
Expand Down
31 changes: 31 additions & 0 deletions tests/test_resend_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,37 @@ def test_all_options(self):
len(message.anymail_status.message_id), 0
) # non-empty string

def test_batch_send(self):
# merge_metadata or merge_data will use batch send API
message = AnymailMessage(
subject="Anymail Resend batch sendintegration test",
body="This is the text body",
from_email=self.from_email,
to=["[email protected]", '"Recipient 2" <[email protected]>'],
metadata={"meta1": "simple string", "meta2": 2},
merge_metadata={
"[email protected]": {"meta3": "recipient 1"},
"[email protected]": {"meta3": "recipient 2"},
},
tags=["tag 1", "tag 2"],
)
message.attach_alternative("<p>HTML content</p>", "text/html")
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")

message.send()
# Resend always queues:
self.assertEqual(message.anymail_status.status, {"queued"})
recipient_status = message.anymail_status.recipients
self.assertEqual(recipient_status["[email protected]"].status, "queued")
self.assertEqual(recipient_status["[email protected]"].status, "queued")
self.assertRegex(recipient_status["[email protected]"].message_id, r".+")
self.assertRegex(recipient_status["[email protected]"].message_id, r".+")
# Each recipient gets their own message_id:
self.assertNotEqual(
recipient_status["[email protected]"].message_id,
recipient_status["[email protected]"].message_id,
)

@unittest.skip("Resend has stopped responding to bad/missing API keys (12/2023)")
@override_settings(ANYMAIL_RESEND_API_KEY="Hey, that's not an API key!")
def test_invalid_api_key(self):
Expand Down

0 comments on commit 706fce6

Please sign in to comment.