From 706fce60acc0c5fa2eab97e13949943102d4d3c0 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Mon, 19 Feb 2024 16:18:47 -0800 Subject: [PATCH] Resend: support batch send Add support for `merge_metadata` and new Resend email/batch API. --- CHANGELOG.rst | 2 + anymail/backends/resend.py | 69 +++++++++++++++++++++--- docs/esps/esp-feature-matrix.csv | 2 +- docs/esps/resend.rst | 51 ++++++++++++++---- tests/test_resend_backend.py | 90 +++++++++++++++++++++++++++++++- tests/test_resend_integration.py | 31 +++++++++++ 6 files changed, 227 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9ed5507b..89ac3f1b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -35,6 +35,8 @@ Features * **Brevo:** Add support for batch sending (`docs `__). +* **Resend:** Add support for batch sending + (`docs `__). v10.2 diff --git a/anymail/backends/resend.py b/anymail/backends/resend.py index e424dc3f..d63a6545 100644 --- a/anymail/backends/resend.py +++ b/anymail/backends/resend.py @@ -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, @@ -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( @@ -68,12 +83,21 @@ 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" @@ -81,10 +105,33 @@ def __init__(self, message, defaults, backend, *args, **kwargs): 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 @@ -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 @@ -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): @@ -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) diff --git a/docs/esps/esp-feature-matrix.csv b/docs/esps/esp-feature-matrix.csv index 575440a5..d6c579fa 100644 --- a/docs/esps/esp-feature-matrix.csv +++ b/docs/esps/esp-feature-matrix.csv @@ -2,7 +2,7 @@ Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mail .. rubric:: :ref:`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 diff --git a/docs/esps/resend.rst b/docs/esps/resend.rst index cb746b35..7a981f70 100644 --- a/docs/esps/resend.rst +++ b/docs/esps/resend.rst @@ -176,16 +176,6 @@ anyway---see :ref:`unsupported-features`. tracking webhook using :ref:`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 @@ -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 ` (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=["alice@example.com", "Bob "], + from_email="...", subject="...", body="..." + ) + message.merge_metadata = { + 'alice@example.com': {'user_id': "12345"}, + 'bob@example.com': {'user_id': "54321"}, + } + +Resend does not currently offer :ref:`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 diff --git a/tests/test_resend_backend.py b/tests/test_resend_backend.py index bd4f7659..b2bb70e5 100644 --- a/tests/test_resend_backend.py +++ b/tests/test_resend_backend.py @@ -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, @@ -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 = {"to@example.com": {"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="from@example.com", + to=["alice@example.com", "Bob "], + cc=["cc@example.com"], + merge_data={ + "alice@example.com": {}, + "bob@example.com": {}, + }, + ) + message.send() + self.assert_esp_called("/emails/batch") + data = self.get_api_call_json() + self.assertEqual(len(data), 2) + self.assertEqual(data[0]["to"], ["alice@example.com"]) + self.assertEqual(data[1]["to"], ["Bob "]) + + recipients = message.anymail_status.recipients + self.assertEqual(recipients["alice@example.com"].status, "queued") + self.assertEqual( + recipients["alice@example.com"].message_id, + "ae2014de-c168-4c61-8267-70d2662a1ce1", + ) + self.assertEqual(recipients["bob@example.com"].status, "queued") + self.assertEqual( + recipients["bob@example.com"].message_id, + "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb", + ) + # No message_id for cc/bcc recipients in a batch send + self.assertEqual(recipients["cc@example.com"].status, "queued") + self.assertIsNone(recipients["cc@example.com"].message_id) + + def test_merge_metadata(self): + self.set_mock_response(json_data=self._mock_batch_response) + message = AnymailMessage( + from_email="from@example.com", + to=["alice@example.com", "Bob "], + merge_metadata={ + "alice@example.com": {"order_id": 123, "tier": "premium"}, + "bob@example.com": {"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"], ["alice@example.com"]) + # 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 "]) + self.assertEqual( + json.loads(data[1]["headers"]["X-Metadata"]), + {"order_id": 678, "notification_batch": "zx912"}, + ) + + recipients = message.anymail_status.recipients + self.assertEqual(recipients["alice@example.com"].status, "queued") + self.assertEqual( + recipients["alice@example.com"].message_id, + "ae2014de-c168-4c61-8267-70d2662a1ce1", + ) + self.assertEqual(recipients["bob@example.com"].status, "queued") + self.assertEqual( + recipients["bob@example.com"].message_id, + "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb", + ) + def test_track_opens(self): self.message.track_opens = True with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"): diff --git a/tests/test_resend_integration.py b/tests/test_resend_integration.py index 919edb80..6ea722c7 100644 --- a/tests/test_resend_integration.py +++ b/tests/test_resend_integration.py @@ -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=["test+to1@anymail.dev", '"Recipient 2" '], + metadata={"meta1": "simple string", "meta2": 2}, + merge_metadata={ + "test+to1@anymail.dev": {"meta3": "recipient 1"}, + "test+to2@anymail.dev": {"meta3": "recipient 2"}, + }, + tags=["tag 1", "tag 2"], + ) + message.attach_alternative("

HTML content

", "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["test+to1@anymail.dev"].status, "queued") + self.assertEqual(recipient_status["test+to2@anymail.dev"].status, "queued") + self.assertRegex(recipient_status["test+to1@anymail.dev"].message_id, r".+") + self.assertRegex(recipient_status["test+to2@anymail.dev"].message_id, r".+") + # Each recipient gets their own message_id: + self.assertNotEqual( + recipient_status["test+to1@anymail.dev"].message_id, + recipient_status["test+to2@anymail.dev"].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):