mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
Resend: support batch send
Add support for `merge_metadata` and new Resend email/batch API.
This commit is contained in:
@@ -35,6 +35,8 @@ Features
|
|||||||
|
|
||||||
* **Brevo:** Add support for batch sending
|
* **Brevo:** Add support for batch sending
|
||||||
(`docs <https://anymail.dev/en/latest/esps/brevo/#batch-sending-merge-and-esp-templates>`__).
|
(`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
|
v10.2
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from email.charset import QP, Charset
|
|||||||
from email.header import decode_header, make_header
|
from email.header import decode_header, make_header
|
||||||
from email.headerregistry import Address
|
from email.headerregistry import Address
|
||||||
|
|
||||||
|
from ..exceptions import AnymailRequestsAPIError
|
||||||
from ..message import AnymailRecipientStatus
|
from ..message import AnymailRecipientStatus
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
BASIC_NUMERIC_TYPES,
|
BASIC_NUMERIC_TYPES,
|
||||||
@@ -56,10 +57,24 @@ class EmailBackend(AnymailRequestsBackend):
|
|||||||
return ResendPayload(message, defaults, self)
|
return ResendPayload(message, defaults, self)
|
||||||
|
|
||||||
def parse_recipient_status(self, response, payload, message):
|
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)
|
parsed_response = self.deserialize_json_response(response, payload, message)
|
||||||
|
try:
|
||||||
message_id = parsed_response["id"]
|
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(
|
recipient_status = CaseInsensitiveCasePreservingDict(
|
||||||
{
|
{
|
||||||
recip.addr_spec: AnymailRecipientStatus(
|
recip.addr_spec: AnymailRecipientStatus(
|
||||||
@@ -68,12 +83,21 @@ class EmailBackend(AnymailRequestsBackend):
|
|||||||
for recip in payload.recipients
|
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)
|
return dict(recipient_status)
|
||||||
|
|
||||||
|
|
||||||
class ResendPayload(RequestsPayload):
|
class ResendPayload(RequestsPayload):
|
||||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||||
self.recipients = [] # for parse_recipient_status
|
self.recipients = [] # for parse_recipient_status
|
||||||
|
self.to_recipients = [] # for parse_recipient_status
|
||||||
|
self.metadata = {}
|
||||||
|
self.merge_metadata = {}
|
||||||
headers = kwargs.pop("headers", {})
|
headers = kwargs.pop("headers", {})
|
||||||
headers["Authorization"] = "Bearer %s" % backend.api_key
|
headers["Authorization"] = "Bearer %s" % backend.api_key
|
||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
@@ -81,10 +105,33 @@ class ResendPayload(RequestsPayload):
|
|||||||
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
|
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
|
||||||
|
|
||||||
def get_api_endpoint(self):
|
def get_api_endpoint(self):
|
||||||
|
if self.is_batch():
|
||||||
|
return "emails/batch"
|
||||||
return "emails"
|
return "emails"
|
||||||
|
|
||||||
def serialize_data(self):
|
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
|
# Payload construction
|
||||||
@@ -147,6 +194,8 @@ class ResendPayload(RequestsPayload):
|
|||||||
field = recipient_type
|
field = recipient_type
|
||||||
self.data[field] = [self._resend_email_address(email) for email in emails]
|
self.data[field] = [self._resend_email_address(email) for email in emails]
|
||||||
self.recipients += emails
|
self.recipients += emails
|
||||||
|
if recipient_type == "to":
|
||||||
|
self.to_recipients = emails
|
||||||
|
|
||||||
def set_subject(self, subject):
|
def set_subject(self, subject):
|
||||||
self.data["subject"] = subject
|
self.data["subject"] = subject
|
||||||
@@ -206,6 +255,7 @@ class ResendPayload(RequestsPayload):
|
|||||||
self.data.setdefault("headers", {})["X-Metadata"] = self.serialize_json(
|
self.data.setdefault("headers", {})["X-Metadata"] = self.serialize_json(
|
||||||
metadata
|
metadata
|
||||||
)
|
)
|
||||||
|
self.metadata = metadata # may be needed for batch send in serialize_data
|
||||||
|
|
||||||
# Resend doesn't support delayed sending
|
# Resend doesn't support delayed sending
|
||||||
# def set_send_at(self, send_at):
|
# def set_send_at(self, send_at):
|
||||||
@@ -223,9 +273,16 @@ class ResendPayload(RequestsPayload):
|
|||||||
# (Their template feature is rendered client-side,
|
# (Their template feature is rendered client-side,
|
||||||
# using React in node.js.)
|
# using React in node.js.)
|
||||||
# def set_template_id(self, template_id):
|
# 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_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):
|
def set_esp_extra(self, extra):
|
||||||
self.data.update(extra)
|
self.data.update(extra)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mail
|
|||||||
.. rubric:: :ref:`Anymail send options <anymail-send-options>`,,,,,,,,,,,
|
.. 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.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.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.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.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
|
:attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes
|
||||||
|
|||||||
|
@@ -176,16 +176,6 @@ anyway---see :ref:`unsupported-features`.
|
|||||||
tracking webhook using :ref:`esp_event <resend-esp-event>`. (The linked
|
tracking webhook using :ref:`esp_event <resend-esp-event>`. (The linked
|
||||||
sections below include examples.)
|
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**
|
**No click/open tracking overrides**
|
||||||
Resend does not support :attr:`~anymail.message.AnymailMessage.track_clicks`
|
Resend does not support :attr:`~anymail.message.AnymailMessage.track_clicks`
|
||||||
or :attr:`~anymail.message.AnymailMessage.track_opens`. Its
|
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 <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=["alice@example.com", "Bob <bob@example.com>"],
|
||||||
|
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 <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:
|
.. _resend-webhooks:
|
||||||
|
|
||||||
Status tracking webhooks
|
Status tracking webhooks
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from anymail.exceptions import (
|
|||||||
AnymailSerializationError,
|
AnymailSerializationError,
|
||||||
AnymailUnsupportedFeature,
|
AnymailUnsupportedFeature,
|
||||||
)
|
)
|
||||||
from anymail.message import attach_inline_image_file
|
from anymail.message import AnymailMessage, attach_inline_image_file
|
||||||
|
|
||||||
from .mock_requests_backend import (
|
from .mock_requests_backend import (
|
||||||
RequestsBackendMockAPITestCase,
|
RequestsBackendMockAPITestCase,
|
||||||
@@ -445,6 +445,94 @@ class ResendBackendAnymailFeatureTests(ResendBackendMockAPITestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_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 <bob@example.com>"],
|
||||||
|
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 <bob@example.com>"])
|
||||||
|
|
||||||
|
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 <bob@example.com>"],
|
||||||
|
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 <bob@example.com>"])
|
||||||
|
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):
|
def test_track_opens(self):
|
||||||
self.message.track_opens = True
|
self.message.track_opens = True
|
||||||
with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"):
|
with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"):
|
||||||
|
|||||||
@@ -86,6 +86,37 @@ class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
len(message.anymail_status.message_id), 0
|
len(message.anymail_status.message_id), 0
|
||||||
) # non-empty string
|
) # 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" <test+to2@anymail.dev>'],
|
||||||
|
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("<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["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)")
|
@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!")
|
@override_settings(ANYMAIL_RESEND_API_KEY="Hey, that's not an API key!")
|
||||||
def test_invalid_api_key(self):
|
def test_invalid_api_key(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user