Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resend: support batch send #359

Merged
merged 1 commit into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading