Feature: Implement merge_headers

Implement and document `merge_headers`
for all other ESPs that can support it. (See #371
for base and Amazon SES implementation.)

Closes #374
This commit is contained in:
Mike Edmunds
2024-06-20 15:31:58 -07:00
committed by GitHub
parent 6e696b8566
commit 0776b12331
35 changed files with 754 additions and 40 deletions

View File

@@ -44,9 +44,12 @@ Breaking changes
Features Features
~~~~~~~~ ~~~~~~~~
* **Amazon SES:** Add new ``merge_headers`` option for per-recipient * Add new ``merge_headers`` option for per-recipient headers with batch sends.
headers with template sends. (Requires boto3 >= 1.34.98.) This can be helpful to send individual *List-Unsubscribe* headers (for example).
(Thanks to `@carrerasrodrigo`_ the implementation.) Supported for all current ESPs *except* MailerSend, Mandrill and Postal. See
`docs <https://anymail.dev/en/latest/sending/anymail_additions/#anymail.message.AnymailMessage.merge_headers>`__.
(Thanks to `@carrerasrodrigo`_ for the idea, and for the base and
Amazon SES implementations.)
* **Amazon SES:** Allow extra headers, ``metadata``, ``merge_metadata``, * **Amazon SES:** Allow extra headers, ``metadata``, ``merge_metadata``,
and ``tags`` when sending with a ``template_id``. and ``tags`` when sending with a ``template_id``.

View File

@@ -91,28 +91,32 @@ class BrevoPayload(RequestsPayload):
self.merge_data = {} self.merge_data = {}
self.metadata = {} self.metadata = {}
self.merge_metadata = {} self.merge_metadata = {}
self.merge_headers = {}
def serialize_data(self): def serialize_data(self):
"""Performs any necessary serialization on self.data, and returns the result.""" """Performs any necessary serialization on self.data, and returns the result."""
if self.is_batch(): if self.is_batch():
# Burst data["to"] into data["messageVersions"] # Burst data["to"] into data["messageVersions"]
to_list = self.data.pop("to", []) to_list = self.data.pop("to", [])
self.data["messageVersions"] = [ self.data["messageVersions"] = []
{"to": [to], "params": self.merge_data.get(to["email"])} for to in to_list:
for to in to_list to_email = to["email"]
] version = {"to": [to]}
if self.merge_metadata: headers = CaseInsensitiveDict()
# Merge global metadata with any per-recipient metadata. if to_email in self.merge_data:
# (Top-level X-Mailin-custom header is already set to global metadata, version["params"] = self.merge_data[to_email]
# 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: if to_email in self.merge_metadata:
# Merge global metadata with any per-recipient metadata.
# (Top-level X-Mailin-custom header already has global metadata,
# and will apply for recipients without version headers.)
recipient_metadata = self.metadata.copy() recipient_metadata = self.metadata.copy()
recipient_metadata.update(self.merge_metadata[to_email]) recipient_metadata.update(self.merge_metadata[to_email])
version["headers"] = { headers["X-Mailin-custom"] = self.serialize_json(recipient_metadata)
"X-Mailin-custom": self.serialize_json(recipient_metadata) if to_email in self.merge_headers:
} headers.update(self.merge_headers[to_email])
if headers:
version["headers"] = headers
self.data["messageVersions"].append(version)
if not self.data["headers"]: if not self.data["headers"]:
del self.data["headers"] # don't send empty headers del self.data["headers"] # don't send empty headers
@@ -212,6 +216,10 @@ class BrevoPayload(RequestsPayload):
# Late-bound in serialize_data: # Late-bound in serialize_data:
self.merge_metadata = merge_metadata self.merge_metadata = merge_metadata
def set_merge_headers(self, merge_headers):
# Late-bound in serialize_data:
self.merge_headers = merge_headers
def set_send_at(self, send_at): def set_send_at(self, send_at):
try: try:
start_time_iso = send_at.isoformat(timespec="milliseconds") start_time_iso = send_at.isoformat(timespec="milliseconds")

View File

@@ -117,6 +117,7 @@ class MailgunPayload(RequestsPayload):
self.merge_global_data = {} self.merge_global_data = {}
self.metadata = {} self.metadata = {}
self.merge_metadata = {} self.merge_metadata = {}
self.merge_headers = {}
self.to_emails = [] self.to_emails = []
super().__init__(message, defaults, backend, auth=auth, *args, **kwargs) super().__init__(message, defaults, backend, auth=auth, *args, **kwargs)
@@ -191,6 +192,8 @@ class MailgunPayload(RequestsPayload):
# (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks # (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks
# up its per-recipient value from Mailgun's # up its per-recipient value from Mailgun's
# `recipient-variables[to_email]["name"]`.) # `recipient-variables[to_email]["name"]`.)
# (6) Anymail's `merge_headers` (per-recipient headers) maps to recipient-variables
# prepended with 'h:'.
# #
# If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or # If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or
# `merge_metadata`) are used together, there's a possibility of conflicting keys # `merge_metadata`) are used together, there's a possibility of conflicting keys
@@ -268,6 +271,40 @@ class MailgunPayload(RequestsPayload):
{key: "%recipient.{}%".format(key) for key in merge_data_keys} {key: "%recipient.{}%".format(key) for key in merge_data_keys}
) )
# (6) merge_headers --> Mailgun recipient_variables via 'h:'-prefixed keys
if self.merge_headers:
def hkey(field_name): # 'h:Field-Name'
return "h:{}".format(field_name.title())
merge_header_fields = flatset(
recipient_headers.keys()
for recipient_headers in self.merge_headers.values()
)
merge_header_defaults = {
# existing h:Field-Name value (from extra_headers), or empty string
field: self.data.get(hkey(field), "")
for field in merge_header_fields
}
self.data.update(
# Set up 'h:Field-Name': '%recipient.h:Field-Name%' indirection
{
hvar: f"%recipient.{hvar}%"
for hvar in [hkey(field) for field in merge_header_fields]
}
)
for email in self.to_emails:
# Each recipient's recipient_variables needs _all_ merge header fields
recipient_headers = merge_header_defaults.copy()
recipient_headers.update(self.merge_headers.get(email, {}))
recipient_variables_for_headers = {
hkey(field): value for field, value in recipient_headers.items()
}
recipient_variables.setdefault(email, {}).update(
recipient_variables_for_headers
)
# populate Mailgun params # populate Mailgun params
self.data.update({"v:%s" % key: value for key, value in custom_data.items()}) self.data.update({"v:%s" % key: value for key, value in custom_data.items()})
if recipient_variables or self.is_batch(): if recipient_variables or self.is_batch():
@@ -308,8 +345,8 @@ class MailgunPayload(RequestsPayload):
self.data["h:Reply-To"] = reply_to self.data["h:Reply-To"] = reply_to
def set_extra_headers(self, headers): def set_extra_headers(self, headers):
for key, value in headers.items(): for field, value in headers.items():
self.data["h:%s" % key] = value self.data["h:%s" % field.title()] = value
def set_text_body(self, body): def set_text_body(self, body):
self.data["text"] = body self.data["text"] = body
@@ -385,6 +422,9 @@ class MailgunPayload(RequestsPayload):
# Processed at serialization time (to allow combining with merge_data) # Processed at serialization time (to allow combining with merge_data)
self.merge_metadata = merge_metadata self.merge_metadata = merge_metadata
def set_merge_headers(self, merge_headers):
self.merge_headers = merge_headers
def set_esp_extra(self, extra): def set_esp_extra(self, extra):
self.data.update(extra) self.data.update(extra)
# Allow override of sender_domain via esp_extra # Allow override of sender_domain via esp_extra

View File

@@ -225,6 +225,13 @@ class MailjetPayload(RequestsPayload):
recipient_metadata = merge_metadata[email] recipient_metadata = merge_metadata[email]
message["EventPayload"] = self.serialize_json(recipient_metadata) message["EventPayload"] = self.serialize_json(recipient_metadata)
def set_merge_headers(self, merge_headers):
self._burst_for_batch_send()
for message in self.data["Messages"]:
email = message["To"][0]["Email"]
if email in merge_headers:
message["Headers"] = merge_headers[email]
def set_tags(self, tags): def set_tags(self, tags):
# The choices here are CustomID or Campaign, and Campaign seems closer # The choices here are CustomID or Campaign, and Campaign seems closer
# to how "tags" are handled by other ESPs -- e.g., you can view dashboard # to how "tags" are handled by other ESPs -- e.g., you can view dashboard

View File

@@ -1,5 +1,7 @@
import re import re
from requests.structures import CaseInsensitiveDict
from ..exceptions import AnymailRequestsAPIError from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus from ..message import AnymailRecipientStatus
from ..utils import ( from ..utils import (
@@ -209,6 +211,7 @@ class PostmarkPayload(RequestsPayload):
self.cc_and_bcc_emails = [] # needed for parse_recipient_status self.cc_and_bcc_emails = [] # needed for parse_recipient_status
self.merge_data = None self.merge_data = None
self.merge_metadata = None self.merge_metadata = None
self.merge_headers = {}
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):
@@ -274,6 +277,18 @@ class PostmarkPayload(RequestsPayload):
data["Metadata"].update(recipient_metadata) data["Metadata"].update(recipient_metadata)
else: else:
data["Metadata"] = recipient_metadata data["Metadata"] = recipient_metadata
if to.addr_spec in self.merge_headers:
if "Headers" in data:
# merge global and recipient headers
headers = CaseInsensitiveDict(
(item["Name"], item["Value"]) for item in data["Headers"]
)
headers.update(self.merge_headers[to.addr_spec])
else:
headers = self.merge_headers[to.addr_spec]
data["Headers"] = [
{"Name": name, "Value": value} for name, value in headers.items()
]
return data return data
# #
@@ -383,6 +398,10 @@ class PostmarkPayload(RequestsPayload):
# late-bind # late-bind
self.merge_metadata = merge_metadata self.merge_metadata = merge_metadata
def set_merge_headers(self, merge_headers):
# late-bind
self.merge_headers = merge_headers
def set_esp_extra(self, extra): def set_esp_extra(self, extra):
self.data.update(extra) self.data.update(extra)
# Special handling for 'server_token': # Special handling for 'server_token':

View File

@@ -98,6 +98,7 @@ class ResendPayload(RequestsPayload):
self.to_recipients = [] # for parse_recipient_status self.to_recipients = [] # for parse_recipient_status
self.metadata = {} self.metadata = {}
self.merge_metadata = {} self.merge_metadata = {}
self.merge_headers = {}
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"
@@ -129,6 +130,14 @@ class ResendPayload(RequestsPayload):
data["headers"]["X-Metadata"] = self.serialize_json( data["headers"]["X-Metadata"] = self.serialize_json(
recipient_metadata recipient_metadata
) )
if to.addr_spec in self.merge_headers:
if "headers" in data:
# Merge global headers (or X-Metadata from above)
headers = CaseInsensitiveCasePreservingDict(data["headers"])
headers.update(self.merge_headers[to.addr_spec])
else:
headers = self.merge_headers[to.addr_spec]
data["headers"] = headers
payload.append(data) payload.append(data)
return self.serialize_json(payload) return self.serialize_json(payload)
@@ -284,5 +293,8 @@ class ResendPayload(RequestsPayload):
def set_merge_metadata(self, merge_metadata): def set_merge_metadata(self, merge_metadata):
self.merge_metadata = merge_metadata # late bound in serialize_data self.merge_metadata = merge_metadata # late bound in serialize_data
def set_merge_headers(self, merge_headers):
self.merge_headers = merge_headers # 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)

View File

@@ -92,6 +92,7 @@ class SendGridPayload(RequestsPayload):
self.merge_data = {} # late-bound per-recipient data self.merge_data = {} # late-bound per-recipient data
self.merge_global_data = {} self.merge_global_data = {}
self.merge_metadata = {} self.merge_metadata = {}
self.merge_headers = {}
http_headers = kwargs.pop("headers", {}) http_headers = kwargs.pop("headers", {})
http_headers["Authorization"] = "Bearer %s" % backend.api_key http_headers["Authorization"] = "Bearer %s" % backend.api_key
@@ -116,6 +117,7 @@ class SendGridPayload(RequestsPayload):
self.expand_personalizations_for_batch() self.expand_personalizations_for_batch()
self.build_merge_data() self.build_merge_data()
self.build_merge_metadata() self.build_merge_metadata()
self.build_merge_headers()
if self.generate_message_id: if self.generate_message_id:
self.set_anymail_id() self.set_anymail_id()
@@ -216,6 +218,15 @@ class SendGridPayload(RequestsPayload):
recipient_custom_args = self.transform_metadata(recipient_metadata) recipient_custom_args = self.transform_metadata(recipient_metadata)
personalization["custom_args"] = recipient_custom_args personalization["custom_args"] = recipient_custom_args
def build_merge_headers(self):
if self.merge_headers:
for personalization in self.data["personalizations"]:
assert len(personalization["to"]) == 1
recipient_email = personalization["to"][0]["email"]
recipient_headers = self.merge_headers.get(recipient_email)
if recipient_headers:
personalization["headers"] = recipient_headers
# #
# Payload construction # Payload construction
# #
@@ -374,6 +385,11 @@ class SendGridPayload(RequestsPayload):
# and merge_field_format. # and merge_field_format.
self.merge_metadata = merge_metadata self.merge_metadata = merge_metadata
def set_merge_headers(self, merge_headers):
# Becomes personalizations[...]['headers'] in
# build_merge_data
self.merge_headers = merge_headers
def set_esp_extra(self, extra): def set_esp_extra(self, extra):
self.merge_field_format = extra.pop( self.merge_field_format = extra.pop(
"merge_field_format", self.merge_field_format "merge_field_format", self.merge_field_format

View File

@@ -242,6 +242,36 @@ class SparkPostPayload(RequestsPayload):
if to_email in merge_metadata: if to_email in merge_metadata:
recipient["metadata"] = merge_metadata[to_email] recipient["metadata"] = merge_metadata[to_email]
def set_merge_headers(self, merge_headers):
def header_var(field):
return "Header__" + field.title().replace("-", "_")
merge_header_fields = set()
for recipient in self.data["recipients"]:
to_email = recipient["address"]["email"]
if to_email in merge_headers:
recipient_headers = merge_headers[to_email]
recipient.setdefault("substitution_data", {}).update(
{header_var(key): value for key, value in recipient_headers.items()}
)
merge_header_fields.update(recipient_headers.keys())
if merge_header_fields:
headers = self.data.setdefault("content", {}).setdefault("headers", {})
# Global substitution_data supplies defaults for defined headers:
self.data.setdefault("substitution_data", {}).update(
{
header_var(field): headers[field]
for field in merge_header_fields
if field in headers
}
)
# Indirect merge_headers through substitution_data:
headers.update(
{field: "{{%s}}" % header_var(field) for field in merge_header_fields}
)
def set_send_at(self, send_at): def set_send_at(self, send_at):
try: try:
start_time = send_at.replace(microsecond=0).isoformat() start_time = send_at.replace(microsecond=0).isoformat()

View File

@@ -161,7 +161,7 @@ class UnisenderGoPayload(RequestsPayload):
headers.pop("to", None) headers.pop("to", None)
if headers.pop("cc", None): if headers.pop("cc", None):
self.unsupported_feature( self.unsupported_feature(
"cc with batch send (merge_data or merge_metadata)" "cc with batch send (merge_data, merge_metadata, or merge_headers)"
) )
if not headers: if not headers:
@@ -339,5 +339,26 @@ class UnisenderGoPayload(RequestsPayload):
if recipient_email in merge_metadata: if recipient_email in merge_metadata:
recipient["metadata"] = merge_metadata[recipient_email] recipient["metadata"] = merge_metadata[recipient_email]
# Unisender Go supports header substitution only with List-Unsubscribe.
# (See https://godocs.unisender.ru/web-api-ref#email-send under "substitutions".)
SUPPORTED_MERGE_HEADERS = {"List-Unsubscribe"}
def set_merge_headers(self, merge_headers: dict[str, dict[str, str]]) -> None:
assert self.data["recipients"] # must be called after set_to
if merge_headers:
for recipient in self.data["recipients"]:
recipient_email = recipient["email"]
for key, value in merge_headers.get(recipient_email, {}).items():
field = key.title() # canonicalize field name capitalization
if field in self.SUPPORTED_MERGE_HEADERS:
# Set up a substitution for Header__Field_Name
field_sub = "Header__" + field.replace("-", "_")
recipient.setdefault("substitutions", {})[field_sub] = value
self.data.setdefault("headers", {})[field] = (
"{{%s}}" % field_sub
)
else:
self.unsupported_feature(f"{field!r} in merge_headers")
def set_esp_extra(self, extra: dict) -> None: def set_esp_extra(self, extra: dict) -> None:
update_deep(self.data, extra) update_deep(self.data, extra)

View File

@@ -96,6 +96,14 @@ Limitations and quirks
**No delayed sending** **No delayed sending**
Amazon SES does not support :attr:`~anymail.message.AnymailMessage.send_at`. Amazon SES does not support :attr:`~anymail.message.AnymailMessage.send_at`.
**Merge features require template_id**
Anymail's :attr:`~anymail.message.AnymailMessage.merge_headers`,
:attr:`~anymail.message.AnymailMessage.merge_metadata`,
:attr:`~anymail.message.AnymailMessage.merge_data`, and
:attr:`~anymail.message.AnymailMessage.merge_global_data` are only supported
when sending :ref:`templated messages <amazon-ses-templates>`
(using Anymail's :attr:`~anymail.message.AnymailMessage.template_id`).
**No global send defaults for non-Anymail options** **No global send defaults for non-Anymail options**
With the Amazon SES backend, Anymail's :ref:`global send defaults <send-defaults>` With the Amazon SES backend, Anymail's :ref:`global send defaults <send-defaults>`
are only supported for Anymail's added message options (like are only supported for Anymail's added message options (like

View File

@@ -1,8 +1,9 @@
Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend` Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend`
.. 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,No :attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes,No
:attr:`~AnymailMessage.merge_headers`,Yes*,Yes,No,Yes,Yes,No,No,Yes,Yes,Yes,Yes*,Yes*
:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes :attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes
:attr:`~AnymailMessage.merge_metadata`,No,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes :attr:`~AnymailMessage.merge_metadata`,Yes*,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes
:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,No,Yes,Yes,Yes :attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,No,Yes,Yes,Yes
:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag,Yes :attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag,Yes
:attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes :attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
@@ -10,8 +11,8 @@ Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mail
:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes,Yes :ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes,Yes
.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,, .. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,,
:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes :attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
:attr:`~AnymailMessage.merge_data`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes :attr:`~AnymailMessage.merge_data`,Yes*,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
:attr:`~AnymailMessage.merge_global_data`,Yes,Yes,(emulated),(emulated),Yes,Yes,No,Yes,No,Yes,Yes,Yes :attr:`~AnymailMessage.merge_global_data`,Yes*,Yes,(emulated),(emulated),Yes,Yes,No,Yes,No,Yes,Yes,Yes
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`,,,,,,,,,,,, .. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`,,,,,,,,,,,,
:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes :attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes :class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
1 Email Service Provider :ref:`amazon-ses-backend` :ref:`brevo-backend` :ref:`mailersend-backend` :ref:`mailgun-backend` :ref:`mailjet-backend` :ref:`mandrill-backend` :ref:`postal-backend` :ref:`postmark-backend` :ref:`resend-backend` :ref:`sendgrid-backend` :ref:`sparkpost-backend` :ref:`unisender-go-backend`
2 .. rubric:: :ref:`Anymail send options <anymail-send-options>`
3 :attr:`~AnymailMessage.envelope_sender` Yes No No Domain only Yes Domain only Yes No No No Yes No
4 :attr:`~AnymailMessage.merge_headers` Yes* Yes No Yes Yes No No Yes Yes Yes Yes* Yes*
5 :attr:`~AnymailMessage.metadata` Yes Yes No Yes Yes Yes No Yes Yes Yes Yes Yes
6 :attr:`~AnymailMessage.merge_metadata` No Yes* Yes No Yes Yes Yes No Yes Yes Yes Yes Yes
7 :attr:`~AnymailMessage.send_at` No Yes Yes Yes No Yes No No No Yes Yes Yes
8 :attr:`~AnymailMessage.tags` Yes Yes Yes Yes Max 1 tag Yes Max 1 tag Max 1 tag Yes Yes Max 1 tag Yes
9 :attr:`~AnymailMessage.track_clicks` No No Yes Yes Yes Yes No Yes No Yes Yes Yes
11 :ref:`amp-email` Yes No No Yes No No No No No Yes Yes Yes
12 .. rubric:: :ref:`templates-and-merge`
13 :attr:`~AnymailMessage.template_id` Yes Yes Yes Yes Yes Yes No Yes No Yes Yes Yes
14 :attr:`~AnymailMessage.merge_data` Yes Yes* Yes Yes Yes Yes Yes No Yes No Yes Yes Yes
15 :attr:`~AnymailMessage.merge_global_data` Yes Yes* Yes (emulated) (emulated) Yes Yes No Yes No Yes Yes Yes
16 .. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`
17 :attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes
18 :class:`~anymail.signals.AnymailTrackingEvent` from webhooks Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes

View File

@@ -48,6 +48,8 @@ The table below summarizes the Anymail features supported for each ESP.
:widths: auto :widths: auto
:class: sticky-left :class: sticky-left
\* See ESP detail page for limitations and clarifications
Trying to choose an ESP? Please **don't** start with this table. It's far more Trying to choose an ESP? Please **don't** start with this table. It's far more
important to consider things like an ESP's deliverability stats, latency, uptime, important to consider things like an ESP's deliverability stats, latency, uptime,
and support for developers. The *number* of extra features an ESP offers is almost and support for developers. The *number* of extra features an ESP offers is almost

View File

@@ -219,6 +219,10 @@ see :ref:`unsupported-features`.
Any other extra headers will raise an Any other extra headers will raise an
:exc:`~anymail.exceptions.AnymailUnsupportedFeature` error. :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error.
**No merge headers support**
MailerSend's API does not provide a way to support Anymail's
:attr:`~anymail.message.AnymailMessage.merge_headers`.
**No metadata support** **No metadata support**
MailerSend does not support Anymail's MailerSend does not support Anymail's
:attr:`~anymail.message.AnymailMessage.metadata` or :attr:`~anymail.message.AnymailMessage.metadata` or

View File

@@ -247,18 +247,23 @@ Limitations and quirks
obvious reasons, only the domain portion applies. You can use anything before obvious reasons, only the domain portion applies. You can use anything before
the @, and it will be ignored. the @, and it will be ignored.
**Using merge_metadata with merge_data** **Using merge_metadata and merge_headers with merge_data**
If you use both Anymail's :attr:`~anymail.message.AnymailMessage.merge_data` If you use both Anymail's :attr:`~anymail.message.AnymailMessage.merge_data`
and :attr:`~anymail.message.AnymailMessage.merge_metadata` features, make sure your and :attr:`~anymail.message.AnymailMessage.merge_metadata` features, make sure your
merge_data keys do not start with ``v:``. (It's a good idea anyway to avoid colons :attr:`~!anymail.message.AnymailMessage.merge_data` keys do not start with ``v:``.
and other special characters in merge_data keys, as this isn't generally portable
to other ESPs.) Similarly, if you use Anymail's :attr:`~anymail.message.AnymailMessage.merge_headers`
together with :attr:`~anymail.message.AnymailMessage.merge_data`, make sure your
:attr:`~!anymail.message.AnymailMessage.merge_data` keys do not start with ``h:``.
(It's a good idea anyway to avoid colons and other special characters in merge data
keys, as this isn't generally portable to other ESPs.)
The same underlying Mailgun feature ("recipient-variables") is used to implement The same underlying Mailgun feature ("recipient-variables") is used to implement
both Anymail features. To avoid conflicts, Anymail prepends ``v:`` to recipient all three Anymail features. To avoid conflicts, Anymail prepends ``v:`` to recipient
variables needed for merge_metadata. (This prefix is stripped as Mailgun prepares variables needed for merge metadata, and ``h:`` for merge headers recipient variables.
the message to send, so it won't be present in your Mailgun API logs or the metadata (These prefixes are stripped as Mailgun prepares the message to send, so won't appear
that is sent to tracking webhooks.) in your Mailgun API logs or the metadata that is sent to tracking webhooks.)
**Additional limitations on merge_data with template_id** **Additional limitations on merge_data with template_id**
If you are using Mailgun's stored handlebars templates (Anymail's If you are using Mailgun's stored handlebars templates (Anymail's

View File

@@ -143,6 +143,10 @@ Limitations and quirks
(Verified and reported to MailChimp support 4/2022; (Verified and reported to MailChimp support 4/2022;
see `Anymail discussion #257`_ for more details.) see `Anymail discussion #257`_ for more details.)
**No merge headers support**
Mandrill's API does not provide a way to support Anymail's
:attr:`~anymail.message.AnymailMessage.merge_headers`.
**Envelope sender uses only domain** **Envelope sender uses only domain**
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
populate Mandrill's `'return_path_domain'`---but only the domain portion. populate Mandrill's `'return_path_domain'`---but only the domain portion.

View File

@@ -119,6 +119,13 @@ see :ref:`unsupported-features`.
**Attachments must be named** **Attachments must be named**
Postal issues an `AttachmentMissingName` error when trying to send an attachment without name. Postal issues an `AttachmentMissingName` error when trying to send an attachment without name.
**No merge features**
Because Postal does not support batch sending, Anymail's
:attr:`~anymail.message.AnymailMessage.merge_headers`,
:attr:`~anymail.message.AnymailMessage.merge_metadata`,
and :attr:`~anymail.message.AnymailMessage.merge_data`
are not supported.
.. _postal-templates: .. _postal-templates:

View File

@@ -206,6 +206,15 @@ Limitations and quirks
.. versionadded:: 8.0 .. versionadded:: 8.0
**Extra header limitations**
SparkPost's API silently ignores certain email headers (specified via
Django's :ref:`headers or extra_headers <message-headers>` or Anymail's
:attr:`~anymail.message.AnymailMessage.merge_headers`). In particular,
attempts to provide a custom :mailheader:`List-Unsubscribe` header will
not work; the message will be sent with SparkPost's own subscription
management headers. (The list of allowed custom headers does not seem
to be documented.)
**Envelope sender may use domain only** **Envelope sender may use domain only**
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
populate SparkPost's `'return_path'` parameter. Anymail supplies the full populate SparkPost's `'return_path'` parameter. Anymail supplies the full

View File

@@ -212,6 +212,13 @@ Limitations and quirks
:attr:`~anymail.message.AnymailMessage.merge_data` or :attr:`~anymail.message.AnymailMessage.merge_data` or
:attr:`~anymail.message.AnymailMessage.merge_global_data`. :attr:`~anymail.message.AnymailMessage.merge_global_data`.
**Limited merge headers support**
Unisender Go supports per-recipient :mailheader:`List-Unsubscribe` headers
(if your account has been approved to disable their unsubscribe link),
but trying to include any other field in Anymail's
:attr:`~anymail.message.AnymailMessage.merge_headers` will raise
an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error.
**No envelope sender overrides** **No envelope sender overrides**
Unisender Go does not support overriding a message's Unisender Go does not support overriding a message's
:attr:`~anymail.message.AnymailMessage.envelope_sender`. :attr:`~anymail.message.AnymailMessage.envelope_sender`.

View File

@@ -101,6 +101,49 @@ an :ref:`unsupported feature <unsupported-features>` error.
.. _how envelope sender relates to return path: .. _how envelope sender relates to return path:
https://www.postmastery.com/blog/about-the-return-path-header/ https://www.postmastery.com/blog/about-the-return-path-header/
.. attribute:: merge_headers
.. versionadded:: 11.0
On a message with multiple recipients, if your ESP supports it,
you can set this to a `dict` of *per-recipient* extra email headers.
Each key in the dict is a recipient email (address portion only),
and its value is a dict of header fields and values for that recipient:
.. code-block:: python
message.to = ["wile@example.com", "R. Runner <rr@example.com>"]
message.extra_headers = {
# Headers for all recipients
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
message.merge_headers = {
# Per-recipient headers
"wile@example.com": {
"List-Unsubscribe": "<https://example.com/unsubscribe/12345>",
},
"rr@example.com": {
"List-Unsubscribe": "<https://example.com/unsubscribe/98765>",
},
}
When :attr:`!merge_headers` is set, Anymail will use the ESP's
:ref:`batch sending <batch-send>` option, so that each :attr:`to` recipient gets
an individual message (and doesn't see the other emails on the :attr:`to` list).
Many ESPs restrict which headers are allowed. Be sure to check Anymail's
:ref:`ESP-specific docs <supported-esps>` for your ESP.
(Also, :ref:`special handling <message-headers>` for :mailheader:`From`,
:mailheader:`To` and :mailheader:`Reply-To` headers does *not* apply
to :attr:`!merge_headers`.)
If :attr:`!merge_headers` defines a particular header for only some
recipients, the default for other recipients comes from the message's
:ref:`extra_headers <message-headers>`. If not defined there, behavior
varies by ESP: some will include the header field only for recipients
where you have provided it; other ESPs will send an empty header field
to the other recipients.
.. attribute:: metadata .. attribute:: metadata
If your ESP supports tracking arbitrary metadata, you can set this to If your ESP supports tracking arbitrary metadata, you can set this to

View File

@@ -568,6 +568,43 @@ class BrevoBackendAnymailFeatureTests(BrevoBackendMockAPITestCase):
{"notification_batch": "zx912"}, {"notification_batch": "zx912"},
) )
def test_merge_headers(self):
self.set_mock_response(json_data=self._mock_batch_response)
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
}
self.message.send()
data = self.get_api_call_json()
versions = data["messageVersions"]
self.assertEqual(len(versions), 2)
self.assertEqual(
versions[0]["headers"], {"List-Unsubscribe": "<https://example.com/a/>"}
)
self.assertEqual(
versions[1]["headers"], {"List-Unsubscribe": "<https://example.com/b/>"}
)
self.assertNotIn("params", versions[0]) # because no merge_data
# non-merge headers still in base data
self.assertEqual(
data["headers"],
{
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
)
def test_default_omits_options(self): def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options. """Make sure by default we don't send any ESP-specific options.

View File

@@ -113,6 +113,18 @@ class BrevoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"test+to1@anymail.dev": {"customer-id": "ZXK9123"}, "test+to1@anymail.dev": {"customer-id": "ZXK9123"},
"test+to2@anymail.dev": {"customer-id": "ZZT4192"}, "test+to2@anymail.dev": {"customer-id": "ZZT4192"},
}, },
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
) )
message.attach("attachment1.txt", "Here is some\ntext", "text/plain") message.attach("attachment1.txt", "Here is some\ntext", "text/plain")

View File

@@ -111,7 +111,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
cc=["cc1@example.com", "Also CC <cc2@example.com>"], cc=["cc1@example.com", "Also CC <cc2@example.com>"],
headers={ headers={
"Reply-To": "another@example.com", "Reply-To": "another@example.com",
"X-MyHeader": "my value", "x-my-header": "my value",
"Message-ID": "mycustommsgid@example.com", "Message-ID": "mycustommsgid@example.com",
}, },
) )
@@ -126,8 +126,8 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
) )
self.assertEqual(data["cc"], ["cc1@example.com", "Also CC <cc2@example.com>"]) self.assertEqual(data["cc"], ["cc1@example.com", "Also CC <cc2@example.com>"])
self.assertEqual(data["h:Reply-To"], "another@example.com") self.assertEqual(data["h:Reply-To"], "another@example.com")
self.assertEqual(data["h:X-MyHeader"], "my value") self.assertEqual(data["h:X-My-Header"], "my value")
self.assertEqual(data["h:Message-ID"], "mycustommsgid@example.com") self.assertEqual(data["h:Message-Id"], "mycustommsgid@example.com")
# multiple recipients, but not a batch send: # multiple recipients, but not a batch send:
self.assertNotIn("recipient-variables", data) self.assertNotIn("recipient-variables", data)
@@ -816,6 +816,51 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase):
): ):
self.message.send() self.message.send()
def test_merge_headers(self):
# Per-recipient merge_headers uses the same recipient-variables mechanism
# as above, using variable names starting with "h:"
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
"X-Custom": "custom-default",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
"X-No-Default": "custom-for-alice",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
"X-Custom": "custom-for-bob",
},
}
self.message.send()
data = self.get_api_call_data()
# non-merge header has fixed value:
self.assertEqual(data["h:List-Unsubscribe-Post"], "List-Unsubscribe=One-Click")
# merge headers refer to recipient-variables:
self.assertEqual(data["h:List-Unsubscribe"], "%recipient.h:List-Unsubscribe%")
self.assertEqual(data["h:X-Custom"], "%recipient.h:X-Custom%")
self.assertEqual(data["h:X-No-Default"], "%recipient.h:X-No-Default%")
# recipient-variables populates them:
self.assertJSONEqual(
data["recipient-variables"],
{
"alice@example.com": {
"h:List-Unsubscribe": "<https://example.com/a/>",
"h:X-Custom": "custom-default", # from extra_headers
"h:X-No-Default": "custom-for-alice",
},
"bob@example.com": {
"h:List-Unsubscribe": "<https://example.com/b/>",
"h:X-Custom": "custom-for-bob",
"h:X-No-Default": "", # no default in extra_headers
},
},
)
def test_force_batch(self): def test_force_batch(self):
# Mailgun uses presence of recipient-variables to indicate batch send # Mailgun uses presence of recipient-variables to indicate batch send
self.message.to = ["alice@example.com", "Bob <bob@example.com>"] self.message.to = ["alice@example.com", "Bob <bob@example.com>"]

View File

@@ -201,6 +201,36 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
# (We could try fetching the message from event["storage"]["url"] # (We could try fetching the message from event["storage"]["url"]
# to verify content and other headers.) # to verify content and other headers.)
def test_per_recipient_options(self):
message = AnymailMessage(
from_email=formataddr(("Test From", self.from_email)),
to=["test+to1@anymail.dev", '"Recipient 2" <test+to2@anymail.dev>'],
subject="Anymail Mailgun per-recipient options test",
body="This is the text body",
merge_metadata={
"test+to1@anymail.dev": {"meta1": "one", "meta2": "two"},
"test+to2@anymail.dev": {"meta1": "recipient 2"},
},
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
"X-Custom-Header": "default",
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
"X-Custom-Header": "custom",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
)
message.send()
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")
def test_stored_template(self): def test_stored_template(self):
message = AnymailMessage( message = AnymailMessage(
# name of a real template named in Anymail's Mailgun test account: # name of a real template named in Anymail's Mailgun test account:

View File

@@ -562,6 +562,45 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
{"order_id": 678, "notification_batch": "zx912"}, {"order_id": 678, "notification_batch": "zx912"},
) )
def test_merge_headers(self):
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
}
self.message.send()
data = self.get_api_call_json()
messages = data["Messages"]
self.assertEqual(len(messages), 2)
self.assertEqual(messages[0]["To"][0]["Email"], "alice@example.com")
self.assertEqual(
messages[0]["Headers"],
{"List-Unsubscribe": "<https://example.com/a/>"},
)
self.assertEqual(messages[1]["To"][0]["Email"], "bob@example.com")
self.assertEqual(
messages[1]["Headers"],
{"List-Unsubscribe": "<https://example.com/b/>"},
)
# non-merge headers still in globals:
self.assertEqual(
data["Globals"]["Headers"],
{
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
)
def test_default_omits_options(self): def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options. """Make sure by default we don't send any ESP-specific options.

View File

@@ -113,6 +113,23 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"test+to2@anymail.dev": {"value": "two"}, "test+to2@anymail.dev": {"value": "two"},
}, },
merge_global_data={"global": "global_value"}, merge_global_data={"global": "global_value"},
metadata={"customer-id": "unknown", "meta2": 2},
merge_metadata={
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
},
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
) )
message.send() message.send()
recipient_status = message.anymail_status.recipients recipient_status = message.anymail_status.recipients

View File

@@ -712,6 +712,49 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase):
self.assertEqual(messages[1]["To"], "Bob <bob@example.com>") self.assertEqual(messages[1]["To"], "Bob <bob@example.com>")
self.assertEqual(messages[1]["Metadata"], {"order_id": 678, "tier": "premium"}) self.assertEqual(messages[1]["Metadata"], {"order_id": 678, "tier": "premium"})
def test_merge_headers(self):
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
}
self.message.send()
self.assert_esp_called("/email/batch")
data = self.get_api_call_json()
self.assertEqual(len(data), 2)
# Global and merge headers are combined:
self.assertEqual(data[0]["To"], "alice@example.com")
self.assertCountEqual(
data[0]["Headers"],
[
{"Name": "List-Unsubscribe", "Value": "<https://example.com/a/>"},
{
"Name": "List-Unsubscribe-Post",
"Value": "List-Unsubscribe=One-Click",
},
],
)
self.assertEqual(data[1]["To"], "Bob <bob@example.com>")
self.assertCountEqual(
data[1]["Headers"],
[
{"Name": "List-Unsubscribe", "Value": "<https://example.com/b/>"},
{
"Name": "List-Unsubscribe-Post",
"Value": "List-Unsubscribe=One-Click",
},
],
)
def test_default_omits_options(self): def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options. """Make sure by default we don't send any ESP-specific options.

View File

@@ -68,13 +68,29 @@ class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
cc=["test+cc1@anymail.dev", "Copy 2 <test+cc2@anymail.dev>"], cc=["test+cc1@anymail.dev", "Copy 2 <test+cc2@anymail.dev>"],
bcc=["test+bcc1@anymail.dev", "Blind Copy 2 <test+bcc2@anymail.dev>"], bcc=["test+bcc1@anymail.dev", "Blind Copy 2 <test+bcc2@anymail.dev>"],
reply_to=["reply1@example.com", "Reply 2 <reply2@example.com>"], reply_to=["reply1@example.com", "Reply 2 <reply2@example.com>"],
headers={"X-Anymail-Test": "value"}, headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
# no send_at support # no send_at support
metadata={"meta1": "simple string", "meta2": 2}, metadata={"meta1": "simple string", "meta2": 2},
tags=["tag 1"], # max one tag tags=["tag 1"], # max one tag
track_opens=True, track_opens=True,
track_clicks=True, track_clicks=True,
merge_data={}, # force batch send (distinct message for each `to`) # either of these merge_ options will force batch send
# (unique message for each "to" recipient)
merge_metadata={
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
) )
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")

View File

@@ -533,6 +533,49 @@ class ResendBackendAnymailFeatureTests(ResendBackendMockAPITestCase):
"faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb", "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb",
) )
def test_merge_headers(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>"],
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
merge_headers={
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
)
message.send()
# merge_headers 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"])
# global and recipient headers are combined:
self.assertEqual(
data[0]["headers"],
{
"List-Unsubscribe": "<https://example.com/a/>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
)
self.assertEqual(data[1]["to"], ["Bob <bob@example.com>"])
self.assertEqual(
data[1]["headers"],
{
"List-Unsubscribe": "<https://example.com/b/>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
)
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"):

View File

@@ -87,7 +87,7 @@ class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
) # non-empty string ) # non-empty string
def test_batch_send(self): def test_batch_send(self):
# merge_metadata or merge_data will use batch send API # merge_metadata, merge_headers, or merge_data will use batch send API
message = AnymailMessage( message = AnymailMessage(
subject="Anymail Resend batch sendintegration test", subject="Anymail Resend batch sendintegration test",
body="This is the text body", body="This is the text body",
@@ -99,6 +99,18 @@ class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"test+to2@anymail.dev": {"meta3": "recipient 2"}, "test+to2@anymail.dev": {"meta3": "recipient 2"},
}, },
tags=["tag 1", "tag 2"], tags=["tag 1", "tag 2"],
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
) )
message.attach_alternative("<p>HTML content</p>", "text/html") message.attach_alternative("<p>HTML content</p>", "text/html")
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")

View File

@@ -1016,6 +1016,45 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase):
], ],
) )
def test_merge_headers(self):
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
}
self.message.send()
data = self.get_api_call_json()
personalizations = data["personalizations"]
self.assertEqual(len(personalizations), 2)
self.assertEqual(personalizations[0]["to"][0]["email"], "alice@example.com")
self.assertEqual(
personalizations[0]["headers"],
{"List-Unsubscribe": "<https://example.com/a/>"},
)
self.assertEqual(personalizations[1]["to"][0]["email"], "bob@example.com")
self.assertEqual(
personalizations[1]["headers"],
{"List-Unsubscribe": "<https://example.com/b/>"},
)
# non-merge headers still in globals:
self.assertEqual(
data["headers"],
{
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
)
@override_settings( @override_settings(
ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False # else we force custom_args ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False # else we force custom_args
) )

View File

@@ -119,6 +119,23 @@ class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
esp_extra={ esp_extra={
"merge_field_format": "%{}%", "merge_field_format": "%{}%",
}, },
metadata={"meta1": "simple string", "meta2": 2},
merge_metadata={
"to1@sink.sendgrid.net": {"meta3": "recipient 1"},
"to2@sink.sendgrid.net": {"meta3": "recipient 2"},
},
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
merge_headers={
"to1@sink.sendgrid.net": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"to2@sink.sendgrid.net": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
) )
message.send() message.send()
recipient_status = message.anymail_status.recipients recipient_status = message.anymail_status.recipients

View File

@@ -605,6 +605,58 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
) )
self.assertEqual(data["metadata"], {"notification_batch": "zx912"}) self.assertEqual(data["metadata"], {"notification_batch": "zx912"})
def test_merge_headers(self):
self.set_mock_result(accepted=2)
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"X-Custom-1": "custom 1",
"X-Custom-2": "custom 2 (default)",
}
self.message.merge_headers = {
"alice@example.com": {
"X-Custom-2": "custom 2 alice",
"X-Custom-3": "custom 3 alice",
},
"bob@example.com": {"X-Custom-2": "custom 2 bob"},
}
self.message.send()
data = self.get_api_call_json()
recipients = data["recipients"]
self.assertEqual(len(recipients), 2)
self.assertEqual(recipients[0]["address"]["email"], "alice@example.com")
self.assertEqual(
recipients[0]["substitution_data"],
{
"Header__X_Custom_2": "custom 2 alice",
"Header__X_Custom_3": "custom 3 alice",
},
)
self.assertEqual(recipients[1]["address"]["email"], "bob@example.com")
self.assertEqual(
recipients[1]["substitution_data"],
{
"Header__X_Custom_2": "custom 2 bob",
},
)
# Indirect merge_headers through template substitutions:
self.assertEqual(
data["content"]["headers"],
{
"X-Custom-1": "custom 1", # (not a merge_header, value unchanged)
"X-Custom-2": "{{Header__X_Custom_2}}",
"X-Custom-3": "{{Header__X_Custom_3}}",
},
)
# Defaults for merge_headers in global substitution_data:
self.assertEqual(
data["substitution_data"],
{
"Header__X_Custom_2": "custom 2 (default)",
# No default specified for X-Custom-3; SparkPost will use empty string
},
)
def test_default_omits_options(self): def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options. """Make sure by default we don't send any ESP-specific options.

View File

@@ -116,6 +116,19 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"to2@test.sink.sparkpostmail.com": {"value": "two"}, "to2@test.sink.sparkpostmail.com": {"value": "two"},
}, },
merge_global_data={"global": "global_value"}, merge_global_data={"global": "global_value"},
merge_metadata={
"to1@test.sink.sparkpostmail.com": {"meta1": "one"},
"to2@test.sink.sparkpostmail.com": {"meta1": "two"},
},
headers={
"X-Custom": "custom header default",
},
merge_headers={
# (Note that SparkPost doesn't support custom List-Unsubscribe headers)
"to1@test.sink.sparkpostmail.com": {
"X-Custom": "custom header one",
},
},
) )
message.send() message.send()
recipient_status = message.anymail_status.recipients recipient_status = message.anymail_status.recipients

View File

@@ -602,12 +602,53 @@ class UnisenderGoBackendAnymailFeatureTests(UnisenderGoBackendMockAPITestCase):
self.assertNotIn("to", headers) self.assertNotIn("to", headers)
self.assertNotIn("cc", headers) self.assertNotIn("cc", headers)
def test_merge_headers(self):
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
}
self.message.send()
data = self.get_api_call_json()
headers = data["message"]["headers"]
recipients = data["message"]["recipients"]
self.assertEqual(headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click")
# merge_headers List-Unsubscribe is handled as substitution:
self.assertEqual(headers["List-Unsubscribe"], "{{Header__List_Unsubscribe}}")
self.assertEqual(
recipients[0]["substitutions"],
{"Header__List_Unsubscribe": "<https://example.com/a/>"},
)
self.assertEqual(
recipients[1]["substitutions"],
# Header substitutions merged with other substitutions:
{"Header__List_Unsubscribe": "<https://example.com/b/>", "to_name": "Bob"},
)
def test_unsupported_merge_headers(self):
# Unisender Go only allows substitutions in the List-Unsubscribe header
self.message.merge_headers = {"to@example.com": {"X-Other": "not supported"}}
with self.assertRaisesMessage(
AnymailUnsupportedFeature, "'X-Other' in merge_headers"
):
self.message.send()
def test_cc_unsupported_with_batch_send(self): def test_cc_unsupported_with_batch_send(self):
self.message.merge_data = {} self.message.merge_data = {}
self.message.cc = ["cc@example.com"] self.message.cc = ["cc@example.com"]
with self.assertRaisesMessage( with self.assertRaisesMessage(
AnymailUnsupportedFeature, AnymailUnsupportedFeature,
"cc with batch send (merge_data or merge_metadata)", "cc with batch send (merge_data, merge_metadata, or merge_headers)",
): ):
self.message.send() self.message.send()

View File

@@ -147,6 +147,18 @@ class UnisenderGoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"test+to1@anymail.dev": {"customer-id": "ZXK9123"}, "test+to1@anymail.dev": {"customer-id": "ZXK9123"},
"test+to2@anymail.dev": {"customer-id": "ZZT4192"}, "test+to2@anymail.dev": {"customer-id": "ZZT4192"},
}, },
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
) )
message.from_email = None # use template sender message.from_email = None # use template sender
message.attach("attachment1.txt", "Here is some\ntext", "text/plain") message.attach("attachment1.txt", "Here is some\ntext", "text/plain")