Repository: anymail/django-anymail (language: python)

Issue:
Amazon SES: support extra_headers, metadata, tags for template sends
Originally, AWS's `ses::SendBulkEmail` API didn't allow specifying email headers. Since Anymail's Amazon SES backend also uses custom headers for `metadata` and `tags`, this meant you [couldn't use](https://anymail.dev/en/stable/esps/amazon_ses/#batch-sending-merge-and-esp-templates) any of the following message options together with a `template_id`:

- `headers` (a.k.a. `extra_headers`)
- `metadata` or `merge_metadata`
- `tags` (except a single tag when using `AMAZON_SES_MESSAGE_TAG_NAME`)

In March, [AWS added a new `ReplacementHeaders` parameter](https://aws.amazon.com/about-aws/whats-new/2024/03/amazon-ses-headers-sending-email/) for `ses::SendBulkEmail`, which allows per-recipient custom headers. In early May, the [new parameter was made available in boto3](https://github.com/boto/boto3/blob/1.34.98/CHANGELOG.rst#L14).

Anymail should use SES's new ReplacementHeaders to support extra_headers, metadata, merge_metadata, and tags in template sends.

(Related: #371)


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 29f5d08..f93e9e7 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -48,6 +48,10 @@ Features
   headers with template sends. (Requires boto3 >= 1.34.98.)
   (Thanks to `@carrerasrodrigo`_ the implementation.)
 
+* **Amazon SES:** Allow extra headers, ``metadata``, ``merge_metadata``,
+  and ``tags`` when sending with a ``template_id``.
+  (Requires boto3 v1.34.98 or later.)
+
 
 v10.3
 -----
diff --git a/anymail/backends/amazon_ses.py b/anymail/backends/amazon_ses.py
index 041297a..5c62943 100644
--- a/anymail/backends/amazon_ses.py
+++ b/anymail/backends/amazon_ses.py
@@ -2,6 +2,8 @@
 import email.encoders
 import email.policy
 
+from requests.structures import CaseInsensitiveDict
+
 from .. import __version__ as ANYMAIL_VERSION
 from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
 from ..message import AnymailRecipientStatus
@@ -339,10 +341,14 @@ class AmazonSESV2SendBulkEmailPayload(AmazonSESBasePayload):
 
     def init_payload(self):
         super().init_payload()
-        # late-bind recipients and merge_data in finalize_payload
+        # late-bind in finalize_payload:
         self.recipients = {"to": [], "cc": [], "bcc": []}
         self.merge_data = {}
+        self.headers = {}
         self.merge_headers = {}
+        self.metadata = {}
+        self.merge_metadata = {}
+        self.tags = []
 
     def finalize_payload(self):
         # Build BulkEmailEntries from recipients and merge_data.
@@ -372,11 +378,26 @@ def finalize_payload(self):
                 },
             }
 
-            if len(self.merge_headers) > 0:
-                entry["ReplacementHeaders"] = [
-                    {"Name": key, "Value": value}
-                    for key, value in self.merge_headers.get(to.addr_spec, {}).items()
+            replacement_headers = []
+            if self.headers or to.addr_spec in self.merge_headers:
+                headers = CaseInsensitiveDict(self.headers)
+                headers.update(self.merge_headers.get(to.addr_spec, {}))
+                replacement_headers += [
+                    {"Name": key, "Value": value} for key, value in headers.items()
+                ]
+            if self.metadata or to.addr_spec in self.merge_metadata:
+                metadata = self.metadata.copy()
+                metadata.update(self.merge_metadata.get(to.addr_spec, {}))
+                if metadata:
+                    replacement_headers.append(
+                        {"Name": "X-Metadata", "Value": self.serialize_json(metadata)}
+                    )
+            if self.tags:
+                replacement_headers += [
+                    {"Name": "X-Tag", "Value": tag} for tag in self.tags
                 ]
+            if replacement_headers:
+                entry["ReplacementHeaders"] = replacement_headers
             self.params["BulkEmailEntries"].append(entry)
 
     def parse_recipient_status(self, response):
@@ -446,7 +467,7 @@ def set_reply_to(self, emails):
             self.params["ReplyToAddresses"] = [email.address for email in emails]
 
     def set_extra_headers(self, headers):
-        self.unsupported_feature("extra_headers with template")
+        self.headers = headers
 
     def set_text_body(self, body):
         if body:
@@ -468,27 +489,26 @@ def set_envelope_sender(self, email):
         self.params["FeedbackForwardingEmailAddress"] = email.addr_spec
 
     def set_metadata(self, metadata):
-        # no custom headers with SendBulkEmail
-        self.unsupported_feature("metadata with template")
+        self.metadata = metadata
+
+    def set_merge_metadata(self, merge_metadata):
+        self.merge_metadata = merge_metadata
 
     def set_tags(self, tags):
-        # no custom headers with SendBulkEmail, but support
-        # AMAZON_SES_MESSAGE_TAG_NAME if used (see tags/metadata in
-        # AmazonSESV2SendEmailPayload for more info)
-        if tags:
-            if self.backend.message_tag_name is not None:
-                if len(tags) > 1:
-                    self.unsupported_feature(
-                        "multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting"
-                    )
-                self.params["DefaultEmailTags"] = [
-                    {"Name": self.backend.message_tag_name, "Value": tags[0]}
-                ]
-            else:
+        self.tags = tags
+
+        # Also *optionally* pass a single Message Tag if the AMAZON_SES_MESSAGE_TAG_NAME
+        # Anymail setting is set (default no). The AWS API restricts tag content in this
+        # case. (This is useful for dashboard segmentation; use esp_extra["Tags"] for
+        # anything more complex.)
+        if tags and self.backend.message_tag_name is not None:
+            if len(tags) > 1:
                 self.unsupported_feature(
-                    "tags with template (unless using the"
-                    " AMAZON_SES_MESSAGE_TAG_NAME setting)"
+                    "multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting"
                 )
+            self.params["DefaultEmailTags"] = [
+                {"Name": self.backend.message_tag_name, "Value": tags[0]}
+            ]
 
     def set_template_id(self, template_id):
         # DefaultContent.Template.TemplateName
diff --git a/docs/esps/amazon_ses.rst b/docs/esps/amazon_ses.rst
index 73639a6..507682c 100644
--- a/docs/esps/amazon_ses.rst
+++ b/docs/esps/amazon_ses.rst
@@ -68,6 +68,11 @@ setting to customize the Boto session.
 Limitations and quirks
 ----------------------
 
+.. versionchanged:: 11.0
+
+    Anymail's :attr:`~anymail.message.AnymailMessage.merge_metadata`
+    is now supported.
+
 **Hard throttling**
   Like most ESPs, Amazon SES `throttles sending`_ for new customers. But unlike
   most ESPs, SES does not queue and slowly release throttled messages. Instead, it
@@ -80,11 +85,6 @@ Limitations and quirks
   :attr:`~anymail.message.AnymailMessage.tags` feature. See :ref:`amazon-ses-tags`
   below for more information and additional options.
 
-**No merge_metadata**
-  Amazon SES's batch sending API does not support the custom headers Anymail uses
-  for metadata, so Anymail's :attr:`~anymail.message.AnymailMessage.merge_metadata`
-  feature is not available. (See :ref:`amazon-ses-tags` below for more information.)
-
 **Open and click tracking overrides**
   Anymail's :attr:`~anymail.message.AnymailMessage.track_opens` and
   :attr:`~anymail.message.AnymailMessage.track_clicks` are not supported.
@@ -126,7 +126,7 @@ Limitations and quirks
   signal, and using it will likely prevent delivery of your email.)
 
 **Template limitations**
-  Messages sent with templates have a number of additional limitations, such as not
+  Messages sent with templates have some additional limitations, such as not
   supporting attachments. See :ref:`amazon-ses-templates` below.
 
 
@@ -195,12 +195,7 @@ characters.
 
 For more complex use cases, set the SES ``EmailTags`` parameter (or ``DefaultEmailTags``
 for template sends) directly in Anymail's :ref:`esp_extra <amazon-ses-esp-extra>`. See
-the example below. (Because custom headers do not work with SES's SendBulkEmail call,
-esp_extra ``DefaultEmailTags`` is the only way to attach data to SES messages also using
-Anymail's :attr:`~anymail.message.AnymailMessage.template_id` and
-:attr:`~anymail.message.AnymailMessage.merge_data` features, and
-:attr:`~anymail.message.AnymailMessage.merge_metadata` cannot be supported.)
-
+the example below.
 
 .. _Introducing Sending Metrics:
     https://aws.amazon.com/blogs/ses/introducing-sending-metrics/
@@ -264,9 +259,10 @@ See Amazon's `Sending personalized email`_ guide for more information.
 When you set a message's :attr:`~anymail.message.AnymailMessage.template_id`
 to the name of one of your SES templates, Anymail will use the SES v2 `SendBulkEmail`_
 call to send template messages personalized with data
-from Anymail's normalized :attr:`~anymail.message.AnymailMessage.merge_data`
-and :attr:`~anymail.message.AnymailMessage.merge_global_data`
-message attributes.
+from Anymail's normalized :attr:`~anymail.message.AnymailMessage.merge_data`,
+:attr:`~anymail.message.AnymailMessage.merge_global_data`,
+:attr:`~anymail.message.AnymailMessage.merge_metadata`, and
+:attr:`~anymail.message.AnymailMessage.merge_headers` message attributes.
 
   .. code-block:: python
 
@@ -284,17 +280,21 @@ message attributes.
           'ship_date': "May 15",
       }
 
-Amazon's templated email APIs don't support several features available for regular email.
+Amazon's templated email APIs don't support a few features available for regular email.
 When :attr:`~anymail.message.AnymailMessage.template_id` is used:
 
-* Attachments and alternative parts (including AMPHTML) are not supported
-* Extra headers are not supported
+* Attachments and inline images are not supported
+* Alternative parts (including AMPHTML) are not supported
 * Overriding the template's subject or body is not supported
-* Anymail's :attr:`~anymail.message.AnymailMessage.metadata` is not supported
-* Anymail's :attr:`~anymail.message.AnymailMessage.tags` are only supported
-  with the :setting:`AMAZON_SES_MESSAGE_TAG_NAME <ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME>`
-  setting; only a single tag is allowed, and the tag is not directly available
-  to webhooks. (See :ref:`amazon-ses-tags` above.)
+
+.. versionchanged:: 11.0
+
+    Extra headers, :attr:`~anymail.message.AnymailMessage.metadata`,
+    :attr:`~anymail.message.AnymailMessage.merge_metadata`, and
+    :attr:`~anymail.message.AnymailMessage.tags` are now fully supported
+    when using :attr:`~anymail.message.AnymailMessage.template_id`.
+    (This requires :pypi:`boto3` v1.34.98 or later, which enables the
+    ReplacementHeaders parameter for SendBulkEmail.)
 
 .. _Sending personalized email:
    https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-personalized-email-api.html
diff --git a/pyproject.toml b/pyproject.toml
index d91c329..2d2716e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -65,7 +65,7 @@ dependencies = [
 # ESP-specific additional dependencies.
 # (For simplicity, requests is included in the base dependencies.)
 # (Do not use underscores in extra names: they get normalized to hyphens.)
-amazon-ses = ["boto3"]
+amazon-ses = ["boto3>=1.10.17"]
 brevo = []
 mailersend = []
 mailgun = []


Test patch:
diff --git a/tests/test_amazon_ses_backend.py b/tests/test_amazon_ses_backend.py
index b371e28..cd22df6 100644
--- a/tests/test_amazon_ses_backend.py
+++ b/tests/test_amazon_ses_backend.py
@@ -568,60 +568,6 @@ def test_merge_headers(self):
         ):
             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"
-    )
     def test_template(self):
         """With template_id, Anymail switches to SESv2 SendBulkEmail"""
         # SendBulkEmail uses a completely different API call and payload
@@ -648,24 +594,29 @@ def test_template(self):
             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"},
+            headers={
+                "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
+                "List-Unsubscribe": "<mailto:unsubscribe@example.com>",
             },
             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",
+                "bob@example.com": {
+                    "List-Unsubscribe": "<https://example.com/b/>",
                 },
             },
+            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"],
+            tags=["Welcome Variant A", "Cohort 12/2017"],
+            metadata={"meta1": "test"},
+            merge_metadata={
+                "alice@example.com": {"meta2": "meta-alice"},
+            },
             envelope_sender="bounce@example.com",
             esp_extra={
                 "FromEmailAddressIdentityArn": (
@@ -715,19 +666,40 @@ def test_template(self):
             {"name": "Bob"},
         )
 
-        self.assertEqual(
+        self.assertCountEqual(
             bulk_entries[0]["ReplacementHeaders"],
             [
-                {"Name": "List-Unsubscribe", "Value": "<https://example.com/a/>"},
+                # From extra_headers and merge_headers:
                 {
                     "Name": "List-Unsubscribe-Post",
                     "Value": "List-Unsubscribe=One-Click",
                 },
+                {"Name": "List-Unsubscribe", "Value": "<https://example.com/a/>"},
+                # From metadata and merge_metadata:
+                {
+                    "Name": "X-Metadata",
+                    "Value": '{"meta1": "test", "meta2": "meta-alice"}',
+                },
+                # From tags:
+                {"Name": "X-Tag", "Value": "Welcome Variant A"},
+                {"Name": "X-Tag", "Value": "Cohort 12/2017"},
             ],
         )
-        self.assertEqual(
+        self.assertCountEqual(
             bulk_entries[1]["ReplacementHeaders"],
-            [],
+            [
+                # From extra_headers and merge_headers:
+                {
+                    "Name": "List-Unsubscribe-Post",
+                    "Value": "List-Unsubscribe=One-Click",
+                },
+                {"Name": "List-Unsubscribe", "Value": "<https://example.com/b/>"},
+                # From metadata (no merge_metadata for bob@):
+                {"Name": "X-Metadata", "Value": '{"meta1": "test"}'},
+                # From tags:
+                {"Name": "X-Tag", "Value": "Welcome Variant A"},
+                {"Name": "X-Tag", "Value": "Cohort 12/2017"},
+            ],
         )
         self.assertEqual(
             json.loads(params["DefaultContent"]["Template"]["TemplateData"]),
@@ -737,10 +709,6 @@ def test_template(self):
             params["ReplyToAddresses"],
             ["reply1@example.com", "Reply 2 <reply2@example.com>"],
         )
-        self.assertEqual(
-            params["DefaultEmailTags"],
-            [{"Name": "Campaign", "Value": "WelcomeVariantA"}],
-        )
         self.assertEqual(params["FeedbackForwardingEmailAddress"], "bounce@example.com")
         # esp_extra:
         self.assertEqual(
@@ -769,6 +737,69 @@ def test_template(self):
         )
         self.assertEqual(message.anymail_status.esp_response, raw_response)
 
+    def test_template_omits_unused_replacement_headers(self):
+        """If headers are not needed, the ReplacementHeaders param should be omitted"""
+        # bob@example.com requires ReplacementHeaders; alice@example.com doesn't
+        raw_response = {
+            "BulkEmailEntryResults": [
+                {
+                    "Status": "SUCCESS",
+                    "MessageId": "1111111111111111-bbbbbbbb-3333-7777",
+                },
+                {
+                    "Status": "SUCCESS",
+                    "MessageId": "1111111111111111-bbbbbbbb-4444-8888",
+                },
+            ],
+            "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>"],
+            reply_to=["reply1@example.com", "Reply 2 <reply2@example.com>"],
+            merge_headers={
+                "alice@example.com": {},
+                "bob@example.com": {"X-Test": "test"},
+            },
+            merge_global_data={"group": "Users", "site": "ExampleCo"},
+        )
+        message.send()
+
+        params = self.get_send_params(operation_name="send_bulk_email")
+        self.assertNotIn("ReplacementHeaders", params["BulkEmailEntries"][0])
+        self.assertIn("ReplacementHeaders", params["BulkEmailEntries"][1])
+
+    @override_settings(
+        # This will pass DefaultEmailTags: Name "Campaign"
+        ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign"
+    )
+    def test_template_default_email_tag(self):
+        raw_response = {
+            "BulkEmailEntryResults": [
+                {
+                    "Status": "SUCCESS",
+                    "MessageId": "1111111111111111-bbbbbbbb-3333-7777",
+                },
+            ],
+            "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"],
+            tags=["WelcomeVariantA"],
+        )
+        message.send()
+
+        params = self.get_send_params(operation_name="send_bulk_email")
+        self.assertEqual(
+            params["DefaultEmailTags"],
+            [{"Name": "Campaign", "Value": "WelcomeVariantA"}],
+        )
+
     def test_template_failure(self):
         """Failures to all recipients raise a similar error to non-template sends"""
         raw_response = {
@@ -794,7 +825,7 @@ def test_template_failure(self):
             message.send()
 
     def test_template_unsupported(self):
-        """A lot of options are not compatible with SendBulkTemplatedEmail"""
+        """Some options are not compatible with SendBulkTemplatedEmail"""
         message = AnymailMessage(template_id="welcome_template", to=["to@example.com"])
 
         message.subject = "nope, can't change template subject"
@@ -823,25 +854,6 @@ def test_template_unsupported(self):
             message.send()
         message.attachments = []
 
-        message.extra_headers = {"X-Custom": "header"}
-        with self.assertRaisesMessage(
-            AnymailUnsupportedFeature, "extra_headers with template"
-        ):
-            message.send()
-        message.extra_headers = {}
-
-        message.metadata = {"meta": "data"}
-        with self.assertRaisesMessage(
-            AnymailUnsupportedFeature, "metadata with template"
-        ):
-            message.send()
-        message.metadata = None
-
-        message.tags = ["tag 1", "tag 2"]
-        with self.assertRaisesMessage(AnymailUnsupportedFeature, "tags with template"):
-            message.send()
-        message.tags = None
-
     def test_send_anymail_message_without_template(self):
         # Make sure SendEmail is used for non-template_id messages
         message = AnymailMessage(
diff --git a/tests/test_amazon_ses_integration.py b/tests/test_amazon_ses_integration.py
index 3f88ec1..ac8a0d2 100644
--- a/tests/test_amazon_ses_integration.py
+++ b/tests/test_amazon_ses_integration.py
@@ -164,6 +164,20 @@ def test_stored_template(self):
                 "success+to2@simulator.amazonses.com": {"order": 6789},
             },
             merge_global_data={"name": "Customer", "ship_date": "today"},  # default
+            headers={
+                "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
+            },
+            merge_headers={
+                "success+to1@simulator.amazonses.com": {
+                    "List-Unsubscribe": "<https://example.com/unsubscribe/to1>"
+                },
+                "success+to2@simulator.amazonses.com": {
+                    "List-Unsubscribe": "<https://example.com/unsubscribe/to2>"
+                },
+            },
+            tags=["Live integration test", "Template send"],
+            metadata={"test": "data"},
+            merge_metadata={"success+to2@simulator.amazonses.com": {"user-id": "2"}},
         )
         message.send()
         recipient_status = message.anymail_status.recipients