mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
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>
This commit is contained in:
committed by
GitHub
parent
4c62f7bee0
commit
33f680686b
@@ -41,6 +41,13 @@ Breaking changes
|
|||||||
(Anymail 10.0 switched to the SES v2 API by default. If your ``EMAIL_BACKEND``
|
(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``.)
|
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
|
v10.3
|
||||||
-----
|
-----
|
||||||
@@ -1615,6 +1622,7 @@ Features
|
|||||||
.. _@Arondit: https://github.com/Arondit
|
.. _@Arondit: https://github.com/Arondit
|
||||||
.. _@b0d0nne11: https://github.com/b0d0nne11
|
.. _@b0d0nne11: https://github.com/b0d0nne11
|
||||||
.. _@calvin: https://github.com/calvin
|
.. _@calvin: https://github.com/calvin
|
||||||
|
.. _@carrerasrodrigo: https://github.com/carrerasrodrigo
|
||||||
.. _@chrisgrande: https://github.com/chrisgrande
|
.. _@chrisgrande: https://github.com/chrisgrande
|
||||||
.. _@cjsoftuk: https://github.com/cjsoftuk
|
.. _@cjsoftuk: https://github.com/cjsoftuk
|
||||||
.. _@costela: https://github.com/costela
|
.. _@costela: https://github.com/costela
|
||||||
|
|||||||
@@ -298,6 +298,9 @@ class AmazonSESV2SendEmailPayload(AmazonSESBasePayload):
|
|||||||
# metadata.
|
# metadata.
|
||||||
self.mime_message["X-Metadata"] = self.serialize_json(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):
|
def set_tags(self, tags):
|
||||||
# See note about Amazon SES Message Tags and custom headers in set_metadata
|
# 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.
|
# above. To support reliable retrieval in webhooks, use custom headers for tags.
|
||||||
@@ -339,6 +342,7 @@ class AmazonSESV2SendBulkEmailPayload(AmazonSESBasePayload):
|
|||||||
# late-bind recipients and merge_data in finalize_payload
|
# late-bind recipients and merge_data in finalize_payload
|
||||||
self.recipients = {"to": [], "cc": [], "bcc": []}
|
self.recipients = {"to": [], "cc": [], "bcc": []}
|
||||||
self.merge_data = {}
|
self.merge_data = {}
|
||||||
|
self.merge_headers = {}
|
||||||
|
|
||||||
def finalize_payload(self):
|
def finalize_payload(self):
|
||||||
# Build BulkEmailEntries from recipients and merge_data.
|
# Build BulkEmailEntries from recipients and merge_data.
|
||||||
@@ -355,8 +359,9 @@ class AmazonSESV2SendBulkEmailPayload(AmazonSESBasePayload):
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Construct an entry with merge data for each "to" recipient:
|
# 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),
|
"Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses),
|
||||||
"ReplacementEmailContent": {
|
"ReplacementEmailContent": {
|
||||||
"ReplacementTemplate": {
|
"ReplacementTemplate": {
|
||||||
@@ -366,8 +371,13 @@ class AmazonSESV2SendBulkEmailPayload(AmazonSESBasePayload):
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
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):
|
def parse_recipient_status(self, response):
|
||||||
try:
|
try:
|
||||||
@@ -490,6 +500,10 @@ class AmazonSESV2SendBulkEmailPayload(AmazonSESBasePayload):
|
|||||||
# late-bound in finalize_payload
|
# late-bound in finalize_payload
|
||||||
self.merge_data = merge_data
|
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):
|
def set_merge_global_data(self, merge_global_data):
|
||||||
# DefaultContent.Template.TemplateData
|
# DefaultContent.Template.TemplateData
|
||||||
self.params.setdefault("DefaultContent", {}).setdefault("Template", {})[
|
self.params.setdefault("DefaultContent", {}).setdefault("Template", {})[
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ class BasePayload:
|
|||||||
("template_id", last, force_non_lazy),
|
("template_id", last, force_non_lazy),
|
||||||
("merge_data", merge_dicts_one_level, force_non_lazy_dict),
|
("merge_data", merge_dicts_one_level, force_non_lazy_dict),
|
||||||
("merge_global_data", merge_dicts_shallow, 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),
|
("merge_metadata", merge_dicts_one_level, force_non_lazy_dict),
|
||||||
("esp_extra", merge_dicts_deep, 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
|
# If any of these attrs are set on a message, treat the message
|
||||||
# as a batch send (separate message for each `to` recipient):
|
# 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):
|
def __init__(self, message, defaults, backend):
|
||||||
self.message = message
|
self.message = message
|
||||||
@@ -617,6 +618,9 @@ class BasePayload:
|
|||||||
def set_merge_data(self, merge_data):
|
def set_merge_data(self, merge_data):
|
||||||
self.unsupported_feature("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):
|
def set_merge_global_data(self, merge_global_data):
|
||||||
self.unsupported_feature("merge_global_data")
|
self.unsupported_feature("merge_global_data")
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ class TestPayload(BasePayload):
|
|||||||
def set_merge_data(self, merge_data):
|
def set_merge_data(self, merge_data):
|
||||||
self.params["merge_data"] = 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):
|
def set_merge_metadata(self, merge_metadata):
|
||||||
self.params["merge_metadata"] = merge_metadata
|
self.params["merge_metadata"] = merge_metadata
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class AnymailMessageMixin(EmailMessage):
|
|||||||
self.template_id = kwargs.pop("template_id", UNSET)
|
self.template_id = kwargs.pop("template_id", UNSET)
|
||||||
self.merge_data = kwargs.pop("merge_data", UNSET)
|
self.merge_data = kwargs.pop("merge_data", UNSET)
|
||||||
self.merge_global_data = kwargs.pop("merge_global_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.merge_metadata = kwargs.pop("merge_metadata", UNSET)
|
||||||
self.anymail_status = AnymailStatus()
|
self.anymail_status = AnymailStatus()
|
||||||
|
|
||||||
|
|||||||
@@ -560,6 +560,64 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
):
|
):
|
||||||
self.message.send()
|
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(
|
@override_settings(
|
||||||
# only way to use tags with template_id:
|
# only way to use tags with template_id:
|
||||||
ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign"
|
ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign"
|
||||||
@@ -595,6 +653,16 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
"bob@example.com": {"name": "Bob"}, # and leave group undefined
|
"bob@example.com": {"name": "Bob"}, # and leave group undefined
|
||||||
"nobody@example.com": {"name": "Not a recipient for this message"},
|
"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"},
|
merge_global_data={"group": "Users", "site": "ExampleCo"},
|
||||||
# (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template):
|
# (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template):
|
||||||
tags=["WelcomeVariantA"],
|
tags=["WelcomeVariantA"],
|
||||||
@@ -646,6 +714,21 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
),
|
),
|
||||||
{"name": "Bob"},
|
{"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(
|
self.assertEqual(
|
||||||
json.loads(params["DefaultContent"]["Template"]["TemplateData"]),
|
json.loads(params["DefaultContent"]["Template"]["TemplateData"]),
|
||||||
{"group": "Users", "site": "ExampleCo"},
|
{"group": "Users", "site": "ExampleCo"},
|
||||||
|
|||||||
Reference in New Issue
Block a user