Skip to content

Commit

Permalink
Merge branch 'main' into feature/unisender-esp
Browse files Browse the repository at this point in the history
  • Loading branch information
medmunds authored Feb 20, 2024
2 parents 3a6f13a + 706fce6 commit 819efb8
Show file tree
Hide file tree
Showing 14 changed files with 548 additions and 142 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ vNext

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>`__).
* **Unisender GO**: Add support for this ESP
(`docs <https://anymail.dev/en/latest/esps/unisender_go/>`__).

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)
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
40 changes: 40 additions & 0 deletions docs/_static/table-formatting.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Return the first sibling of el that matches CSS selector, or null if no matches.
* @param {HTMLElement} el
* @param {string} selector
* @returns {HTMLElement|null}
*/
function nextSiblingMatching(el, selector) {
while (el && el.nextElementSibling) {
el = el.nextElementSibling;
if (el.matches(selector)) {
return el;
}
}
return null;
}

/**
* Convert runs of empty <td> elements to a colspan on the first <td>.
*/
function collapseEmptyTableCells() {
document.querySelectorAll(".rst-content tr:has(td:empty)").forEach((tr) => {
for (
let spanStart = tr.querySelector("td");
spanStart;
spanStart = nextSiblingMatching(spanStart, "td")
) {
let emptyCell;
while ((emptyCell = nextSiblingMatching(spanStart, "td:empty"))) {
emptyCell.remove();
spanStart.colSpan++;
}
}
});
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", collapseEmptyTableCells);
} else {
collapseEmptyTableCells();
}
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ def setup(app):
anymail_config_js = (DOCS_PATH / "_static/anymail-config.js").read_text()
app.add_js_file(None, body=anymail_config_js)
app.add_js_file("version-alert.js", **{"async": "async"})
app.add_js_file("table-formatting.js", **{"async": "async"})
app.add_js_file("https://unpkg.com/rate-the-docs", **{"async": "async"})

# Django-specific roles, from
Expand Down
Loading

0 comments on commit 819efb8

Please sign in to comment.