From 33f680686b8d1ee1801c4d928a1f385af9fa04ac Mon Sep 17 00:00:00 2001 From: Rodrigo Nicolas Carreras Date: Tue, 21 May 2024 15:55:29 -0300 Subject: [PATCH] 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 --- CHANGELOG.rst | 8 +++ anymail/backends/amazon_ses.py | 22 +++++++-- anymail/backends/base.py | 6 ++- anymail/backends/test.py | 3 ++ anymail/message.py | 1 + tests/test_amazon_ses_backend.py | 83 ++++++++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8c2643cc..29f5d08f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -41,6 +41,13 @@ Breaking changes (Anymail 10.0 switched to the SES v2 API by default. If your ``EMAIL_BACKEND`` setting has ``amazon_sesv2``, change that to just ``amazon_ses``.) +Features +~~~~~~~~ + +* **Amazon SES:** Add new ``merge_headers`` option for per-recipient + headers with template sends. (Requires boto3 >= 1.34.98.) + (Thanks to `@carrerasrodrigo`_ the implementation.) + v10.3 ----- @@ -1615,6 +1622,7 @@ Features .. _@Arondit: https://github.com/Arondit .. _@b0d0nne11: https://github.com/b0d0nne11 .. _@calvin: https://github.com/calvin +.. _@carrerasrodrigo: https://github.com/carrerasrodrigo .. _@chrisgrande: https://github.com/chrisgrande .. _@cjsoftuk: https://github.com/cjsoftuk .. _@costela: https://github.com/costela diff --git a/anymail/backends/amazon_ses.py b/anymail/backends/amazon_ses.py index 24c31db4..041297ac 100644 --- a/anymail/backends/amazon_ses.py +++ b/anymail/backends/amazon_ses.py @@ -298,6 +298,9 @@ def set_metadata(self, metadata): # metadata. self.mime_message["X-Metadata"] = self.serialize_json(metadata) + def set_merge_headers(self, merge_headers): + self.unsupported_feature("merge_headers without template_id") + def set_tags(self, tags): # See note about Amazon SES Message Tags and custom headers in set_metadata # above. To support reliable retrieval in webhooks, use custom headers for tags. @@ -339,6 +342,7 @@ def init_payload(self): # late-bind recipients and merge_data in finalize_payload self.recipients = {"to": [], "cc": [], "bcc": []} self.merge_data = {} + self.merge_headers = {} def finalize_payload(self): # Build BulkEmailEntries from recipients and merge_data. @@ -355,8 +359,9 @@ def finalize_payload(self): ] # Construct an entry with merge data for each "to" recipient: - self.params["BulkEmailEntries"] = [ - { + self.params["BulkEmailEntries"] = [] + for to in self.recipients["to"]: + entry = { "Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses), "ReplacementEmailContent": { "ReplacementTemplate": { @@ -366,8 +371,13 @@ def finalize_payload(self): } }, } - for to in self.recipients["to"] - ] + + if len(self.merge_headers) > 0: + entry["ReplacementHeaders"] = [ + {"Name": key, "Value": value} + for key, value in self.merge_headers.get(to.addr_spec, {}).items() + ] + self.params["BulkEmailEntries"].append(entry) def parse_recipient_status(self, response): try: @@ -490,6 +500,10 @@ def set_merge_data(self, merge_data): # late-bound in finalize_payload self.merge_data = merge_data + def set_merge_headers(self, merge_headers): + # late-bound in finalize_payload + self.merge_headers = merge_headers + def set_merge_global_data(self, merge_global_data): # DefaultContent.Template.TemplateData self.params.setdefault("DefaultContent", {}).setdefault("Template", {})[ diff --git a/anymail/backends/base.py b/anymail/backends/base.py index 35ed3897..979e29d0 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -286,6 +286,7 @@ class BasePayload: ("template_id", last, force_non_lazy), ("merge_data", merge_dicts_one_level, force_non_lazy_dict), ("merge_global_data", merge_dicts_shallow, force_non_lazy_dict), + ("merge_headers", None, None), ("merge_metadata", merge_dicts_one_level, force_non_lazy_dict), ("esp_extra", merge_dicts_deep, force_non_lazy_dict), ) @@ -293,7 +294,7 @@ class BasePayload: # If any of these attrs are set on a message, treat the message # as a batch send (separate message for each `to` recipient): - batch_attrs = ("merge_data", "merge_metadata") + batch_attrs = ("merge_data", "merge_headers", "merge_metadata") def __init__(self, message, defaults, backend): self.message = message @@ -617,6 +618,9 @@ def set_template_id(self, template_id): def set_merge_data(self, merge_data): self.unsupported_feature("merge_data") + def set_merge_headers(self, merge_headers): + self.unsupported_feature("merge_headers") + def set_merge_global_data(self, merge_global_data): self.unsupported_feature("merge_global_data") diff --git a/anymail/backends/test.py b/anymail/backends/test.py index 3a9d5353..4a085428 100644 --- a/anymail/backends/test.py +++ b/anymail/backends/test.py @@ -147,6 +147,9 @@ def set_template_id(self, template_id): def set_merge_data(self, merge_data): self.params["merge_data"] = merge_data + def set_merge_headers(self, merge_headers): + self.params["merge_headers"] = merge_headers + def set_merge_metadata(self, merge_metadata): self.params["merge_metadata"] = merge_metadata diff --git a/anymail/message.py b/anymail/message.py index 7f109994..3c1a5040 100644 --- a/anymail/message.py +++ b/anymail/message.py @@ -29,6 +29,7 @@ def __init__(self, *args, **kwargs): self.template_id = kwargs.pop("template_id", UNSET) self.merge_data = kwargs.pop("merge_data", UNSET) self.merge_global_data = kwargs.pop("merge_global_data", UNSET) + self.merge_headers = kwargs.pop("merge_headers", UNSET) self.merge_metadata = kwargs.pop("merge_metadata", UNSET) self.anymail_status = AnymailStatus() diff --git a/tests/test_amazon_ses_backend.py b/tests/test_amazon_ses_backend.py index b8c2c19e..b371e288 100644 --- a/tests/test_amazon_ses_backend.py +++ b/tests/test_amazon_ses_backend.py @@ -560,6 +560,64 @@ def test_merge_data(self): ): self.message.send() + def test_merge_headers(self): + # Amazon SES only supports merging when using templates (see below) + self.message.merge_headers = {} + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "merge_headers without template_id" + ): + self.message.send() + + @override_settings( + # only way to use tags with template_id: + ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign" + ) + def test_template_dont_add_merge_headers(self): + """With template_id, Anymail switches to SESv2 SendBulkEmail""" + # SendBulkEmail uses a completely different API call and payload + # structure, so this re-tests a bunch of Anymail features that were handled + # differently above. (See test_amazon_ses_integration for a more realistic + # template example.) + raw_response = { + "BulkEmailEntryResults": [ + { + "Status": "SUCCESS", + "MessageId": "1111111111111111-bbbbbbbb-3333-7777", + }, + { + "Status": "ACCOUNT_DAILY_QUOTA_EXCEEDED", + "Error": "Daily message quota exceeded", + }, + ], + "ResponseMetadata": self.DEFAULT_SEND_RESPONSE["ResponseMetadata"], + } + self.set_mock_response(raw_response, operation_name="send_bulk_email") + message = AnymailMessage( + template_id="welcome_template", + from_email='"Example, Inc." ', + to=["alice@example.com", "罗伯特 "], + cc=["cc@example.com"], + reply_to=["reply1@example.com", "Reply 2 "], + merge_data={ + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined + "nobody@example.com": {"name": "Not a recipient for this message"}, + }, + merge_global_data={"group": "Users", "site": "ExampleCo"}, + # (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template): + tags=["WelcomeVariantA"], + envelope_sender="bounce@example.com", + esp_extra={ + "FromEmailAddressIdentityArn": ( + "arn:aws:ses:us-east-1:123456789012:identity/example.com" + ) + }, + ) + message.send() + + params = self.get_send_params(operation_name="send_bulk_email") + self.assertNotIn("ReplacementHeaders", params["BulkEmailEntries"][0]) + @override_settings( # only way to use tags with template_id: ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign" @@ -595,6 +653,16 @@ def test_template(self): "bob@example.com": {"name": "Bob"}, # and leave group undefined "nobody@example.com": {"name": "Not a recipient for this message"}, }, + merge_headers={ + "alice@example.com": { + "List-Unsubscribe": "", + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + "nobody@example.com": { + "List-Unsubscribe": "", + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + }, merge_global_data={"group": "Users", "site": "ExampleCo"}, # (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template): tags=["WelcomeVariantA"], @@ -646,6 +714,21 @@ def test_template(self): ), {"name": "Bob"}, ) + + self.assertEqual( + bulk_entries[0]["ReplacementHeaders"], + [ + {"Name": "List-Unsubscribe", "Value": ""}, + { + "Name": "List-Unsubscribe-Post", + "Value": "List-Unsubscribe=One-Click", + }, + ], + ) + self.assertEqual( + bulk_entries[1]["ReplacementHeaders"], + [], + ) self.assertEqual( json.loads(params["DefaultContent"]["Template"]["TemplateData"]), {"group": "Users", "site": "ExampleCo"},