Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support merge_headers in Amazon SES bulk send #371

Merged
merged 6 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down Expand Up @@ -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
Expand Down
22 changes: 18 additions & 4 deletions anymail/backends/amazon_ses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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": {
Expand All @@ -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:
Expand Down Expand Up @@ -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", {})[
Expand Down
6 changes: 5 additions & 1 deletion anymail/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,14 +286,15 @@ 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),
)
esp_message_attrs = () # subclasses can override

# 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
Expand Down Expand Up @@ -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")

Expand Down
3 changes: 3 additions & 0 deletions anymail/backends/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions anymail/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
83 changes: 83 additions & 0 deletions tests/test_amazon_ses_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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." <from@example.com>',
to=["alice@example.com", "罗伯特 <bob@example.com>"],
cc=["cc@example.com"],
reply_to=["reply1@example.com", "Reply 2 <reply2@example.com>"],
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"
Expand Down Expand Up @@ -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": "<https://example.com/a/>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
"nobody@example.com": {
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
"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"],
Expand Down Expand Up @@ -646,6 +714,21 @@ def test_template(self):
),
{"name": "Bob"},
)

self.assertEqual(
bulk_entries[0]["ReplacementHeaders"],
[
{"Name": "List-Unsubscribe", "Value": "<https://example.com/a/>"},
{
"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"},
Expand Down