Skip to content

Commit 0776b12

Browse files
authored
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
1 parent 6e696b8 commit 0776b12

35 files changed

+754
-40
lines changed

CHANGELOG.rst

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,12 @@ Breaking changes
4444
Features
4545
~~~~~~~~
4646

47-
* **Amazon SES:** Add new ``merge_headers`` option for per-recipient
48-
headers with template sends. (Requires boto3 >= 1.34.98.)
49-
(Thanks to `@carrerasrodrigo`_ the implementation.)
47+
* Add new ``merge_headers`` option for per-recipient headers with batch sends.
48+
This can be helpful to send individual *List-Unsubscribe* headers (for example).
49+
Supported for all current ESPs *except* MailerSend, Mandrill and Postal. See
50+
`docs <https://anymail.dev/en/latest/sending/anymail_additions/#anymail.message.AnymailMessage.merge_headers>`__.
51+
(Thanks to `@carrerasrodrigo`_ for the idea, and for the base and
52+
Amazon SES implementations.)
5053

5154
* **Amazon SES:** Allow extra headers, ``metadata``, ``merge_metadata``,
5255
and ``tags`` when sending with a ``template_id``.

anymail/backends/brevo.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -91,28 +91,32 @@ def init_payload(self):
9191
self.merge_data = {}
9292
self.metadata = {}
9393
self.merge_metadata = {}
94+
self.merge_headers = {}
9495

9596
def serialize_data(self):
9697
"""Performs any necessary serialization on self.data, and returns the result."""
9798
if self.is_batch():
9899
# Burst data["to"] into data["messageVersions"]
99100
to_list = self.data.pop("to", [])
100-
self.data["messageVersions"] = [
101-
{"to": [to], "params": self.merge_data.get(to["email"])}
102-
for to in to_list
103-
]
104-
if self.merge_metadata:
105-
# Merge global metadata with any per-recipient metadata.
106-
# (Top-level X-Mailin-custom header is already set to global metadata,
107-
# and will apply for recipients without a "headers" override.)
108-
for version in self.data["messageVersions"]:
109-
to_email = version["to"][0]["email"]
110-
if to_email in self.merge_metadata:
111-
recipient_metadata = self.metadata.copy()
112-
recipient_metadata.update(self.merge_metadata[to_email])
113-
version["headers"] = {
114-
"X-Mailin-custom": self.serialize_json(recipient_metadata)
115-
}
101+
self.data["messageVersions"] = []
102+
for to in to_list:
103+
to_email = to["email"]
104+
version = {"to": [to]}
105+
headers = CaseInsensitiveDict()
106+
if to_email in self.merge_data:
107+
version["params"] = self.merge_data[to_email]
108+
if to_email in self.merge_metadata:
109+
# Merge global metadata with any per-recipient metadata.
110+
# (Top-level X-Mailin-custom header already has global metadata,
111+
# and will apply for recipients without version headers.)
112+
recipient_metadata = self.metadata.copy()
113+
recipient_metadata.update(self.merge_metadata[to_email])
114+
headers["X-Mailin-custom"] = self.serialize_json(recipient_metadata)
115+
if to_email in self.merge_headers:
116+
headers.update(self.merge_headers[to_email])
117+
if headers:
118+
version["headers"] = headers
119+
self.data["messageVersions"].append(version)
116120

117121
if not self.data["headers"]:
118122
del self.data["headers"] # don't send empty headers
@@ -212,6 +216,10 @@ def set_merge_metadata(self, merge_metadata):
212216
# Late-bound in serialize_data:
213217
self.merge_metadata = merge_metadata
214218

219+
def set_merge_headers(self, merge_headers):
220+
# Late-bound in serialize_data:
221+
self.merge_headers = merge_headers
222+
215223
def set_send_at(self, send_at):
216224
try:
217225
start_time_iso = send_at.isoformat(timespec="milliseconds")

anymail/backends/mailgun.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
117117
self.merge_global_data = {}
118118
self.metadata = {}
119119
self.merge_metadata = {}
120+
self.merge_headers = {}
120121
self.to_emails = []
121122

122123
super().__init__(message, defaults, backend, auth=auth, *args, **kwargs)
@@ -191,6 +192,8 @@ def serialize_data(self):
191192
# (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks
192193
# up its per-recipient value from Mailgun's
193194
# `recipient-variables[to_email]["name"]`.)
195+
# (6) Anymail's `merge_headers` (per-recipient headers) maps to recipient-variables
196+
# prepended with 'h:'.
194197
#
195198
# If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or
196199
# `merge_metadata`) are used together, there's a possibility of conflicting keys
@@ -268,6 +271,40 @@ def vkey(key): # 'v:key'
268271
{key: "%recipient.{}%".format(key) for key in merge_data_keys}
269272
)
270273

274+
# (6) merge_headers --> Mailgun recipient_variables via 'h:'-prefixed keys
275+
if self.merge_headers:
276+
277+
def hkey(field_name): # 'h:Field-Name'
278+
return "h:{}".format(field_name.title())
279+
280+
merge_header_fields = flatset(
281+
recipient_headers.keys()
282+
for recipient_headers in self.merge_headers.values()
283+
)
284+
merge_header_defaults = {
285+
# existing h:Field-Name value (from extra_headers), or empty string
286+
field: self.data.get(hkey(field), "")
287+
for field in merge_header_fields
288+
}
289+
self.data.update(
290+
# Set up 'h:Field-Name': '%recipient.h:Field-Name%' indirection
291+
{
292+
hvar: f"%recipient.{hvar}%"
293+
for hvar in [hkey(field) for field in merge_header_fields]
294+
}
295+
)
296+
297+
for email in self.to_emails:
298+
# Each recipient's recipient_variables needs _all_ merge header fields
299+
recipient_headers = merge_header_defaults.copy()
300+
recipient_headers.update(self.merge_headers.get(email, {}))
301+
recipient_variables_for_headers = {
302+
hkey(field): value for field, value in recipient_headers.items()
303+
}
304+
recipient_variables.setdefault(email, {}).update(
305+
recipient_variables_for_headers
306+
)
307+
271308
# populate Mailgun params
272309
self.data.update({"v:%s" % key: value for key, value in custom_data.items()})
273310
if recipient_variables or self.is_batch():
@@ -308,8 +345,8 @@ def set_reply_to(self, emails):
308345
self.data["h:Reply-To"] = reply_to
309346

310347
def set_extra_headers(self, headers):
311-
for key, value in headers.items():
312-
self.data["h:%s" % key] = value
348+
for field, value in headers.items():
349+
self.data["h:%s" % field.title()] = value
313350

314351
def set_text_body(self, body):
315352
self.data["text"] = body
@@ -385,6 +422,9 @@ def set_merge_metadata(self, merge_metadata):
385422
# Processed at serialization time (to allow combining with merge_data)
386423
self.merge_metadata = merge_metadata
387424

425+
def set_merge_headers(self, merge_headers):
426+
self.merge_headers = merge_headers
427+
388428
def set_esp_extra(self, extra):
389429
self.data.update(extra)
390430
# Allow override of sender_domain via esp_extra

anymail/backends/mailjet.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,13 @@ def set_merge_metadata(self, merge_metadata):
225225
recipient_metadata = merge_metadata[email]
226226
message["EventPayload"] = self.serialize_json(recipient_metadata)
227227

228+
def set_merge_headers(self, merge_headers):
229+
self._burst_for_batch_send()
230+
for message in self.data["Messages"]:
231+
email = message["To"][0]["Email"]
232+
if email in merge_headers:
233+
message["Headers"] = merge_headers[email]
234+
228235
def set_tags(self, tags):
229236
# The choices here are CustomID or Campaign, and Campaign seems closer
230237
# to how "tags" are handled by other ESPs -- e.g., you can view dashboard

anymail/backends/postmark.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import re
22

3+
from requests.structures import CaseInsensitiveDict
4+
35
from ..exceptions import AnymailRequestsAPIError
46
from ..message import AnymailRecipientStatus
57
from ..utils import (
@@ -209,6 +211,7 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
209211
self.cc_and_bcc_emails = [] # needed for parse_recipient_status
210212
self.merge_data = None
211213
self.merge_metadata = None
214+
self.merge_headers = {}
212215
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
213216

214217
def get_api_endpoint(self):
@@ -274,6 +277,18 @@ def data_for_recipient(self, to):
274277
data["Metadata"].update(recipient_metadata)
275278
else:
276279
data["Metadata"] = recipient_metadata
280+
if to.addr_spec in self.merge_headers:
281+
if "Headers" in data:
282+
# merge global and recipient headers
283+
headers = CaseInsensitiveDict(
284+
(item["Name"], item["Value"]) for item in data["Headers"]
285+
)
286+
headers.update(self.merge_headers[to.addr_spec])
287+
else:
288+
headers = self.merge_headers[to.addr_spec]
289+
data["Headers"] = [
290+
{"Name": name, "Value": value} for name, value in headers.items()
291+
]
277292
return data
278293

279294
#
@@ -383,6 +398,10 @@ def set_merge_metadata(self, merge_metadata):
383398
# late-bind
384399
self.merge_metadata = merge_metadata
385400

401+
def set_merge_headers(self, merge_headers):
402+
# late-bind
403+
self.merge_headers = merge_headers
404+
386405
def set_esp_extra(self, extra):
387406
self.data.update(extra)
388407
# Special handling for 'server_token':

anymail/backends/resend.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
9898
self.to_recipients = [] # for parse_recipient_status
9999
self.metadata = {}
100100
self.merge_metadata = {}
101+
self.merge_headers = {}
101102
headers = kwargs.pop("headers", {})
102103
headers["Authorization"] = "Bearer %s" % backend.api_key
103104
headers["Content-Type"] = "application/json"
@@ -129,6 +130,14 @@ def serialize_data(self):
129130
data["headers"]["X-Metadata"] = self.serialize_json(
130131
recipient_metadata
131132
)
133+
if to.addr_spec in self.merge_headers:
134+
if "headers" in data:
135+
# Merge global headers (or X-Metadata from above)
136+
headers = CaseInsensitiveCasePreservingDict(data["headers"])
137+
headers.update(self.merge_headers[to.addr_spec])
138+
else:
139+
headers = self.merge_headers[to.addr_spec]
140+
data["headers"] = headers
132141
payload.append(data)
133142

134143
return self.serialize_json(payload)
@@ -284,5 +293,8 @@ def set_merge_data(self, merge_data):
284293
def set_merge_metadata(self, merge_metadata):
285294
self.merge_metadata = merge_metadata # late bound in serialize_data
286295

296+
def set_merge_headers(self, merge_headers):
297+
self.merge_headers = merge_headers # late bound in serialize_data
298+
287299
def set_esp_extra(self, extra):
288300
self.data.update(extra)

anymail/backends/sendgrid.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
9292
self.merge_data = {} # late-bound per-recipient data
9393
self.merge_global_data = {}
9494
self.merge_metadata = {}
95+
self.merge_headers = {}
9596

9697
http_headers = kwargs.pop("headers", {})
9798
http_headers["Authorization"] = "Bearer %s" % backend.api_key
@@ -116,6 +117,7 @@ def serialize_data(self):
116117
self.expand_personalizations_for_batch()
117118
self.build_merge_data()
118119
self.build_merge_metadata()
120+
self.build_merge_headers()
119121
if self.generate_message_id:
120122
self.set_anymail_id()
121123

@@ -216,6 +218,15 @@ def build_merge_metadata(self):
216218
recipient_custom_args = self.transform_metadata(recipient_metadata)
217219
personalization["custom_args"] = recipient_custom_args
218220

221+
def build_merge_headers(self):
222+
if self.merge_headers:
223+
for personalization in self.data["personalizations"]:
224+
assert len(personalization["to"]) == 1
225+
recipient_email = personalization["to"][0]["email"]
226+
recipient_headers = self.merge_headers.get(recipient_email)
227+
if recipient_headers:
228+
personalization["headers"] = recipient_headers
229+
219230
#
220231
# Payload construction
221232
#
@@ -374,6 +385,11 @@ def set_merge_metadata(self, merge_metadata):
374385
# and merge_field_format.
375386
self.merge_metadata = merge_metadata
376387

388+
def set_merge_headers(self, merge_headers):
389+
# Becomes personalizations[...]['headers'] in
390+
# build_merge_data
391+
self.merge_headers = merge_headers
392+
377393
def set_esp_extra(self, extra):
378394
self.merge_field_format = extra.pop(
379395
"merge_field_format", self.merge_field_format

anymail/backends/sparkpost.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,36 @@ def set_merge_metadata(self, merge_metadata):
242242
if to_email in merge_metadata:
243243
recipient["metadata"] = merge_metadata[to_email]
244244

245+
def set_merge_headers(self, merge_headers):
246+
def header_var(field):
247+
return "Header__" + field.title().replace("-", "_")
248+
249+
merge_header_fields = set()
250+
251+
for recipient in self.data["recipients"]:
252+
to_email = recipient["address"]["email"]
253+
if to_email in merge_headers:
254+
recipient_headers = merge_headers[to_email]
255+
recipient.setdefault("substitution_data", {}).update(
256+
{header_var(key): value for key, value in recipient_headers.items()}
257+
)
258+
merge_header_fields.update(recipient_headers.keys())
259+
260+
if merge_header_fields:
261+
headers = self.data.setdefault("content", {}).setdefault("headers", {})
262+
# Global substitution_data supplies defaults for defined headers:
263+
self.data.setdefault("substitution_data", {}).update(
264+
{
265+
header_var(field): headers[field]
266+
for field in merge_header_fields
267+
if field in headers
268+
}
269+
)
270+
# Indirect merge_headers through substitution_data:
271+
headers.update(
272+
{field: "{{%s}}" % header_var(field) for field in merge_header_fields}
273+
)
274+
245275
def set_send_at(self, send_at):
246276
try:
247277
start_time = send_at.replace(microsecond=0).isoformat()

anymail/backends/unisender_go.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def serialize_data(self) -> str:
161161
headers.pop("to", None)
162162
if headers.pop("cc", None):
163163
self.unsupported_feature(
164-
"cc with batch send (merge_data or merge_metadata)"
164+
"cc with batch send (merge_data, merge_metadata, or merge_headers)"
165165
)
166166

167167
if not headers:
@@ -339,5 +339,26 @@ def set_merge_metadata(self, merge_metadata: dict[str, str]) -> None:
339339
if recipient_email in merge_metadata:
340340
recipient["metadata"] = merge_metadata[recipient_email]
341341

342+
# Unisender Go supports header substitution only with List-Unsubscribe.
343+
# (See https://godocs.unisender.ru/web-api-ref#email-send under "substitutions".)
344+
SUPPORTED_MERGE_HEADERS = {"List-Unsubscribe"}
345+
346+
def set_merge_headers(self, merge_headers: dict[str, dict[str, str]]) -> None:
347+
assert self.data["recipients"] # must be called after set_to
348+
if merge_headers:
349+
for recipient in self.data["recipients"]:
350+
recipient_email = recipient["email"]
351+
for key, value in merge_headers.get(recipient_email, {}).items():
352+
field = key.title() # canonicalize field name capitalization
353+
if field in self.SUPPORTED_MERGE_HEADERS:
354+
# Set up a substitution for Header__Field_Name
355+
field_sub = "Header__" + field.replace("-", "_")
356+
recipient.setdefault("substitutions", {})[field_sub] = value
357+
self.data.setdefault("headers", {})[field] = (
358+
"{{%s}}" % field_sub
359+
)
360+
else:
361+
self.unsupported_feature(f"{field!r} in merge_headers")
362+
342363
def set_esp_extra(self, extra: dict) -> None:
343364
update_deep(self.data, extra)

docs/esps/amazon_ses.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ Limitations and quirks
9696
**No delayed sending**
9797
Amazon SES does not support :attr:`~anymail.message.AnymailMessage.send_at`.
9898

99+
**Merge features require template_id**
100+
Anymail's :attr:`~anymail.message.AnymailMessage.merge_headers`,
101+
:attr:`~anymail.message.AnymailMessage.merge_metadata`,
102+
:attr:`~anymail.message.AnymailMessage.merge_data`, and
103+
:attr:`~anymail.message.AnymailMessage.merge_global_data` are only supported
104+
when sending :ref:`templated messages <amazon-ses-templates>`
105+
(using Anymail's :attr:`~anymail.message.AnymailMessage.template_id`).
106+
99107
**No global send defaults for non-Anymail options**
100108
With the Amazon SES backend, Anymail's :ref:`global send defaults <send-defaults>`
101109
are only supported for Anymail's added message options (like

0 commit comments

Comments
 (0)