Skip to content

Commit

Permalink
Brevo: add batch send support
Browse files Browse the repository at this point in the history
Closes #353
  • Loading branch information
medmunds committed Feb 19, 2024
1 parent 804cb76 commit 143ea7a
Show file tree
Hide file tree
Showing 5 changed files with 258 additions and 78 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ Release history
^^^^^^^^^^^^^^^
.. This extra heading level keeps the ToC from becoming unmanageably long
vNext
-----

*unreleased changes*

Features
~~~~~~~~

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


v10.2
-----

Expand Down
66 changes: 55 additions & 11 deletions anymail/backends/sendinblue.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,27 +39,41 @@ def parse_recipient_status(self, response, payload, message):
# SendinBlue doesn't give any detail on a success
# https://developers.sendinblue.com/docs/responses
message_id = None
message_ids = []

if response.content != b"":
parsed_response = self.deserialize_json_response(response, payload, message)
try:
message_id = parsed_response["messageId"]
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError(
"Invalid SendinBlue API response format",
email_message=message,
payload=payload,
response=response,
backend=self,
) from err
except (KeyError, TypeError):
try:
# batch send
message_ids = parsed_response["messageIds"]
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError(
"Invalid SendinBlue API response format",
email_message=message,
payload=payload,
response=response,
backend=self,
) from err

status = AnymailRecipientStatus(message_id=message_id, status="queued")
return {recipient.addr_spec: status for recipient in payload.all_recipients}
recipient_status = {
recipient.addr_spec: status for recipient in payload.all_recipients
}
if message_ids:
for to, message_id in zip(payload.to_recipients, message_ids):
recipient_status[to.addr_spec] = AnymailRecipientStatus(
message_id=message_id, status="queued"
)
return recipient_status


class SendinBluePayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
self.all_recipients = [] # used for backend.parse_recipient_status
self.to_recipients = [] # used for backend.parse_recipient_status

http_headers = kwargs.pop("headers", {})
http_headers["api-key"] = backend.api_key
Expand All @@ -74,9 +88,32 @@ def get_api_endpoint(self):

def init_payload(self):
self.data = {"headers": CaseInsensitiveDict()} # becomes json
self.merge_data = {}
self.metadata = {}
self.merge_metadata = {}

def serialize_data(self):
"""Performs any necessary serialization on self.data, and returns the result."""
if self.is_batch():
# Burst data["to"] into data["messageVersions"]
to_list = self.data.pop("to", [])
self.data["messageVersions"] = [
{"to": [to], "params": self.merge_data.get(to["email"])}
for to in to_list
]
if self.merge_metadata:
# Merge global metadata with any per-recipient metadata.
# (Top-level X-Mailin-custom header is already set to global metadata,
# and will apply for recipients without a "headers" override.)
for version in self.data["messageVersions"]:
to_email = version["to"][0]["email"]
if to_email in self.merge_metadata:
recipient_metadata = self.metadata.copy()
recipient_metadata.update(self.merge_metadata[to_email])
version["headers"] = {
"X-Mailin-custom": self.serialize_json(recipient_metadata)
}

if not self.data["headers"]:
del self.data["headers"] # don't send empty headers
return self.serialize_json(self.data)
Expand All @@ -102,6 +139,8 @@ def set_recipients(self, recipient_type, emails):
if emails:
self.data[recipient_type] = [self.email_object(email) for email in emails]
self.all_recipients += emails # used for backend.parse_recipient_status
if recipient_type == "to":
self.to_recipients = emails # used for backend.parse_recipient_status

def set_subject(self, subject):
if subject != "": # see note in set_text_body about template rendering
Expand Down Expand Up @@ -158,15 +197,20 @@ def set_esp_extra(self, extra):
self.data.update(extra)

def set_merge_data(self, merge_data):
"""SendinBlue doesn't support special attributes for each recipient"""
self.unsupported_feature("merge_data")
# Late bound in serialize_data:
self.merge_data = merge_data

def set_merge_global_data(self, merge_global_data):
self.data["params"] = merge_global_data

def set_metadata(self, metadata):
# SendinBlue expects a single string payload
self.data["headers"]["X-Mailin-custom"] = self.serialize_json(metadata)
self.metadata = metadata # needed in serialize_data for batch send

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

def set_send_at(self, send_at):
try:
Expand Down
84 changes: 69 additions & 15 deletions docs/esps/brevo.rst
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,33 @@ Brevo can handle.
If you are ignoring unsupported features and have multiple reply addresses,
Anymail will use only the first one.

**Metadata**
**Metadata exposed in message headers**
Anymail passes :attr:`~anymail.message.AnymailMessage.metadata` to Brevo
as a JSON-encoded string using their :mailheader:`X-Mailin-custom` email header.
The metadata is available in tracking webhooks.
This header is included in the sent message, so **metadata will be visible to
message recipients** if they view the raw message source.

**Special headers**
Brevo uses special email headers to control certain features.
You can set these using Django's
:class:`EmailMessage.headers <django.core.mail.EmailMessage>`:

.. code-block:: python
message = EmailMessage(
...,
headers = {
"sender.ip": "10.10.1.150", # use a dedicated IP
"idempotencyKey": "...uuid...", # batch send deduplication
}
)
# Note the constructor param is called `headers`, but the
# corresponding attribute is named `extra_headers`:
message.extra_headers = {
"sender.ip": "10.10.1.222",
"idempotencyKey": "...uuid...",
}
**Delayed sending**
.. versionadded:: 9.0
Expand All @@ -174,30 +197,33 @@ Brevo can handle.
Batch sending/merge and ESP templates
-------------------------------------

Brevo supports :ref:`ESP stored templates <esp-stored-templates>` populated with
global merge data for all recipients, but does not offer :ref:`batch sending <batch-send>`
with per-recipient merge data. Anymail's :attr:`~anymail.message.AnymailMessage.merge_data`
and :attr:`~anymail.message.AnymailMessage.merge_metadata` message attributes are not
supported with the Brevo backend, but you can use Anymail's
:attr:`~anymail.message.AnymailMessage.merge_global_data` with Brevo templates.
.. versionchanged:: 10.3

Added support for batch sending with :attr:`~anymail.message.AnymailMessage.merge_data`
and :attr:`~anymail.message.AnymailMessage.merge_metadata`.

Brevo supports :ref:`ESP stored templates <esp-stored-templates>` and
:ref:`batch sending <batch-send>` with per-recipient merge data.

To use a Brevo template, set the message's
:attr:`~anymail.message.AnymailMessage.template_id` to the numeric
Brevo template ID, and supply substitution attributes using
the message's :attr:`~anymail.message.AnymailMessage.merge_global_data`:
Brevo template ID, and supply substitution params using Anymail's normalized
:attr:`~anymail.message.AnymailMessage.merge_data` and
:attr:`~anymail.message.AnymailMessage.merge_global_data` message attributes:

.. code-block:: python
message = EmailMessage(
to=["alice@example.com"] # single recipient...
# ...multiple to emails would all get the same message
# (and would all see each other's emails in the "to" header)
# (subject and body come from the template, so don't include those)
to=["alice@example.com", "Bob <bob@example.com>"]
)
message.template_id = 3 # use this Brevo template
message.from_email = None # to use the template's default sender
message.merge_data = {
'alice@example.com': {'name': "Alice", 'order_no': "12345"},
'bob@example.com': {'name': "Bob", 'order_no': "54321"},
}
message.merge_global_data = {
'name': "Alice",
'order_no': "12345",
'ship_date': "May 15",
}
Expand All @@ -214,6 +240,31 @@ If you want to use the template's sender, be sure to set ``from_email`` to ``Non
You can also override the template's subject and reply-to address (but not body)
using standard :class:`~django.core.mail.EmailMessage` attributes.

Brevo also supports batch-sending without using an ESP-stored template. In this
case, each recipient will receive the same content (Brevo doesn't support inline
templates) but will see only their own *To* email address. Setting either of
:attr:`~anymail.message.AnymailMessage.merge_data` or
:attr:`~anymail.message.AnymailMessage.merge_metadata`---even to an empty
dict---will cause Anymail to use Brevo's batch send option (``"messageVersions"``).

You can use Anymail's
:attr:`~anymail.message.AnymailMessage.merge_metadata` to supply custom tracking
data for each recipient:

.. 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"},
}
To use Brevo's "`idempotencyKey`_" with a batch send, set it in the
message's headers: ``message.extra_headers = {"idempotencyKey": "...uuid..."}``.

.. caution::

**Sendinblue "old template language" not supported**
Expand Down Expand Up @@ -241,6 +292,9 @@ using standard :class:`~django.core.mail.EmailMessage` attributes.
.. _Brevo Template Language:
https://help.brevo.com/hc/en-us/articles/360000946299

.. _idempotencyKey:
https://developers.brevo.com/docs/heterogenous-versions-batch-emails

.. _convert each old template:
https://help.brevo.com/hc/en-us/articles/360000991960

Expand Down
Loading

0 comments on commit 143ea7a

Please sign in to comment.