Skip to content

Commit 33f6806

Browse files
Add merge_headers option for Amazon SES
Add new `merge_headers` message option for per-recipient headers with template sends. * Support in base backend * Implement in Amazon SES backend (Requires boto3 >= 1.34.98.) --------- Co-authored-by: Mike Edmunds <medmunds@gmail.com>
1 parent 4c62f7b commit 33f6806

File tree

6 files changed

+118
-5
lines changed

6 files changed

+118
-5
lines changed

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ Breaking changes
4141
(Anymail 10.0 switched to the SES v2 API by default. If your ``EMAIL_BACKEND``
4242
setting has ``amazon_sesv2``, change that to just ``amazon_ses``.)
4343

44+
Features
45+
~~~~~~~~
46+
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.)
50+
4451

4552
v10.3
4653
-----
@@ -1615,6 +1622,7 @@ Features
16151622
.. _@Arondit: https://github.com/Arondit
16161623
.. _@b0d0nne11: https://github.com/b0d0nne11
16171624
.. _@calvin: https://github.com/calvin
1625+
.. _@carrerasrodrigo: https://github.com/carrerasrodrigo
16181626
.. _@chrisgrande: https://github.com/chrisgrande
16191627
.. _@cjsoftuk: https://github.com/cjsoftuk
16201628
.. _@costela: https://github.com/costela

anymail/backends/amazon_ses.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,9 @@ def set_metadata(self, metadata):
298298
# metadata.
299299
self.mime_message["X-Metadata"] = self.serialize_json(metadata)
300300

301+
def set_merge_headers(self, merge_headers):
302+
self.unsupported_feature("merge_headers without template_id")
303+
301304
def set_tags(self, tags):
302305
# See note about Amazon SES Message Tags and custom headers in set_metadata
303306
# above. To support reliable retrieval in webhooks, use custom headers for tags.
@@ -339,6 +342,7 @@ def init_payload(self):
339342
# late-bind recipients and merge_data in finalize_payload
340343
self.recipients = {"to": [], "cc": [], "bcc": []}
341344
self.merge_data = {}
345+
self.merge_headers = {}
342346

343347
def finalize_payload(self):
344348
# Build BulkEmailEntries from recipients and merge_data.
@@ -355,8 +359,9 @@ def finalize_payload(self):
355359
]
356360

357361
# Construct an entry with merge data for each "to" recipient:
358-
self.params["BulkEmailEntries"] = [
359-
{
362+
self.params["BulkEmailEntries"] = []
363+
for to in self.recipients["to"]:
364+
entry = {
360365
"Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses),
361366
"ReplacementEmailContent": {
362367
"ReplacementTemplate": {
@@ -366,8 +371,13 @@ def finalize_payload(self):
366371
}
367372
},
368373
}
369-
for to in self.recipients["to"]
370-
]
374+
375+
if len(self.merge_headers) > 0:
376+
entry["ReplacementHeaders"] = [
377+
{"Name": key, "Value": value}
378+
for key, value in self.merge_headers.get(to.addr_spec, {}).items()
379+
]
380+
self.params["BulkEmailEntries"].append(entry)
371381

372382
def parse_recipient_status(self, response):
373383
try:
@@ -490,6 +500,10 @@ def set_merge_data(self, merge_data):
490500
# late-bound in finalize_payload
491501
self.merge_data = merge_data
492502

503+
def set_merge_headers(self, merge_headers):
504+
# late-bound in finalize_payload
505+
self.merge_headers = merge_headers
506+
493507
def set_merge_global_data(self, merge_global_data):
494508
# DefaultContent.Template.TemplateData
495509
self.params.setdefault("DefaultContent", {}).setdefault("Template", {})[

anymail/backends/base.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,14 +286,15 @@ class BasePayload:
286286
("template_id", last, force_non_lazy),
287287
("merge_data", merge_dicts_one_level, force_non_lazy_dict),
288288
("merge_global_data", merge_dicts_shallow, force_non_lazy_dict),
289+
("merge_headers", None, None),
289290
("merge_metadata", merge_dicts_one_level, force_non_lazy_dict),
290291
("esp_extra", merge_dicts_deep, force_non_lazy_dict),
291292
)
292293
esp_message_attrs = () # subclasses can override
293294

294295
# If any of these attrs are set on a message, treat the message
295296
# as a batch send (separate message for each `to` recipient):
296-
batch_attrs = ("merge_data", "merge_metadata")
297+
batch_attrs = ("merge_data", "merge_headers", "merge_metadata")
297298

298299
def __init__(self, message, defaults, backend):
299300
self.message = message
@@ -617,6 +618,9 @@ def set_template_id(self, template_id):
617618
def set_merge_data(self, merge_data):
618619
self.unsupported_feature("merge_data")
619620

621+
def set_merge_headers(self, merge_headers):
622+
self.unsupported_feature("merge_headers")
623+
620624
def set_merge_global_data(self, merge_global_data):
621625
self.unsupported_feature("merge_global_data")
622626

anymail/backends/test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ def set_template_id(self, template_id):
147147
def set_merge_data(self, merge_data):
148148
self.params["merge_data"] = merge_data
149149

150+
def set_merge_headers(self, merge_headers):
151+
self.params["merge_headers"] = merge_headers
152+
150153
def set_merge_metadata(self, merge_metadata):
151154
self.params["merge_metadata"] = merge_metadata
152155

anymail/message.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def __init__(self, *args, **kwargs):
2929
self.template_id = kwargs.pop("template_id", UNSET)
3030
self.merge_data = kwargs.pop("merge_data", UNSET)
3131
self.merge_global_data = kwargs.pop("merge_global_data", UNSET)
32+
self.merge_headers = kwargs.pop("merge_headers", UNSET)
3233
self.merge_metadata = kwargs.pop("merge_metadata", UNSET)
3334
self.anymail_status = AnymailStatus()
3435

tests/test_amazon_ses_backend.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,64 @@ def test_merge_data(self):
560560
):
561561
self.message.send()
562562

563+
def test_merge_headers(self):
564+
# Amazon SES only supports merging when using templates (see below)
565+
self.message.merge_headers = {}
566+
with self.assertRaisesMessage(
567+
AnymailUnsupportedFeature, "merge_headers without template_id"
568+
):
569+
self.message.send()
570+
571+
@override_settings(
572+
# only way to use tags with template_id:
573+
ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign"
574+
)
575+
def test_template_dont_add_merge_headers(self):
576+
"""With template_id, Anymail switches to SESv2 SendBulkEmail"""
577+
# SendBulkEmail uses a completely different API call and payload
578+
# structure, so this re-tests a bunch of Anymail features that were handled
579+
# differently above. (See test_amazon_ses_integration for a more realistic
580+
# template example.)
581+
raw_response = {
582+
"BulkEmailEntryResults": [
583+
{
584+
"Status": "SUCCESS",
585+
"MessageId": "1111111111111111-bbbbbbbb-3333-7777",
586+
},
587+
{
588+
"Status": "ACCOUNT_DAILY_QUOTA_EXCEEDED",
589+
"Error": "Daily message quota exceeded",
590+
},
591+
],
592+
"ResponseMetadata": self.DEFAULT_SEND_RESPONSE["ResponseMetadata"],
593+
}
594+
self.set_mock_response(raw_response, operation_name="send_bulk_email")
595+
message = AnymailMessage(
596+
template_id="welcome_template",
597+
from_email='"Example, Inc." <from@example.com>',
598+
to=["alice@example.com", "罗伯特 <bob@example.com>"],
599+
cc=["cc@example.com"],
600+
reply_to=["reply1@example.com", "Reply 2 <reply2@example.com>"],
601+
merge_data={
602+
"alice@example.com": {"name": "Alice", "group": "Developers"},
603+
"bob@example.com": {"name": "Bob"}, # and leave group undefined
604+
"nobody@example.com": {"name": "Not a recipient for this message"},
605+
},
606+
merge_global_data={"group": "Users", "site": "ExampleCo"},
607+
# (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template):
608+
tags=["WelcomeVariantA"],
609+
envelope_sender="bounce@example.com",
610+
esp_extra={
611+
"FromEmailAddressIdentityArn": (
612+
"arn:aws:ses:us-east-1:123456789012:identity/example.com"
613+
)
614+
},
615+
)
616+
message.send()
617+
618+
params = self.get_send_params(operation_name="send_bulk_email")
619+
self.assertNotIn("ReplacementHeaders", params["BulkEmailEntries"][0])
620+
563621
@override_settings(
564622
# only way to use tags with template_id:
565623
ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign"
@@ -595,6 +653,16 @@ def test_template(self):
595653
"bob@example.com": {"name": "Bob"}, # and leave group undefined
596654
"nobody@example.com": {"name": "Not a recipient for this message"},
597655
},
656+
merge_headers={
657+
"alice@example.com": {
658+
"List-Unsubscribe": "<https://example.com/a/>",
659+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
660+
},
661+
"nobody@example.com": {
662+
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
663+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
664+
},
665+
},
598666
merge_global_data={"group": "Users", "site": "ExampleCo"},
599667
# (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template):
600668
tags=["WelcomeVariantA"],
@@ -646,6 +714,21 @@ def test_template(self):
646714
),
647715
{"name": "Bob"},
648716
)
717+
718+
self.assertEqual(
719+
bulk_entries[0]["ReplacementHeaders"],
720+
[
721+
{"Name": "List-Unsubscribe", "Value": "<https://example.com/a/>"},
722+
{
723+
"Name": "List-Unsubscribe-Post",
724+
"Value": "List-Unsubscribe=One-Click",
725+
},
726+
],
727+
)
728+
self.assertEqual(
729+
bulk_entries[1]["ReplacementHeaders"],
730+
[],
731+
)
649732
self.assertEqual(
650733
json.loads(params["DefaultContent"]["Template"]["TemplateData"]),
651734
{"group": "Users", "site": "ExampleCo"},

0 commit comments

Comments
 (0)