mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-19 19:31:06 -05:00
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:
@@ -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``.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user