mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -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
|
Features
|
||||||
~~~~~~~~
|
~~~~~~~~
|
||||||
|
|
||||||
* **Amazon SES:** Add new ``merge_headers`` option for per-recipient
|
* Add new ``merge_headers`` option for per-recipient headers with batch sends.
|
||||||
headers with template sends. (Requires boto3 >= 1.34.98.)
|
This can be helpful to send individual *List-Unsubscribe* headers (for example).
|
||||||
(Thanks to `@carrerasrodrigo`_ the implementation.)
|
Supported for all current ESPs *except* MailerSend, Mandrill and Postal. See
|
||||||
|
`docs <https://anymail.dev/en/latest/sending/anymail_additions/#anymail.message.AnymailMessage.merge_headers>`__.
|
||||||
|
(Thanks to `@carrerasrodrigo`_ for the idea, and for the base and
|
||||||
|
Amazon SES implementations.)
|
||||||
|
|
||||||
* **Amazon SES:** Allow extra headers, ``metadata``, ``merge_metadata``,
|
* **Amazon SES:** Allow extra headers, ``metadata``, ``merge_metadata``,
|
||||||
and ``tags`` when sending with a ``template_id``.
|
and ``tags`` when sending with a ``template_id``.
|
||||||
|
|||||||
@@ -91,28 +91,32 @@ class BrevoPayload(RequestsPayload):
|
|||||||
self.merge_data = {}
|
self.merge_data = {}
|
||||||
self.metadata = {}
|
self.metadata = {}
|
||||||
self.merge_metadata = {}
|
self.merge_metadata = {}
|
||||||
|
self.merge_headers = {}
|
||||||
|
|
||||||
def serialize_data(self):
|
def serialize_data(self):
|
||||||
"""Performs any necessary serialization on self.data, and returns the result."""
|
"""Performs any necessary serialization on self.data, and returns the result."""
|
||||||
if self.is_batch():
|
if self.is_batch():
|
||||||
# Burst data["to"] into data["messageVersions"]
|
# Burst data["to"] into data["messageVersions"]
|
||||||
to_list = self.data.pop("to", [])
|
to_list = self.data.pop("to", [])
|
||||||
self.data["messageVersions"] = [
|
self.data["messageVersions"] = []
|
||||||
{"to": [to], "params": self.merge_data.get(to["email"])}
|
for to in to_list:
|
||||||
for to in to_list
|
to_email = to["email"]
|
||||||
]
|
version = {"to": [to]}
|
||||||
if self.merge_metadata:
|
headers = CaseInsensitiveDict()
|
||||||
# Merge global metadata with any per-recipient metadata.
|
if to_email in self.merge_data:
|
||||||
# (Top-level X-Mailin-custom header is already set to global metadata,
|
version["params"] = self.merge_data[to_email]
|
||||||
# and will apply for recipients without a "headers" override.)
|
|
||||||
for version in self.data["messageVersions"]:
|
|
||||||
to_email = version["to"][0]["email"]
|
|
||||||
if to_email in self.merge_metadata:
|
if to_email in self.merge_metadata:
|
||||||
|
# Merge global metadata with any per-recipient metadata.
|
||||||
|
# (Top-level X-Mailin-custom header already has global metadata,
|
||||||
|
# and will apply for recipients without version headers.)
|
||||||
recipient_metadata = self.metadata.copy()
|
recipient_metadata = self.metadata.copy()
|
||||||
recipient_metadata.update(self.merge_metadata[to_email])
|
recipient_metadata.update(self.merge_metadata[to_email])
|
||||||
version["headers"] = {
|
headers["X-Mailin-custom"] = self.serialize_json(recipient_metadata)
|
||||||
"X-Mailin-custom": self.serialize_json(recipient_metadata)
|
if to_email in self.merge_headers:
|
||||||
}
|
headers.update(self.merge_headers[to_email])
|
||||||
|
if headers:
|
||||||
|
version["headers"] = headers
|
||||||
|
self.data["messageVersions"].append(version)
|
||||||
|
|
||||||
if not self.data["headers"]:
|
if not self.data["headers"]:
|
||||||
del self.data["headers"] # don't send empty headers
|
del self.data["headers"] # don't send empty headers
|
||||||
@@ -212,6 +216,10 @@ class BrevoPayload(RequestsPayload):
|
|||||||
# Late-bound in serialize_data:
|
# Late-bound in serialize_data:
|
||||||
self.merge_metadata = merge_metadata
|
self.merge_metadata = merge_metadata
|
||||||
|
|
||||||
|
def set_merge_headers(self, merge_headers):
|
||||||
|
# Late-bound in serialize_data:
|
||||||
|
self.merge_headers = merge_headers
|
||||||
|
|
||||||
def set_send_at(self, send_at):
|
def set_send_at(self, send_at):
|
||||||
try:
|
try:
|
||||||
start_time_iso = send_at.isoformat(timespec="milliseconds")
|
start_time_iso = send_at.isoformat(timespec="milliseconds")
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class MailgunPayload(RequestsPayload):
|
|||||||
self.merge_global_data = {}
|
self.merge_global_data = {}
|
||||||
self.metadata = {}
|
self.metadata = {}
|
||||||
self.merge_metadata = {}
|
self.merge_metadata = {}
|
||||||
|
self.merge_headers = {}
|
||||||
self.to_emails = []
|
self.to_emails = []
|
||||||
|
|
||||||
super().__init__(message, defaults, backend, auth=auth, *args, **kwargs)
|
super().__init__(message, defaults, backend, auth=auth, *args, **kwargs)
|
||||||
@@ -191,6 +192,8 @@ class MailgunPayload(RequestsPayload):
|
|||||||
# (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks
|
# (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks
|
||||||
# up its per-recipient value from Mailgun's
|
# up its per-recipient value from Mailgun's
|
||||||
# `recipient-variables[to_email]["name"]`.)
|
# `recipient-variables[to_email]["name"]`.)
|
||||||
|
# (6) Anymail's `merge_headers` (per-recipient headers) maps to recipient-variables
|
||||||
|
# prepended with 'h:'.
|
||||||
#
|
#
|
||||||
# If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or
|
# If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or
|
||||||
# `merge_metadata`) are used together, there's a possibility of conflicting keys
|
# `merge_metadata`) are used together, there's a possibility of conflicting keys
|
||||||
@@ -268,6 +271,40 @@ class MailgunPayload(RequestsPayload):
|
|||||||
{key: "%recipient.{}%".format(key) for key in merge_data_keys}
|
{key: "%recipient.{}%".format(key) for key in merge_data_keys}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# (6) merge_headers --> Mailgun recipient_variables via 'h:'-prefixed keys
|
||||||
|
if self.merge_headers:
|
||||||
|
|
||||||
|
def hkey(field_name): # 'h:Field-Name'
|
||||||
|
return "h:{}".format(field_name.title())
|
||||||
|
|
||||||
|
merge_header_fields = flatset(
|
||||||
|
recipient_headers.keys()
|
||||||
|
for recipient_headers in self.merge_headers.values()
|
||||||
|
)
|
||||||
|
merge_header_defaults = {
|
||||||
|
# existing h:Field-Name value (from extra_headers), or empty string
|
||||||
|
field: self.data.get(hkey(field), "")
|
||||||
|
for field in merge_header_fields
|
||||||
|
}
|
||||||
|
self.data.update(
|
||||||
|
# Set up 'h:Field-Name': '%recipient.h:Field-Name%' indirection
|
||||||
|
{
|
||||||
|
hvar: f"%recipient.{hvar}%"
|
||||||
|
for hvar in [hkey(field) for field in merge_header_fields]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for email in self.to_emails:
|
||||||
|
# Each recipient's recipient_variables needs _all_ merge header fields
|
||||||
|
recipient_headers = merge_header_defaults.copy()
|
||||||
|
recipient_headers.update(self.merge_headers.get(email, {}))
|
||||||
|
recipient_variables_for_headers = {
|
||||||
|
hkey(field): value for field, value in recipient_headers.items()
|
||||||
|
}
|
||||||
|
recipient_variables.setdefault(email, {}).update(
|
||||||
|
recipient_variables_for_headers
|
||||||
|
)
|
||||||
|
|
||||||
# populate Mailgun params
|
# populate Mailgun params
|
||||||
self.data.update({"v:%s" % key: value for key, value in custom_data.items()})
|
self.data.update({"v:%s" % key: value for key, value in custom_data.items()})
|
||||||
if recipient_variables or self.is_batch():
|
if recipient_variables or self.is_batch():
|
||||||
@@ -308,8 +345,8 @@ class MailgunPayload(RequestsPayload):
|
|||||||
self.data["h:Reply-To"] = reply_to
|
self.data["h:Reply-To"] = reply_to
|
||||||
|
|
||||||
def set_extra_headers(self, headers):
|
def set_extra_headers(self, headers):
|
||||||
for key, value in headers.items():
|
for field, value in headers.items():
|
||||||
self.data["h:%s" % key] = value
|
self.data["h:%s" % field.title()] = value
|
||||||
|
|
||||||
def set_text_body(self, body):
|
def set_text_body(self, body):
|
||||||
self.data["text"] = body
|
self.data["text"] = body
|
||||||
@@ -385,6 +422,9 @@ class MailgunPayload(RequestsPayload):
|
|||||||
# Processed at serialization time (to allow combining with merge_data)
|
# Processed at serialization time (to allow combining with merge_data)
|
||||||
self.merge_metadata = merge_metadata
|
self.merge_metadata = merge_metadata
|
||||||
|
|
||||||
|
def set_merge_headers(self, merge_headers):
|
||||||
|
self.merge_headers = merge_headers
|
||||||
|
|
||||||
def set_esp_extra(self, extra):
|
def set_esp_extra(self, extra):
|
||||||
self.data.update(extra)
|
self.data.update(extra)
|
||||||
# Allow override of sender_domain via esp_extra
|
# Allow override of sender_domain via esp_extra
|
||||||
|
|||||||
@@ -225,6 +225,13 @@ class MailjetPayload(RequestsPayload):
|
|||||||
recipient_metadata = merge_metadata[email]
|
recipient_metadata = merge_metadata[email]
|
||||||
message["EventPayload"] = self.serialize_json(recipient_metadata)
|
message["EventPayload"] = self.serialize_json(recipient_metadata)
|
||||||
|
|
||||||
|
def set_merge_headers(self, merge_headers):
|
||||||
|
self._burst_for_batch_send()
|
||||||
|
for message in self.data["Messages"]:
|
||||||
|
email = message["To"][0]["Email"]
|
||||||
|
if email in merge_headers:
|
||||||
|
message["Headers"] = merge_headers[email]
|
||||||
|
|
||||||
def set_tags(self, tags):
|
def set_tags(self, tags):
|
||||||
# The choices here are CustomID or Campaign, and Campaign seems closer
|
# The choices here are CustomID or Campaign, and Campaign seems closer
|
||||||
# to how "tags" are handled by other ESPs -- e.g., you can view dashboard
|
# to how "tags" are handled by other ESPs -- e.g., you can view dashboard
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
|
from requests.structures import CaseInsensitiveDict
|
||||||
|
|
||||||
from ..exceptions import AnymailRequestsAPIError
|
from ..exceptions import AnymailRequestsAPIError
|
||||||
from ..message import AnymailRecipientStatus
|
from ..message import AnymailRecipientStatus
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
@@ -209,6 +211,7 @@ class PostmarkPayload(RequestsPayload):
|
|||||||
self.cc_and_bcc_emails = [] # needed for parse_recipient_status
|
self.cc_and_bcc_emails = [] # needed for parse_recipient_status
|
||||||
self.merge_data = None
|
self.merge_data = None
|
||||||
self.merge_metadata = None
|
self.merge_metadata = None
|
||||||
|
self.merge_headers = {}
|
||||||
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
|
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
|
||||||
|
|
||||||
def get_api_endpoint(self):
|
def get_api_endpoint(self):
|
||||||
@@ -274,6 +277,18 @@ class PostmarkPayload(RequestsPayload):
|
|||||||
data["Metadata"].update(recipient_metadata)
|
data["Metadata"].update(recipient_metadata)
|
||||||
else:
|
else:
|
||||||
data["Metadata"] = recipient_metadata
|
data["Metadata"] = recipient_metadata
|
||||||
|
if to.addr_spec in self.merge_headers:
|
||||||
|
if "Headers" in data:
|
||||||
|
# merge global and recipient headers
|
||||||
|
headers = CaseInsensitiveDict(
|
||||||
|
(item["Name"], item["Value"]) for item in data["Headers"]
|
||||||
|
)
|
||||||
|
headers.update(self.merge_headers[to.addr_spec])
|
||||||
|
else:
|
||||||
|
headers = self.merge_headers[to.addr_spec]
|
||||||
|
data["Headers"] = [
|
||||||
|
{"Name": name, "Value": value} for name, value in headers.items()
|
||||||
|
]
|
||||||
return data
|
return data
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -383,6 +398,10 @@ class PostmarkPayload(RequestsPayload):
|
|||||||
# late-bind
|
# late-bind
|
||||||
self.merge_metadata = merge_metadata
|
self.merge_metadata = merge_metadata
|
||||||
|
|
||||||
|
def set_merge_headers(self, merge_headers):
|
||||||
|
# late-bind
|
||||||
|
self.merge_headers = merge_headers
|
||||||
|
|
||||||
def set_esp_extra(self, extra):
|
def set_esp_extra(self, extra):
|
||||||
self.data.update(extra)
|
self.data.update(extra)
|
||||||
# Special handling for 'server_token':
|
# Special handling for 'server_token':
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ class ResendPayload(RequestsPayload):
|
|||||||
self.to_recipients = [] # for parse_recipient_status
|
self.to_recipients = [] # for parse_recipient_status
|
||||||
self.metadata = {}
|
self.metadata = {}
|
||||||
self.merge_metadata = {}
|
self.merge_metadata = {}
|
||||||
|
self.merge_headers = {}
|
||||||
headers = kwargs.pop("headers", {})
|
headers = kwargs.pop("headers", {})
|
||||||
headers["Authorization"] = "Bearer %s" % backend.api_key
|
headers["Authorization"] = "Bearer %s" % backend.api_key
|
||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
@@ -129,6 +130,14 @@ class ResendPayload(RequestsPayload):
|
|||||||
data["headers"]["X-Metadata"] = self.serialize_json(
|
data["headers"]["X-Metadata"] = self.serialize_json(
|
||||||
recipient_metadata
|
recipient_metadata
|
||||||
)
|
)
|
||||||
|
if to.addr_spec in self.merge_headers:
|
||||||
|
if "headers" in data:
|
||||||
|
# Merge global headers (or X-Metadata from above)
|
||||||
|
headers = CaseInsensitiveCasePreservingDict(data["headers"])
|
||||||
|
headers.update(self.merge_headers[to.addr_spec])
|
||||||
|
else:
|
||||||
|
headers = self.merge_headers[to.addr_spec]
|
||||||
|
data["headers"] = headers
|
||||||
payload.append(data)
|
payload.append(data)
|
||||||
|
|
||||||
return self.serialize_json(payload)
|
return self.serialize_json(payload)
|
||||||
@@ -284,5 +293,8 @@ class ResendPayload(RequestsPayload):
|
|||||||
def set_merge_metadata(self, merge_metadata):
|
def set_merge_metadata(self, merge_metadata):
|
||||||
self.merge_metadata = merge_metadata # late bound in serialize_data
|
self.merge_metadata = merge_metadata # late bound in serialize_data
|
||||||
|
|
||||||
|
def set_merge_headers(self, merge_headers):
|
||||||
|
self.merge_headers = merge_headers # late bound in serialize_data
|
||||||
|
|
||||||
def set_esp_extra(self, extra):
|
def set_esp_extra(self, extra):
|
||||||
self.data.update(extra)
|
self.data.update(extra)
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class SendGridPayload(RequestsPayload):
|
|||||||
self.merge_data = {} # late-bound per-recipient data
|
self.merge_data = {} # late-bound per-recipient data
|
||||||
self.merge_global_data = {}
|
self.merge_global_data = {}
|
||||||
self.merge_metadata = {}
|
self.merge_metadata = {}
|
||||||
|
self.merge_headers = {}
|
||||||
|
|
||||||
http_headers = kwargs.pop("headers", {})
|
http_headers = kwargs.pop("headers", {})
|
||||||
http_headers["Authorization"] = "Bearer %s" % backend.api_key
|
http_headers["Authorization"] = "Bearer %s" % backend.api_key
|
||||||
@@ -116,6 +117,7 @@ class SendGridPayload(RequestsPayload):
|
|||||||
self.expand_personalizations_for_batch()
|
self.expand_personalizations_for_batch()
|
||||||
self.build_merge_data()
|
self.build_merge_data()
|
||||||
self.build_merge_metadata()
|
self.build_merge_metadata()
|
||||||
|
self.build_merge_headers()
|
||||||
if self.generate_message_id:
|
if self.generate_message_id:
|
||||||
self.set_anymail_id()
|
self.set_anymail_id()
|
||||||
|
|
||||||
@@ -216,6 +218,15 @@ class SendGridPayload(RequestsPayload):
|
|||||||
recipient_custom_args = self.transform_metadata(recipient_metadata)
|
recipient_custom_args = self.transform_metadata(recipient_metadata)
|
||||||
personalization["custom_args"] = recipient_custom_args
|
personalization["custom_args"] = recipient_custom_args
|
||||||
|
|
||||||
|
def build_merge_headers(self):
|
||||||
|
if self.merge_headers:
|
||||||
|
for personalization in self.data["personalizations"]:
|
||||||
|
assert len(personalization["to"]) == 1
|
||||||
|
recipient_email = personalization["to"][0]["email"]
|
||||||
|
recipient_headers = self.merge_headers.get(recipient_email)
|
||||||
|
if recipient_headers:
|
||||||
|
personalization["headers"] = recipient_headers
|
||||||
|
|
||||||
#
|
#
|
||||||
# Payload construction
|
# Payload construction
|
||||||
#
|
#
|
||||||
@@ -374,6 +385,11 @@ class SendGridPayload(RequestsPayload):
|
|||||||
# and merge_field_format.
|
# and merge_field_format.
|
||||||
self.merge_metadata = merge_metadata
|
self.merge_metadata = merge_metadata
|
||||||
|
|
||||||
|
def set_merge_headers(self, merge_headers):
|
||||||
|
# Becomes personalizations[...]['headers'] in
|
||||||
|
# build_merge_data
|
||||||
|
self.merge_headers = merge_headers
|
||||||
|
|
||||||
def set_esp_extra(self, extra):
|
def set_esp_extra(self, extra):
|
||||||
self.merge_field_format = extra.pop(
|
self.merge_field_format = extra.pop(
|
||||||
"merge_field_format", self.merge_field_format
|
"merge_field_format", self.merge_field_format
|
||||||
|
|||||||
@@ -242,6 +242,36 @@ class SparkPostPayload(RequestsPayload):
|
|||||||
if to_email in merge_metadata:
|
if to_email in merge_metadata:
|
||||||
recipient["metadata"] = merge_metadata[to_email]
|
recipient["metadata"] = merge_metadata[to_email]
|
||||||
|
|
||||||
|
def set_merge_headers(self, merge_headers):
|
||||||
|
def header_var(field):
|
||||||
|
return "Header__" + field.title().replace("-", "_")
|
||||||
|
|
||||||
|
merge_header_fields = set()
|
||||||
|
|
||||||
|
for recipient in self.data["recipients"]:
|
||||||
|
to_email = recipient["address"]["email"]
|
||||||
|
if to_email in merge_headers:
|
||||||
|
recipient_headers = merge_headers[to_email]
|
||||||
|
recipient.setdefault("substitution_data", {}).update(
|
||||||
|
{header_var(key): value for key, value in recipient_headers.items()}
|
||||||
|
)
|
||||||
|
merge_header_fields.update(recipient_headers.keys())
|
||||||
|
|
||||||
|
if merge_header_fields:
|
||||||
|
headers = self.data.setdefault("content", {}).setdefault("headers", {})
|
||||||
|
# Global substitution_data supplies defaults for defined headers:
|
||||||
|
self.data.setdefault("substitution_data", {}).update(
|
||||||
|
{
|
||||||
|
header_var(field): headers[field]
|
||||||
|
for field in merge_header_fields
|
||||||
|
if field in headers
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Indirect merge_headers through substitution_data:
|
||||||
|
headers.update(
|
||||||
|
{field: "{{%s}}" % header_var(field) for field in merge_header_fields}
|
||||||
|
)
|
||||||
|
|
||||||
def set_send_at(self, send_at):
|
def set_send_at(self, send_at):
|
||||||
try:
|
try:
|
||||||
start_time = send_at.replace(microsecond=0).isoformat()
|
start_time = send_at.replace(microsecond=0).isoformat()
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ class UnisenderGoPayload(RequestsPayload):
|
|||||||
headers.pop("to", None)
|
headers.pop("to", None)
|
||||||
if headers.pop("cc", None):
|
if headers.pop("cc", None):
|
||||||
self.unsupported_feature(
|
self.unsupported_feature(
|
||||||
"cc with batch send (merge_data or merge_metadata)"
|
"cc with batch send (merge_data, merge_metadata, or merge_headers)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not headers:
|
if not headers:
|
||||||
@@ -339,5 +339,26 @@ class UnisenderGoPayload(RequestsPayload):
|
|||||||
if recipient_email in merge_metadata:
|
if recipient_email in merge_metadata:
|
||||||
recipient["metadata"] = merge_metadata[recipient_email]
|
recipient["metadata"] = merge_metadata[recipient_email]
|
||||||
|
|
||||||
|
# Unisender Go supports header substitution only with List-Unsubscribe.
|
||||||
|
# (See https://godocs.unisender.ru/web-api-ref#email-send under "substitutions".)
|
||||||
|
SUPPORTED_MERGE_HEADERS = {"List-Unsubscribe"}
|
||||||
|
|
||||||
|
def set_merge_headers(self, merge_headers: dict[str, dict[str, str]]) -> None:
|
||||||
|
assert self.data["recipients"] # must be called after set_to
|
||||||
|
if merge_headers:
|
||||||
|
for recipient in self.data["recipients"]:
|
||||||
|
recipient_email = recipient["email"]
|
||||||
|
for key, value in merge_headers.get(recipient_email, {}).items():
|
||||||
|
field = key.title() # canonicalize field name capitalization
|
||||||
|
if field in self.SUPPORTED_MERGE_HEADERS:
|
||||||
|
# Set up a substitution for Header__Field_Name
|
||||||
|
field_sub = "Header__" + field.replace("-", "_")
|
||||||
|
recipient.setdefault("substitutions", {})[field_sub] = value
|
||||||
|
self.data.setdefault("headers", {})[field] = (
|
||||||
|
"{{%s}}" % field_sub
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.unsupported_feature(f"{field!r} in merge_headers")
|
||||||
|
|
||||||
def set_esp_extra(self, extra: dict) -> None:
|
def set_esp_extra(self, extra: dict) -> None:
|
||||||
update_deep(self.data, extra)
|
update_deep(self.data, extra)
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ Limitations and quirks
|
|||||||
**No delayed sending**
|
**No delayed sending**
|
||||||
Amazon SES does not support :attr:`~anymail.message.AnymailMessage.send_at`.
|
Amazon SES does not support :attr:`~anymail.message.AnymailMessage.send_at`.
|
||||||
|
|
||||||
|
**Merge features require template_id**
|
||||||
|
Anymail's :attr:`~anymail.message.AnymailMessage.merge_headers`,
|
||||||
|
:attr:`~anymail.message.AnymailMessage.merge_metadata`,
|
||||||
|
:attr:`~anymail.message.AnymailMessage.merge_data`, and
|
||||||
|
:attr:`~anymail.message.AnymailMessage.merge_global_data` are only supported
|
||||||
|
when sending :ref:`templated messages <amazon-ses-templates>`
|
||||||
|
(using Anymail's :attr:`~anymail.message.AnymailMessage.template_id`).
|
||||||
|
|
||||||
**No global send defaults for non-Anymail options**
|
**No global send defaults for non-Anymail options**
|
||||||
With the Amazon SES backend, Anymail's :ref:`global send defaults <send-defaults>`
|
With the Amazon SES backend, Anymail's :ref:`global send defaults <send-defaults>`
|
||||||
are only supported for Anymail's added message options (like
|
are only supported for Anymail's added message options (like
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend`
|
Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend`
|
||||||
.. rubric:: :ref:`Anymail send options <anymail-send-options>`,,,,,,,,,,,,
|
.. rubric:: :ref:`Anymail send options <anymail-send-options>`,,,,,,,,,,,,
|
||||||
:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes,No
|
:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes,No
|
||||||
|
:attr:`~AnymailMessage.merge_headers`,Yes*,Yes,No,Yes,Yes,No,No,Yes,Yes,Yes,Yes*,Yes*
|
||||||
:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes
|
:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes
|
||||||
:attr:`~AnymailMessage.merge_metadata`,No,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes
|
:attr:`~AnymailMessage.merge_metadata`,Yes*,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes
|
||||||
:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,No,Yes,Yes,Yes
|
:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,No,Yes,Yes,Yes
|
||||||
:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag,Yes
|
:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag,Yes
|
||||||
:attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
|
:attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
|
||||||
@@ -10,8 +11,8 @@ Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mail
|
|||||||
:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes,Yes
|
:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes,Yes
|
||||||
.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,,
|
.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,,
|
||||||
:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
|
:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
|
||||||
:attr:`~AnymailMessage.merge_data`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
|
:attr:`~AnymailMessage.merge_data`,Yes*,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
|
||||||
:attr:`~AnymailMessage.merge_global_data`,Yes,Yes,(emulated),(emulated),Yes,Yes,No,Yes,No,Yes,Yes,Yes
|
:attr:`~AnymailMessage.merge_global_data`,Yes*,Yes,(emulated),(emulated),Yes,Yes,No,Yes,No,Yes,Yes,Yes
|
||||||
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`,,,,,,,,,,,,
|
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`,,,,,,,,,,,,
|
||||||
:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
|
:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
|
||||||
:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
|
:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
|
||||||
|
|||||||
|
@@ -48,6 +48,8 @@ The table below summarizes the Anymail features supported for each ESP.
|
|||||||
:widths: auto
|
:widths: auto
|
||||||
:class: sticky-left
|
:class: sticky-left
|
||||||
|
|
||||||
|
\* See ESP detail page for limitations and clarifications
|
||||||
|
|
||||||
Trying to choose an ESP? Please **don't** start with this table. It's far more
|
Trying to choose an ESP? Please **don't** start with this table. It's far more
|
||||||
important to consider things like an ESP's deliverability stats, latency, uptime,
|
important to consider things like an ESP's deliverability stats, latency, uptime,
|
||||||
and support for developers. The *number* of extra features an ESP offers is almost
|
and support for developers. The *number* of extra features an ESP offers is almost
|
||||||
|
|||||||
@@ -219,6 +219,10 @@ see :ref:`unsupported-features`.
|
|||||||
Any other extra headers will raise an
|
Any other extra headers will raise an
|
||||||
:exc:`~anymail.exceptions.AnymailUnsupportedFeature` error.
|
:exc:`~anymail.exceptions.AnymailUnsupportedFeature` error.
|
||||||
|
|
||||||
|
**No merge headers support**
|
||||||
|
MailerSend's API does not provide a way to support Anymail's
|
||||||
|
:attr:`~anymail.message.AnymailMessage.merge_headers`.
|
||||||
|
|
||||||
**No metadata support**
|
**No metadata support**
|
||||||
MailerSend does not support Anymail's
|
MailerSend does not support Anymail's
|
||||||
:attr:`~anymail.message.AnymailMessage.metadata` or
|
:attr:`~anymail.message.AnymailMessage.metadata` or
|
||||||
|
|||||||
@@ -247,18 +247,23 @@ Limitations and quirks
|
|||||||
obvious reasons, only the domain portion applies. You can use anything before
|
obvious reasons, only the domain portion applies. You can use anything before
|
||||||
the @, and it will be ignored.
|
the @, and it will be ignored.
|
||||||
|
|
||||||
**Using merge_metadata with merge_data**
|
**Using merge_metadata and merge_headers with merge_data**
|
||||||
If you use both Anymail's :attr:`~anymail.message.AnymailMessage.merge_data`
|
If you use both Anymail's :attr:`~anymail.message.AnymailMessage.merge_data`
|
||||||
and :attr:`~anymail.message.AnymailMessage.merge_metadata` features, make sure your
|
and :attr:`~anymail.message.AnymailMessage.merge_metadata` features, make sure your
|
||||||
merge_data keys do not start with ``v:``. (It's a good idea anyway to avoid colons
|
:attr:`~!anymail.message.AnymailMessage.merge_data` keys do not start with ``v:``.
|
||||||
and other special characters in merge_data keys, as this isn't generally portable
|
|
||||||
to other ESPs.)
|
Similarly, if you use Anymail's :attr:`~anymail.message.AnymailMessage.merge_headers`
|
||||||
|
together with :attr:`~anymail.message.AnymailMessage.merge_data`, make sure your
|
||||||
|
:attr:`~!anymail.message.AnymailMessage.merge_data` keys do not start with ``h:``.
|
||||||
|
|
||||||
|
(It's a good idea anyway to avoid colons and other special characters in merge data
|
||||||
|
keys, as this isn't generally portable to other ESPs.)
|
||||||
|
|
||||||
The same underlying Mailgun feature ("recipient-variables") is used to implement
|
The same underlying Mailgun feature ("recipient-variables") is used to implement
|
||||||
both Anymail features. To avoid conflicts, Anymail prepends ``v:`` to recipient
|
all three Anymail features. To avoid conflicts, Anymail prepends ``v:`` to recipient
|
||||||
variables needed for merge_metadata. (This prefix is stripped as Mailgun prepares
|
variables needed for merge metadata, and ``h:`` for merge headers recipient variables.
|
||||||
the message to send, so it won't be present in your Mailgun API logs or the metadata
|
(These prefixes are stripped as Mailgun prepares the message to send, so won't appear
|
||||||
that is sent to tracking webhooks.)
|
in your Mailgun API logs or the metadata that is sent to tracking webhooks.)
|
||||||
|
|
||||||
**Additional limitations on merge_data with template_id**
|
**Additional limitations on merge_data with template_id**
|
||||||
If you are using Mailgun's stored handlebars templates (Anymail's
|
If you are using Mailgun's stored handlebars templates (Anymail's
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ Limitations and quirks
|
|||||||
(Verified and reported to MailChimp support 4/2022;
|
(Verified and reported to MailChimp support 4/2022;
|
||||||
see `Anymail discussion #257`_ for more details.)
|
see `Anymail discussion #257`_ for more details.)
|
||||||
|
|
||||||
|
**No merge headers support**
|
||||||
|
Mandrill's API does not provide a way to support Anymail's
|
||||||
|
:attr:`~anymail.message.AnymailMessage.merge_headers`.
|
||||||
|
|
||||||
**Envelope sender uses only domain**
|
**Envelope sender uses only domain**
|
||||||
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
|
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
|
||||||
populate Mandrill's `'return_path_domain'`---but only the domain portion.
|
populate Mandrill's `'return_path_domain'`---but only the domain portion.
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ see :ref:`unsupported-features`.
|
|||||||
**Attachments must be named**
|
**Attachments must be named**
|
||||||
Postal issues an `AttachmentMissingName` error when trying to send an attachment without name.
|
Postal issues an `AttachmentMissingName` error when trying to send an attachment without name.
|
||||||
|
|
||||||
|
**No merge features**
|
||||||
|
Because Postal does not support batch sending, Anymail's
|
||||||
|
:attr:`~anymail.message.AnymailMessage.merge_headers`,
|
||||||
|
:attr:`~anymail.message.AnymailMessage.merge_metadata`,
|
||||||
|
and :attr:`~anymail.message.AnymailMessage.merge_data`
|
||||||
|
are not supported.
|
||||||
|
|
||||||
|
|
||||||
.. _postal-templates:
|
.. _postal-templates:
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,15 @@ Limitations and quirks
|
|||||||
|
|
||||||
.. versionadded:: 8.0
|
.. versionadded:: 8.0
|
||||||
|
|
||||||
|
**Extra header limitations**
|
||||||
|
SparkPost's API silently ignores certain email headers (specified via
|
||||||
|
Django's :ref:`headers or extra_headers <message-headers>` or Anymail's
|
||||||
|
:attr:`~anymail.message.AnymailMessage.merge_headers`). In particular,
|
||||||
|
attempts to provide a custom :mailheader:`List-Unsubscribe` header will
|
||||||
|
not work; the message will be sent with SparkPost's own subscription
|
||||||
|
management headers. (The list of allowed custom headers does not seem
|
||||||
|
to be documented.)
|
||||||
|
|
||||||
**Envelope sender may use domain only**
|
**Envelope sender may use domain only**
|
||||||
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
|
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
|
||||||
populate SparkPost's `'return_path'` parameter. Anymail supplies the full
|
populate SparkPost's `'return_path'` parameter. Anymail supplies the full
|
||||||
|
|||||||
@@ -212,6 +212,13 @@ Limitations and quirks
|
|||||||
:attr:`~anymail.message.AnymailMessage.merge_data` or
|
:attr:`~anymail.message.AnymailMessage.merge_data` or
|
||||||
:attr:`~anymail.message.AnymailMessage.merge_global_data`.
|
:attr:`~anymail.message.AnymailMessage.merge_global_data`.
|
||||||
|
|
||||||
|
**Limited merge headers support**
|
||||||
|
Unisender Go supports per-recipient :mailheader:`List-Unsubscribe` headers
|
||||||
|
(if your account has been approved to disable their unsubscribe link),
|
||||||
|
but trying to include any other field in Anymail's
|
||||||
|
:attr:`~anymail.message.AnymailMessage.merge_headers` will raise
|
||||||
|
an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error.
|
||||||
|
|
||||||
**No envelope sender overrides**
|
**No envelope sender overrides**
|
||||||
Unisender Go does not support overriding a message's
|
Unisender Go does not support overriding a message's
|
||||||
:attr:`~anymail.message.AnymailMessage.envelope_sender`.
|
:attr:`~anymail.message.AnymailMessage.envelope_sender`.
|
||||||
|
|||||||
@@ -101,6 +101,49 @@ an :ref:`unsupported feature <unsupported-features>` error.
|
|||||||
.. _how envelope sender relates to return path:
|
.. _how envelope sender relates to return path:
|
||||||
https://www.postmastery.com/blog/about-the-return-path-header/
|
https://www.postmastery.com/blog/about-the-return-path-header/
|
||||||
|
|
||||||
|
.. attribute:: merge_headers
|
||||||
|
|
||||||
|
.. versionadded:: 11.0
|
||||||
|
|
||||||
|
On a message with multiple recipients, if your ESP supports it,
|
||||||
|
you can set this to a `dict` of *per-recipient* extra email headers.
|
||||||
|
Each key in the dict is a recipient email (address portion only),
|
||||||
|
and its value is a dict of header fields and values for that recipient:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
message.to = ["wile@example.com", "R. Runner <rr@example.com>"]
|
||||||
|
message.extra_headers = {
|
||||||
|
# Headers for all recipients
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
}
|
||||||
|
message.merge_headers = {
|
||||||
|
# Per-recipient headers
|
||||||
|
"wile@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/unsubscribe/12345>",
|
||||||
|
},
|
||||||
|
"rr@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/unsubscribe/98765>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
When :attr:`!merge_headers` is set, Anymail will use the ESP's
|
||||||
|
:ref:`batch sending <batch-send>` option, so that each :attr:`to` recipient gets
|
||||||
|
an individual message (and doesn't see the other emails on the :attr:`to` list).
|
||||||
|
|
||||||
|
Many ESPs restrict which headers are allowed. Be sure to check Anymail's
|
||||||
|
:ref:`ESP-specific docs <supported-esps>` for your ESP.
|
||||||
|
(Also, :ref:`special handling <message-headers>` for :mailheader:`From`,
|
||||||
|
:mailheader:`To` and :mailheader:`Reply-To` headers does *not* apply
|
||||||
|
to :attr:`!merge_headers`.)
|
||||||
|
|
||||||
|
If :attr:`!merge_headers` defines a particular header for only some
|
||||||
|
recipients, the default for other recipients comes from the message's
|
||||||
|
:ref:`extra_headers <message-headers>`. If not defined there, behavior
|
||||||
|
varies by ESP: some will include the header field only for recipients
|
||||||
|
where you have provided it; other ESPs will send an empty header field
|
||||||
|
to the other recipients.
|
||||||
|
|
||||||
.. attribute:: metadata
|
.. attribute:: metadata
|
||||||
|
|
||||||
If your ESP supports tracking arbitrary metadata, you can set this to
|
If your ESP supports tracking arbitrary metadata, you can set this to
|
||||||
|
|||||||
@@ -568,6 +568,43 @@ class BrevoBackendAnymailFeatureTests(BrevoBackendMockAPITestCase):
|
|||||||
{"notification_batch": "zx912"},
|
{"notification_batch": "zx912"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_merge_headers(self):
|
||||||
|
self.set_mock_response(json_data=self._mock_batch_response)
|
||||||
|
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
|
||||||
|
self.message.extra_headers = {
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
}
|
||||||
|
self.message.merge_headers = {
|
||||||
|
"alice@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"bob@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
versions = data["messageVersions"]
|
||||||
|
self.assertEqual(len(versions), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
versions[0]["headers"], {"List-Unsubscribe": "<https://example.com/a/>"}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
versions[1]["headers"], {"List-Unsubscribe": "<https://example.com/b/>"}
|
||||||
|
)
|
||||||
|
self.assertNotIn("params", versions[0]) # because no merge_data
|
||||||
|
# non-merge headers still in base data
|
||||||
|
self.assertEqual(
|
||||||
|
data["headers"],
|
||||||
|
{
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_default_omits_options(self):
|
def test_default_omits_options(self):
|
||||||
"""Make sure by default we don't send any ESP-specific options.
|
"""Make sure by default we don't send any ESP-specific options.
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,18 @@ class BrevoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
|
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
|
||||||
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
|
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
|
||||||
},
|
},
|
||||||
|
headers={
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
},
|
||||||
|
merge_headers={
|
||||||
|
"test+to1@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"test+to2@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
message.attach("attachment1.txt", "Here is some\ntext", "text/plain")
|
message.attach("attachment1.txt", "Here is some\ntext", "text/plain")
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
|
|||||||
cc=["cc1@example.com", "Also CC <cc2@example.com>"],
|
cc=["cc1@example.com", "Also CC <cc2@example.com>"],
|
||||||
headers={
|
headers={
|
||||||
"Reply-To": "another@example.com",
|
"Reply-To": "another@example.com",
|
||||||
"X-MyHeader": "my value",
|
"x-my-header": "my value",
|
||||||
"Message-ID": "mycustommsgid@example.com",
|
"Message-ID": "mycustommsgid@example.com",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -126,8 +126,8 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(data["cc"], ["cc1@example.com", "Also CC <cc2@example.com>"])
|
self.assertEqual(data["cc"], ["cc1@example.com", "Also CC <cc2@example.com>"])
|
||||||
self.assertEqual(data["h:Reply-To"], "another@example.com")
|
self.assertEqual(data["h:Reply-To"], "another@example.com")
|
||||||
self.assertEqual(data["h:X-MyHeader"], "my value")
|
self.assertEqual(data["h:X-My-Header"], "my value")
|
||||||
self.assertEqual(data["h:Message-ID"], "mycustommsgid@example.com")
|
self.assertEqual(data["h:Message-Id"], "mycustommsgid@example.com")
|
||||||
# multiple recipients, but not a batch send:
|
# multiple recipients, but not a batch send:
|
||||||
self.assertNotIn("recipient-variables", data)
|
self.assertNotIn("recipient-variables", data)
|
||||||
|
|
||||||
@@ -816,6 +816,51 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase):
|
|||||||
):
|
):
|
||||||
self.message.send()
|
self.message.send()
|
||||||
|
|
||||||
|
def test_merge_headers(self):
|
||||||
|
# Per-recipient merge_headers uses the same recipient-variables mechanism
|
||||||
|
# as above, using variable names starting with "h:"
|
||||||
|
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
|
||||||
|
self.message.extra_headers = {
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
"X-Custom": "custom-default",
|
||||||
|
}
|
||||||
|
self.message.merge_headers = {
|
||||||
|
"alice@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
"X-No-Default": "custom-for-alice",
|
||||||
|
},
|
||||||
|
"bob@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
"X-Custom": "custom-for-bob",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
data = self.get_api_call_data()
|
||||||
|
# non-merge header has fixed value:
|
||||||
|
self.assertEqual(data["h:List-Unsubscribe-Post"], "List-Unsubscribe=One-Click")
|
||||||
|
# merge headers refer to recipient-variables:
|
||||||
|
self.assertEqual(data["h:List-Unsubscribe"], "%recipient.h:List-Unsubscribe%")
|
||||||
|
self.assertEqual(data["h:X-Custom"], "%recipient.h:X-Custom%")
|
||||||
|
self.assertEqual(data["h:X-No-Default"], "%recipient.h:X-No-Default%")
|
||||||
|
# recipient-variables populates them:
|
||||||
|
self.assertJSONEqual(
|
||||||
|
data["recipient-variables"],
|
||||||
|
{
|
||||||
|
"alice@example.com": {
|
||||||
|
"h:List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
"h:X-Custom": "custom-default", # from extra_headers
|
||||||
|
"h:X-No-Default": "custom-for-alice",
|
||||||
|
},
|
||||||
|
"bob@example.com": {
|
||||||
|
"h:List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
"h:X-Custom": "custom-for-bob",
|
||||||
|
"h:X-No-Default": "", # no default in extra_headers
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_force_batch(self):
|
def test_force_batch(self):
|
||||||
# Mailgun uses presence of recipient-variables to indicate batch send
|
# Mailgun uses presence of recipient-variables to indicate batch send
|
||||||
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
|
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
|
||||||
|
|||||||
@@ -201,6 +201,36 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
# (We could try fetching the message from event["storage"]["url"]
|
# (We could try fetching the message from event["storage"]["url"]
|
||||||
# to verify content and other headers.)
|
# to verify content and other headers.)
|
||||||
|
|
||||||
|
def test_per_recipient_options(self):
|
||||||
|
message = AnymailMessage(
|
||||||
|
from_email=formataddr(("Test From", self.from_email)),
|
||||||
|
to=["test+to1@anymail.dev", '"Recipient 2" <test+to2@anymail.dev>'],
|
||||||
|
subject="Anymail Mailgun per-recipient options test",
|
||||||
|
body="This is the text body",
|
||||||
|
merge_metadata={
|
||||||
|
"test+to1@anymail.dev": {"meta1": "one", "meta2": "two"},
|
||||||
|
"test+to2@anymail.dev": {"meta1": "recipient 2"},
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
"X-Custom-Header": "default",
|
||||||
|
},
|
||||||
|
merge_headers={
|
||||||
|
"test+to1@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
"X-Custom-Header": "custom",
|
||||||
|
},
|
||||||
|
"test+to2@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
message.send()
|
||||||
|
recipient_status = message.anymail_status.recipients
|
||||||
|
self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "queued")
|
||||||
|
self.assertEqual(recipient_status["test+to2@anymail.dev"].status, "queued")
|
||||||
|
|
||||||
def test_stored_template(self):
|
def test_stored_template(self):
|
||||||
message = AnymailMessage(
|
message = AnymailMessage(
|
||||||
# name of a real template named in Anymail's Mailgun test account:
|
# name of a real template named in Anymail's Mailgun test account:
|
||||||
|
|||||||
@@ -562,6 +562,45 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
|
|||||||
{"order_id": 678, "notification_batch": "zx912"},
|
{"order_id": 678, "notification_batch": "zx912"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_merge_headers(self):
|
||||||
|
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
|
||||||
|
self.message.extra_headers = {
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
}
|
||||||
|
self.message.merge_headers = {
|
||||||
|
"alice@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"bob@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
messages = data["Messages"]
|
||||||
|
self.assertEqual(len(messages), 2)
|
||||||
|
self.assertEqual(messages[0]["To"][0]["Email"], "alice@example.com")
|
||||||
|
self.assertEqual(
|
||||||
|
messages[0]["Headers"],
|
||||||
|
{"List-Unsubscribe": "<https://example.com/a/>"},
|
||||||
|
)
|
||||||
|
self.assertEqual(messages[1]["To"][0]["Email"], "bob@example.com")
|
||||||
|
self.assertEqual(
|
||||||
|
messages[1]["Headers"],
|
||||||
|
{"List-Unsubscribe": "<https://example.com/b/>"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# non-merge headers still in globals:
|
||||||
|
self.assertEqual(
|
||||||
|
data["Globals"]["Headers"],
|
||||||
|
{
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_default_omits_options(self):
|
def test_default_omits_options(self):
|
||||||
"""Make sure by default we don't send any ESP-specific options.
|
"""Make sure by default we don't send any ESP-specific options.
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,23 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
"test+to2@anymail.dev": {"value": "two"},
|
"test+to2@anymail.dev": {"value": "two"},
|
||||||
},
|
},
|
||||||
merge_global_data={"global": "global_value"},
|
merge_global_data={"global": "global_value"},
|
||||||
|
metadata={"customer-id": "unknown", "meta2": 2},
|
||||||
|
merge_metadata={
|
||||||
|
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
|
||||||
|
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
},
|
||||||
|
merge_headers={
|
||||||
|
"test+to1@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"test+to2@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
message.send()
|
message.send()
|
||||||
recipient_status = message.anymail_status.recipients
|
recipient_status = message.anymail_status.recipients
|
||||||
|
|||||||
@@ -712,6 +712,49 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase):
|
|||||||
self.assertEqual(messages[1]["To"], "Bob <bob@example.com>")
|
self.assertEqual(messages[1]["To"], "Bob <bob@example.com>")
|
||||||
self.assertEqual(messages[1]["Metadata"], {"order_id": 678, "tier": "premium"})
|
self.assertEqual(messages[1]["Metadata"], {"order_id": 678, "tier": "premium"})
|
||||||
|
|
||||||
|
def test_merge_headers(self):
|
||||||
|
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
|
||||||
|
self.message.extra_headers = {
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
}
|
||||||
|
self.message.merge_headers = {
|
||||||
|
"alice@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"bob@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
self.assert_esp_called("/email/batch")
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(len(data), 2)
|
||||||
|
# Global and merge headers are combined:
|
||||||
|
self.assertEqual(data[0]["To"], "alice@example.com")
|
||||||
|
self.assertCountEqual(
|
||||||
|
data[0]["Headers"],
|
||||||
|
[
|
||||||
|
{"Name": "List-Unsubscribe", "Value": "<https://example.com/a/>"},
|
||||||
|
{
|
||||||
|
"Name": "List-Unsubscribe-Post",
|
||||||
|
"Value": "List-Unsubscribe=One-Click",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(data[1]["To"], "Bob <bob@example.com>")
|
||||||
|
self.assertCountEqual(
|
||||||
|
data[1]["Headers"],
|
||||||
|
[
|
||||||
|
{"Name": "List-Unsubscribe", "Value": "<https://example.com/b/>"},
|
||||||
|
{
|
||||||
|
"Name": "List-Unsubscribe-Post",
|
||||||
|
"Value": "List-Unsubscribe=One-Click",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_default_omits_options(self):
|
def test_default_omits_options(self):
|
||||||
"""Make sure by default we don't send any ESP-specific options.
|
"""Make sure by default we don't send any ESP-specific options.
|
||||||
|
|
||||||
|
|||||||
@@ -68,13 +68,29 @@ class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
cc=["test+cc1@anymail.dev", "Copy 2 <test+cc2@anymail.dev>"],
|
cc=["test+cc1@anymail.dev", "Copy 2 <test+cc2@anymail.dev>"],
|
||||||
bcc=["test+bcc1@anymail.dev", "Blind Copy 2 <test+bcc2@anymail.dev>"],
|
bcc=["test+bcc1@anymail.dev", "Blind Copy 2 <test+bcc2@anymail.dev>"],
|
||||||
reply_to=["reply1@example.com", "Reply 2 <reply2@example.com>"],
|
reply_to=["reply1@example.com", "Reply 2 <reply2@example.com>"],
|
||||||
headers={"X-Anymail-Test": "value"},
|
headers={
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
},
|
||||||
# no send_at support
|
# no send_at support
|
||||||
metadata={"meta1": "simple string", "meta2": 2},
|
metadata={"meta1": "simple string", "meta2": 2},
|
||||||
tags=["tag 1"], # max one tag
|
tags=["tag 1"], # max one tag
|
||||||
track_opens=True,
|
track_opens=True,
|
||||||
track_clicks=True,
|
track_clicks=True,
|
||||||
merge_data={}, # force batch send (distinct message for each `to`)
|
# either of these merge_ options will force batch send
|
||||||
|
# (unique message for each "to" recipient)
|
||||||
|
merge_metadata={
|
||||||
|
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
|
||||||
|
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
|
||||||
|
},
|
||||||
|
merge_headers={
|
||||||
|
"test+to1@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"test+to2@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
|
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
|
||||||
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
|
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
|
||||||
|
|||||||
@@ -533,6 +533,49 @@ class ResendBackendAnymailFeatureTests(ResendBackendMockAPITestCase):
|
|||||||
"faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb",
|
"faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_merge_headers(self):
|
||||||
|
self.set_mock_response(json_data=self._mock_batch_response)
|
||||||
|
message = AnymailMessage(
|
||||||
|
from_email="from@example.com",
|
||||||
|
to=["alice@example.com", "Bob <bob@example.com>"],
|
||||||
|
headers={
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
},
|
||||||
|
merge_headers={
|
||||||
|
"alice@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"bob@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
message.send()
|
||||||
|
|
||||||
|
# merge_headers forces batch send API:
|
||||||
|
self.assert_esp_called("/emails/batch")
|
||||||
|
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(len(data), 2)
|
||||||
|
self.assertEqual(data[0]["to"], ["alice@example.com"])
|
||||||
|
# global and recipient headers are combined:
|
||||||
|
self.assertEqual(
|
||||||
|
data[0]["headers"],
|
||||||
|
{
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(data[1]["to"], ["Bob <bob@example.com>"])
|
||||||
|
self.assertEqual(
|
||||||
|
data[1]["headers"],
|
||||||
|
{
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_track_opens(self):
|
def test_track_opens(self):
|
||||||
self.message.track_opens = True
|
self.message.track_opens = True
|
||||||
with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"):
|
with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"):
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
) # non-empty string
|
) # non-empty string
|
||||||
|
|
||||||
def test_batch_send(self):
|
def test_batch_send(self):
|
||||||
# merge_metadata or merge_data will use batch send API
|
# merge_metadata, merge_headers, or merge_data will use batch send API
|
||||||
message = AnymailMessage(
|
message = AnymailMessage(
|
||||||
subject="Anymail Resend batch sendintegration test",
|
subject="Anymail Resend batch sendintegration test",
|
||||||
body="This is the text body",
|
body="This is the text body",
|
||||||
@@ -99,6 +99,18 @@ class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
"test+to2@anymail.dev": {"meta3": "recipient 2"},
|
"test+to2@anymail.dev": {"meta3": "recipient 2"},
|
||||||
},
|
},
|
||||||
tags=["tag 1", "tag 2"],
|
tags=["tag 1", "tag 2"],
|
||||||
|
headers={
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
},
|
||||||
|
merge_headers={
|
||||||
|
"test+to1@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"test+to2@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
message.attach_alternative("<p>HTML content</p>", "text/html")
|
message.attach_alternative("<p>HTML content</p>", "text/html")
|
||||||
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
|
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
|
||||||
|
|||||||
@@ -1016,6 +1016,45 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_merge_headers(self):
|
||||||
|
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
|
||||||
|
self.message.extra_headers = {
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
}
|
||||||
|
self.message.merge_headers = {
|
||||||
|
"alice@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"bob@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
personalizations = data["personalizations"]
|
||||||
|
self.assertEqual(len(personalizations), 2)
|
||||||
|
|
||||||
|
self.assertEqual(personalizations[0]["to"][0]["email"], "alice@example.com")
|
||||||
|
self.assertEqual(
|
||||||
|
personalizations[0]["headers"],
|
||||||
|
{"List-Unsubscribe": "<https://example.com/a/>"},
|
||||||
|
)
|
||||||
|
self.assertEqual(personalizations[1]["to"][0]["email"], "bob@example.com")
|
||||||
|
self.assertEqual(
|
||||||
|
personalizations[1]["headers"],
|
||||||
|
{"List-Unsubscribe": "<https://example.com/b/>"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# non-merge headers still in globals:
|
||||||
|
self.assertEqual(
|
||||||
|
data["headers"],
|
||||||
|
{
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False # else we force custom_args
|
ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False # else we force custom_args
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -119,6 +119,23 @@ class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
esp_extra={
|
esp_extra={
|
||||||
"merge_field_format": "%{}%",
|
"merge_field_format": "%{}%",
|
||||||
},
|
},
|
||||||
|
metadata={"meta1": "simple string", "meta2": 2},
|
||||||
|
merge_metadata={
|
||||||
|
"to1@sink.sendgrid.net": {"meta3": "recipient 1"},
|
||||||
|
"to2@sink.sendgrid.net": {"meta3": "recipient 2"},
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
},
|
||||||
|
merge_headers={
|
||||||
|
"to1@sink.sendgrid.net": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"to2@sink.sendgrid.net": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
message.send()
|
message.send()
|
||||||
recipient_status = message.anymail_status.recipients
|
recipient_status = message.anymail_status.recipients
|
||||||
|
|||||||
@@ -605,6 +605,58 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(data["metadata"], {"notification_batch": "zx912"})
|
self.assertEqual(data["metadata"], {"notification_batch": "zx912"})
|
||||||
|
|
||||||
|
def test_merge_headers(self):
|
||||||
|
self.set_mock_result(accepted=2)
|
||||||
|
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
|
||||||
|
self.message.extra_headers = {
|
||||||
|
"X-Custom-1": "custom 1",
|
||||||
|
"X-Custom-2": "custom 2 (default)",
|
||||||
|
}
|
||||||
|
self.message.merge_headers = {
|
||||||
|
"alice@example.com": {
|
||||||
|
"X-Custom-2": "custom 2 alice",
|
||||||
|
"X-Custom-3": "custom 3 alice",
|
||||||
|
},
|
||||||
|
"bob@example.com": {"X-Custom-2": "custom 2 bob"},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
recipients = data["recipients"]
|
||||||
|
self.assertEqual(len(recipients), 2)
|
||||||
|
self.assertEqual(recipients[0]["address"]["email"], "alice@example.com")
|
||||||
|
self.assertEqual(
|
||||||
|
recipients[0]["substitution_data"],
|
||||||
|
{
|
||||||
|
"Header__X_Custom_2": "custom 2 alice",
|
||||||
|
"Header__X_Custom_3": "custom 3 alice",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(recipients[1]["address"]["email"], "bob@example.com")
|
||||||
|
self.assertEqual(
|
||||||
|
recipients[1]["substitution_data"],
|
||||||
|
{
|
||||||
|
"Header__X_Custom_2": "custom 2 bob",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Indirect merge_headers through template substitutions:
|
||||||
|
self.assertEqual(
|
||||||
|
data["content"]["headers"],
|
||||||
|
{
|
||||||
|
"X-Custom-1": "custom 1", # (not a merge_header, value unchanged)
|
||||||
|
"X-Custom-2": "{{Header__X_Custom_2}}",
|
||||||
|
"X-Custom-3": "{{Header__X_Custom_3}}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Defaults for merge_headers in global substitution_data:
|
||||||
|
self.assertEqual(
|
||||||
|
data["substitution_data"],
|
||||||
|
{
|
||||||
|
"Header__X_Custom_2": "custom 2 (default)",
|
||||||
|
# No default specified for X-Custom-3; SparkPost will use empty string
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_default_omits_options(self):
|
def test_default_omits_options(self):
|
||||||
"""Make sure by default we don't send any ESP-specific options.
|
"""Make sure by default we don't send any ESP-specific options.
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,19 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
"to2@test.sink.sparkpostmail.com": {"value": "two"},
|
"to2@test.sink.sparkpostmail.com": {"value": "two"},
|
||||||
},
|
},
|
||||||
merge_global_data={"global": "global_value"},
|
merge_global_data={"global": "global_value"},
|
||||||
|
merge_metadata={
|
||||||
|
"to1@test.sink.sparkpostmail.com": {"meta1": "one"},
|
||||||
|
"to2@test.sink.sparkpostmail.com": {"meta1": "two"},
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"X-Custom": "custom header default",
|
||||||
|
},
|
||||||
|
merge_headers={
|
||||||
|
# (Note that SparkPost doesn't support custom List-Unsubscribe headers)
|
||||||
|
"to1@test.sink.sparkpostmail.com": {
|
||||||
|
"X-Custom": "custom header one",
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
message.send()
|
message.send()
|
||||||
recipient_status = message.anymail_status.recipients
|
recipient_status = message.anymail_status.recipients
|
||||||
|
|||||||
@@ -602,12 +602,53 @@ class UnisenderGoBackendAnymailFeatureTests(UnisenderGoBackendMockAPITestCase):
|
|||||||
self.assertNotIn("to", headers)
|
self.assertNotIn("to", headers)
|
||||||
self.assertNotIn("cc", headers)
|
self.assertNotIn("cc", headers)
|
||||||
|
|
||||||
|
def test_merge_headers(self):
|
||||||
|
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
|
||||||
|
self.message.extra_headers = {
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
}
|
||||||
|
self.message.merge_headers = {
|
||||||
|
"alice@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"bob@example.com": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
headers = data["message"]["headers"]
|
||||||
|
recipients = data["message"]["recipients"]
|
||||||
|
|
||||||
|
self.assertEqual(headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click")
|
||||||
|
|
||||||
|
# merge_headers List-Unsubscribe is handled as substitution:
|
||||||
|
self.assertEqual(headers["List-Unsubscribe"], "{{Header__List_Unsubscribe}}")
|
||||||
|
self.assertEqual(
|
||||||
|
recipients[0]["substitutions"],
|
||||||
|
{"Header__List_Unsubscribe": "<https://example.com/a/>"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
recipients[1]["substitutions"],
|
||||||
|
# Header substitutions merged with other substitutions:
|
||||||
|
{"Header__List_Unsubscribe": "<https://example.com/b/>", "to_name": "Bob"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unsupported_merge_headers(self):
|
||||||
|
# Unisender Go only allows substitutions in the List-Unsubscribe header
|
||||||
|
self.message.merge_headers = {"to@example.com": {"X-Other": "not supported"}}
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
AnymailUnsupportedFeature, "'X-Other' in merge_headers"
|
||||||
|
):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
def test_cc_unsupported_with_batch_send(self):
|
def test_cc_unsupported_with_batch_send(self):
|
||||||
self.message.merge_data = {}
|
self.message.merge_data = {}
|
||||||
self.message.cc = ["cc@example.com"]
|
self.message.cc = ["cc@example.com"]
|
||||||
with self.assertRaisesMessage(
|
with self.assertRaisesMessage(
|
||||||
AnymailUnsupportedFeature,
|
AnymailUnsupportedFeature,
|
||||||
"cc with batch send (merge_data or merge_metadata)",
|
"cc with batch send (merge_data, merge_metadata, or merge_headers)",
|
||||||
):
|
):
|
||||||
self.message.send()
|
self.message.send()
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,18 @@ class UnisenderGoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
|
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
|
||||||
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
|
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
|
||||||
},
|
},
|
||||||
|
headers={
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
|
||||||
|
},
|
||||||
|
merge_headers={
|
||||||
|
"test+to1@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/a/>",
|
||||||
|
},
|
||||||
|
"test+to2@anymail.dev": {
|
||||||
|
"List-Unsubscribe": "<https://example.com/b/>",
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
message.from_email = None # use template sender
|
message.from_email = None # use template sender
|
||||||
message.attach("attachment1.txt", "Here is some\ntext", "text/plain")
|
message.attach("attachment1.txt", "Here is some\ntext", "text/plain")
|
||||||
|
|||||||
Reference in New Issue
Block a user