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
~~~~~~~~
* **Amazon SES:** Add new ``merge_headers`` option for per-recipient
headers with template sends. (Requires boto3 >= 1.34.98.)
(Thanks to `@carrerasrodrigo`_ the implementation.)
* Add new ``merge_headers`` option for per-recipient headers with batch sends.
This can be helpful to send individual *List-Unsubscribe* headers (for example).
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``,
and ``tags`` when sending with a ``template_id``.

View File

@@ -91,28 +91,32 @@ class BrevoPayload(RequestsPayload):
self.merge_data = {}
self.metadata = {}
self.merge_metadata = {}
self.merge_headers = {}
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)
}
self.data["messageVersions"] = []
for to in to_list:
to_email = to["email"]
version = {"to": [to]}
headers = CaseInsensitiveDict()
if to_email in self.merge_data:
version["params"] = self.merge_data[to_email]
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.update(self.merge_metadata[to_email])
headers["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"]:
del self.data["headers"] # don't send empty headers
@@ -212,6 +216,10 @@ class BrevoPayload(RequestsPayload):
# Late-bound in serialize_data:
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):
try:
start_time_iso = send_at.isoformat(timespec="milliseconds")

View File

@@ -117,6 +117,7 @@ class MailgunPayload(RequestsPayload):
self.merge_global_data = {}
self.metadata = {}
self.merge_metadata = {}
self.merge_headers = {}
self.to_emails = []
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
# up its per-recipient value from Mailgun's
# `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
# `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}
)
# (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
self.data.update({"v:%s" % key: value for key, value in custom_data.items()})
if recipient_variables or self.is_batch():
@@ -308,8 +345,8 @@ class MailgunPayload(RequestsPayload):
self.data["h:Reply-To"] = reply_to
def set_extra_headers(self, headers):
for key, value in headers.items():
self.data["h:%s" % key] = value
for field, value in headers.items():
self.data["h:%s" % field.title()] = value
def set_text_body(self, body):
self.data["text"] = body
@@ -385,6 +422,9 @@ class MailgunPayload(RequestsPayload):
# Processed at serialization time (to allow combining with merge_data)
self.merge_metadata = merge_metadata
def set_merge_headers(self, merge_headers):
self.merge_headers = merge_headers
def set_esp_extra(self, extra):
self.data.update(extra)
# Allow override of sender_domain via esp_extra

View File

@@ -225,6 +225,13 @@ class MailjetPayload(RequestsPayload):
recipient_metadata = merge_metadata[email]
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):
# 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

View File

@@ -1,5 +1,7 @@
import re
from requests.structures import CaseInsensitiveDict
from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import (
@@ -209,6 +211,7 @@ class PostmarkPayload(RequestsPayload):
self.cc_and_bcc_emails = [] # needed for parse_recipient_status
self.merge_data = None
self.merge_metadata = None
self.merge_headers = {}
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
def get_api_endpoint(self):
@@ -274,6 +277,18 @@ class PostmarkPayload(RequestsPayload):
data["Metadata"].update(recipient_metadata)
else:
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
#
@@ -383,6 +398,10 @@ class PostmarkPayload(RequestsPayload):
# late-bind
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):
self.data.update(extra)
# Special handling for 'server_token':

View File

@@ -98,6 +98,7 @@ class ResendPayload(RequestsPayload):
self.to_recipients = [] # for parse_recipient_status
self.metadata = {}
self.merge_metadata = {}
self.merge_headers = {}
headers = kwargs.pop("headers", {})
headers["Authorization"] = "Bearer %s" % backend.api_key
headers["Content-Type"] = "application/json"
@@ -129,6 +130,14 @@ class ResendPayload(RequestsPayload):
data["headers"]["X-Metadata"] = self.serialize_json(
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)
return self.serialize_json(payload)
@@ -284,5 +293,8 @@ class ResendPayload(RequestsPayload):
def set_merge_metadata(self, merge_metadata):
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):
self.data.update(extra)

View File

@@ -92,6 +92,7 @@ class SendGridPayload(RequestsPayload):
self.merge_data = {} # late-bound per-recipient data
self.merge_global_data = {}
self.merge_metadata = {}
self.merge_headers = {}
http_headers = kwargs.pop("headers", {})
http_headers["Authorization"] = "Bearer %s" % backend.api_key
@@ -116,6 +117,7 @@ class SendGridPayload(RequestsPayload):
self.expand_personalizations_for_batch()
self.build_merge_data()
self.build_merge_metadata()
self.build_merge_headers()
if self.generate_message_id:
self.set_anymail_id()
@@ -216,6 +218,15 @@ class SendGridPayload(RequestsPayload):
recipient_custom_args = self.transform_metadata(recipient_metadata)
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
#
@@ -374,6 +385,11 @@ class SendGridPayload(RequestsPayload):
# and merge_field_format.
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):
self.merge_field_format = extra.pop(
"merge_field_format", self.merge_field_format

View File

@@ -242,6 +242,36 @@ class SparkPostPayload(RequestsPayload):
if to_email in merge_metadata:
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):
try:
start_time = send_at.replace(microsecond=0).isoformat()

View File

@@ -161,7 +161,7 @@ class UnisenderGoPayload(RequestsPayload):
headers.pop("to", None)
if headers.pop("cc", None):
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:
@@ -339,5 +339,26 @@ class UnisenderGoPayload(RequestsPayload):
if recipient_email in merge_metadata:
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:
update_deep(self.data, extra)

View File

@@ -96,6 +96,14 @@ Limitations and quirks
**No delayed sending**
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**
With the Amazon SES backend, Anymail's :ref:`global send defaults <send-defaults>`
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`
.. 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.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.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.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
@@ -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
.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,,
: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_global_data`,Yes,Yes,(emulated),(emulated),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
.. 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
: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
: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
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

View File

@@ -219,6 +219,10 @@ see :ref:`unsupported-features`.
Any other extra headers will raise an
: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**
MailerSend does not support Anymail's
: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
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`
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
and other special characters in merge_data keys, as this isn't generally portable
to other ESPs.)
:attr:`~!anymail.message.AnymailMessage.merge_data` keys do not start with ``v:``.
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
both Anymail features. To avoid conflicts, Anymail prepends ``v:`` to recipient
variables needed for merge_metadata. (This prefix is stripped as Mailgun prepares
the message to send, so it won't be present in your Mailgun API logs or the metadata
that is sent to tracking webhooks.)
all three Anymail features. To avoid conflicts, Anymail prepends ``v:`` to recipient
variables needed for merge metadata, and ``h:`` for merge headers recipient variables.
(These prefixes are stripped as Mailgun prepares the message to send, so won't appear
in your Mailgun API logs or the metadata that is sent to tracking webhooks.)
**Additional limitations on merge_data with template_id**
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;
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**
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
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**
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:

View File

@@ -206,6 +206,15 @@ Limitations and quirks
.. 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**
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
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_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**
Unisender Go does not support overriding a message's
: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:
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
If your ESP supports tracking arbitrary metadata, you can set this to

View File

@@ -568,6 +568,43 @@ class BrevoBackendAnymailFeatureTests(BrevoBackendMockAPITestCase):
{"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):
"""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+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")

View File

@@ -111,7 +111,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
cc=["cc1@example.com", "Also CC <cc2@example.com>"],
headers={
"Reply-To": "another@example.com",
"X-MyHeader": "my value",
"x-my-header": "my value",
"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["h:Reply-To"], "another@example.com")
self.assertEqual(data["h:X-MyHeader"], "my value")
self.assertEqual(data["h:Message-ID"], "mycustommsgid@example.com")
self.assertEqual(data["h:X-My-Header"], "my value")
self.assertEqual(data["h:Message-Id"], "mycustommsgid@example.com")
# multiple recipients, but not a batch send:
self.assertNotIn("recipient-variables", data)
@@ -816,6 +816,51 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase):
):
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):
# Mailgun uses presence of recipient-variables to indicate batch send
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"]
# 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):
message = AnymailMessage(
# 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"},
)
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):
"""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"},
},
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()
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]["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):
"""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>"],
bcc=["test+bcc1@anymail.dev", "Blind Copy 2 <test+bcc2@anymail.dev>"],
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
metadata={"meta1": "simple string", "meta2": 2},
tags=["tag 1"], # max one tag
track_opens=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("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")

View File

@@ -533,6 +533,49 @@ class ResendBackendAnymailFeatureTests(ResendBackendMockAPITestCase):
"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):
self.message.track_opens = True
with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"):

View File

@@ -87,7 +87,7 @@ class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
) # non-empty string
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(
subject="Anymail Resend batch sendintegration test",
body="This is the text body",
@@ -99,6 +99,18 @@ class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"test+to2@anymail.dev": {"meta3": "recipient 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("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(
ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False # else we force custom_args
)

View File

@@ -119,6 +119,23 @@ class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
esp_extra={
"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()
recipient_status = message.anymail_status.recipients

View File

@@ -605,6 +605,58 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
)
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):
"""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"},
},
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()
recipient_status = message.anymail_status.recipients

View File

@@ -602,12 +602,53 @@ class UnisenderGoBackendAnymailFeatureTests(UnisenderGoBackendMockAPITestCase):
self.assertNotIn("to", 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):
self.message.merge_data = {}
self.message.cc = ["cc@example.com"]
with self.assertRaisesMessage(
AnymailUnsupportedFeature,
"cc with batch send (merge_data or merge_metadata)",
"cc with batch send (merge_data, merge_metadata, or merge_headers)",
):
self.message.send()

View File

@@ -147,6 +147,18 @@ class UnisenderGoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"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.from_email = None # use template sender
message.attach("attachment1.txt", "Here is some\ntext", "text/plain")