Feature: Implement merge_headers

Implement and document `merge_headers`
for all other ESPs that can support it. (See #371
for base and Amazon SES implementation.)

Closes #374
This commit is contained in:
Mike Edmunds
2024-06-20 15:31:58 -07:00
committed by GitHub
parent 6e696b8566
commit 0776b12331
35 changed files with 754 additions and 40 deletions

View File

@@ -568,6 +568,43 @@ class BrevoBackendAnymailFeatureTests(BrevoBackendMockAPITestCase):
{"notification_batch": "zx912"},
)
def test_merge_headers(self):
self.set_mock_response(json_data=self._mock_batch_response)
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
}
self.message.send()
data = self.get_api_call_json()
versions = data["messageVersions"]
self.assertEqual(len(versions), 2)
self.assertEqual(
versions[0]["headers"], {"List-Unsubscribe": "<https://example.com/a/>"}
)
self.assertEqual(
versions[1]["headers"], {"List-Unsubscribe": "<https://example.com/b/>"}
)
self.assertNotIn("params", versions[0]) # because no merge_data
# non-merge headers still in base data
self.assertEqual(
data["headers"],
{
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
)
def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options.

View File

@@ -113,6 +113,18 @@ class BrevoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
},
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
)
message.attach("attachment1.txt", "Here is some\ntext", "text/plain")

View File

@@ -111,7 +111,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
cc=["cc1@example.com", "Also CC <cc2@example.com>"],
headers={
"Reply-To": "another@example.com",
"X-MyHeader": "my value",
"x-my-header": "my value",
"Message-ID": "mycustommsgid@example.com",
},
)
@@ -126,8 +126,8 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
)
self.assertEqual(data["cc"], ["cc1@example.com", "Also CC <cc2@example.com>"])
self.assertEqual(data["h:Reply-To"], "another@example.com")
self.assertEqual(data["h:X-MyHeader"], "my value")
self.assertEqual(data["h:Message-ID"], "mycustommsgid@example.com")
self.assertEqual(data["h:X-My-Header"], "my value")
self.assertEqual(data["h:Message-Id"], "mycustommsgid@example.com")
# multiple recipients, but not a batch send:
self.assertNotIn("recipient-variables", data)
@@ -816,6 +816,51 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase):
):
self.message.send()
def test_merge_headers(self):
# Per-recipient merge_headers uses the same recipient-variables mechanism
# as above, using variable names starting with "h:"
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
"X-Custom": "custom-default",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
"X-No-Default": "custom-for-alice",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
"X-Custom": "custom-for-bob",
},
}
self.message.send()
data = self.get_api_call_data()
# non-merge header has fixed value:
self.assertEqual(data["h:List-Unsubscribe-Post"], "List-Unsubscribe=One-Click")
# merge headers refer to recipient-variables:
self.assertEqual(data["h:List-Unsubscribe"], "%recipient.h:List-Unsubscribe%")
self.assertEqual(data["h:X-Custom"], "%recipient.h:X-Custom%")
self.assertEqual(data["h:X-No-Default"], "%recipient.h:X-No-Default%")
# recipient-variables populates them:
self.assertJSONEqual(
data["recipient-variables"],
{
"alice@example.com": {
"h:List-Unsubscribe": "<https://example.com/a/>",
"h:X-Custom": "custom-default", # from extra_headers
"h:X-No-Default": "custom-for-alice",
},
"bob@example.com": {
"h:List-Unsubscribe": "<https://example.com/b/>",
"h:X-Custom": "custom-for-bob",
"h:X-No-Default": "", # no default in extra_headers
},
},
)
def test_force_batch(self):
# Mailgun uses presence of recipient-variables to indicate batch send
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]

View File

@@ -201,6 +201,36 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
# (We could try fetching the message from event["storage"]["url"]
# to verify content and other headers.)
def test_per_recipient_options(self):
message = AnymailMessage(
from_email=formataddr(("Test From", self.from_email)),
to=["test+to1@anymail.dev", '"Recipient 2" <test+to2@anymail.dev>'],
subject="Anymail Mailgun per-recipient options test",
body="This is the text body",
merge_metadata={
"test+to1@anymail.dev": {"meta1": "one", "meta2": "two"},
"test+to2@anymail.dev": {"meta1": "recipient 2"},
},
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
"X-Custom-Header": "default",
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
"X-Custom-Header": "custom",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
)
message.send()
recipient_status = message.anymail_status.recipients
self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "queued")
self.assertEqual(recipient_status["test+to2@anymail.dev"].status, "queued")
def test_stored_template(self):
message = AnymailMessage(
# name of a real template named in Anymail's Mailgun test account:

View File

@@ -562,6 +562,45 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
{"order_id": 678, "notification_batch": "zx912"},
)
def test_merge_headers(self):
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
}
self.message.send()
data = self.get_api_call_json()
messages = data["Messages"]
self.assertEqual(len(messages), 2)
self.assertEqual(messages[0]["To"][0]["Email"], "alice@example.com")
self.assertEqual(
messages[0]["Headers"],
{"List-Unsubscribe": "<https://example.com/a/>"},
)
self.assertEqual(messages[1]["To"][0]["Email"], "bob@example.com")
self.assertEqual(
messages[1]["Headers"],
{"List-Unsubscribe": "<https://example.com/b/>"},
)
# non-merge headers still in globals:
self.assertEqual(
data["Globals"]["Headers"],
{
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
)
def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options.

View File

@@ -113,6 +113,23 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"test+to2@anymail.dev": {"value": "two"},
},
merge_global_data={"global": "global_value"},
metadata={"customer-id": "unknown", "meta2": 2},
merge_metadata={
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
},
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
)
message.send()
recipient_status = message.anymail_status.recipients

View File

@@ -712,6 +712,49 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase):
self.assertEqual(messages[1]["To"], "Bob <bob@example.com>")
self.assertEqual(messages[1]["Metadata"], {"order_id": 678, "tier": "premium"})
def test_merge_headers(self):
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
}
self.message.send()
self.assert_esp_called("/email/batch")
data = self.get_api_call_json()
self.assertEqual(len(data), 2)
# Global and merge headers are combined:
self.assertEqual(data[0]["To"], "alice@example.com")
self.assertCountEqual(
data[0]["Headers"],
[
{"Name": "List-Unsubscribe", "Value": "<https://example.com/a/>"},
{
"Name": "List-Unsubscribe-Post",
"Value": "List-Unsubscribe=One-Click",
},
],
)
self.assertEqual(data[1]["To"], "Bob <bob@example.com>")
self.assertCountEqual(
data[1]["Headers"],
[
{"Name": "List-Unsubscribe", "Value": "<https://example.com/b/>"},
{
"Name": "List-Unsubscribe-Post",
"Value": "List-Unsubscribe=One-Click",
},
],
)
def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options.

View File

@@ -68,13 +68,29 @@ class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
cc=["test+cc1@anymail.dev", "Copy 2 <test+cc2@anymail.dev>"],
bcc=["test+bcc1@anymail.dev", "Blind Copy 2 <test+bcc2@anymail.dev>"],
reply_to=["reply1@example.com", "Reply 2 <reply2@example.com>"],
headers={"X-Anymail-Test": "value"},
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
# no send_at support
metadata={"meta1": "simple string", "meta2": 2},
tags=["tag 1"], # max one tag
track_opens=True,
track_clicks=True,
merge_data={}, # force batch send (distinct message for each `to`)
# either of these merge_ options will force batch send
# (unique message for each "to" recipient)
merge_metadata={
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
)
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")

View File

@@ -533,6 +533,49 @@ class ResendBackendAnymailFeatureTests(ResendBackendMockAPITestCase):
"faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb",
)
def test_merge_headers(self):
self.set_mock_response(json_data=self._mock_batch_response)
message = AnymailMessage(
from_email="from@example.com",
to=["alice@example.com", "Bob <bob@example.com>"],
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/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
)
message.send()
# merge_headers forces batch send API:
self.assert_esp_called("/emails/batch")
data = self.get_api_call_json()
self.assertEqual(len(data), 2)
self.assertEqual(data[0]["to"], ["alice@example.com"])
# global and recipient headers are combined:
self.assertEqual(
data[0]["headers"],
{
"List-Unsubscribe": "<https://example.com/a/>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
)
self.assertEqual(data[1]["to"], ["Bob <bob@example.com>"])
self.assertEqual(
data[1]["headers"],
{
"List-Unsubscribe": "<https://example.com/b/>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
)
def test_track_opens(self):
self.message.track_opens = True
with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"):

View File

@@ -87,7 +87,7 @@ class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
) # non-empty string
def test_batch_send(self):
# merge_metadata or merge_data will use batch send API
# merge_metadata, merge_headers, or merge_data will use batch send API
message = AnymailMessage(
subject="Anymail Resend batch sendintegration test",
body="This is the text body",
@@ -99,6 +99,18 @@ class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"test+to2@anymail.dev": {"meta3": "recipient 2"},
},
tags=["tag 1", "tag 2"],
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
)
message.attach_alternative("<p>HTML content</p>", "text/html")
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")

View File

@@ -1016,6 +1016,45 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase):
],
)
def test_merge_headers(self):
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
}
self.message.send()
data = self.get_api_call_json()
personalizations = data["personalizations"]
self.assertEqual(len(personalizations), 2)
self.assertEqual(personalizations[0]["to"][0]["email"], "alice@example.com")
self.assertEqual(
personalizations[0]["headers"],
{"List-Unsubscribe": "<https://example.com/a/>"},
)
self.assertEqual(personalizations[1]["to"][0]["email"], "bob@example.com")
self.assertEqual(
personalizations[1]["headers"],
{"List-Unsubscribe": "<https://example.com/b/>"},
)
# non-merge headers still in globals:
self.assertEqual(
data["headers"],
{
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
)
@override_settings(
ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False # else we force custom_args
)

View File

@@ -119,6 +119,23 @@ class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
esp_extra={
"merge_field_format": "%{}%",
},
metadata={"meta1": "simple string", "meta2": 2},
merge_metadata={
"to1@sink.sendgrid.net": {"meta3": "recipient 1"},
"to2@sink.sendgrid.net": {"meta3": "recipient 2"},
},
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
merge_headers={
"to1@sink.sendgrid.net": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"to2@sink.sendgrid.net": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
)
message.send()
recipient_status = message.anymail_status.recipients

View File

@@ -605,6 +605,58 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
)
self.assertEqual(data["metadata"], {"notification_batch": "zx912"})
def test_merge_headers(self):
self.set_mock_result(accepted=2)
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"X-Custom-1": "custom 1",
"X-Custom-2": "custom 2 (default)",
}
self.message.merge_headers = {
"alice@example.com": {
"X-Custom-2": "custom 2 alice",
"X-Custom-3": "custom 3 alice",
},
"bob@example.com": {"X-Custom-2": "custom 2 bob"},
}
self.message.send()
data = self.get_api_call_json()
recipients = data["recipients"]
self.assertEqual(len(recipients), 2)
self.assertEqual(recipients[0]["address"]["email"], "alice@example.com")
self.assertEqual(
recipients[0]["substitution_data"],
{
"Header__X_Custom_2": "custom 2 alice",
"Header__X_Custom_3": "custom 3 alice",
},
)
self.assertEqual(recipients[1]["address"]["email"], "bob@example.com")
self.assertEqual(
recipients[1]["substitution_data"],
{
"Header__X_Custom_2": "custom 2 bob",
},
)
# Indirect merge_headers through template substitutions:
self.assertEqual(
data["content"]["headers"],
{
"X-Custom-1": "custom 1", # (not a merge_header, value unchanged)
"X-Custom-2": "{{Header__X_Custom_2}}",
"X-Custom-3": "{{Header__X_Custom_3}}",
},
)
# Defaults for merge_headers in global substitution_data:
self.assertEqual(
data["substitution_data"],
{
"Header__X_Custom_2": "custom 2 (default)",
# No default specified for X-Custom-3; SparkPost will use empty string
},
)
def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options.

View File

@@ -116,6 +116,19 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"to2@test.sink.sparkpostmail.com": {"value": "two"},
},
merge_global_data={"global": "global_value"},
merge_metadata={
"to1@test.sink.sparkpostmail.com": {"meta1": "one"},
"to2@test.sink.sparkpostmail.com": {"meta1": "two"},
},
headers={
"X-Custom": "custom header default",
},
merge_headers={
# (Note that SparkPost doesn't support custom List-Unsubscribe headers)
"to1@test.sink.sparkpostmail.com": {
"X-Custom": "custom header one",
},
},
)
message.send()
recipient_status = message.anymail_status.recipients

View File

@@ -602,12 +602,53 @@ class UnisenderGoBackendAnymailFeatureTests(UnisenderGoBackendMockAPITestCase):
self.assertNotIn("to", headers)
self.assertNotIn("cc", headers)
def test_merge_headers(self):
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
}
self.message.merge_headers = {
"alice@example.com": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"bob@example.com": {
"List-Unsubscribe": "<https://example.com/b/>",
},
}
self.message.send()
data = self.get_api_call_json()
headers = data["message"]["headers"]
recipients = data["message"]["recipients"]
self.assertEqual(headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click")
# merge_headers List-Unsubscribe is handled as substitution:
self.assertEqual(headers["List-Unsubscribe"], "{{Header__List_Unsubscribe}}")
self.assertEqual(
recipients[0]["substitutions"],
{"Header__List_Unsubscribe": "<https://example.com/a/>"},
)
self.assertEqual(
recipients[1]["substitutions"],
# Header substitutions merged with other substitutions:
{"Header__List_Unsubscribe": "<https://example.com/b/>", "to_name": "Bob"},
)
def test_unsupported_merge_headers(self):
# Unisender Go only allows substitutions in the List-Unsubscribe header
self.message.merge_headers = {"to@example.com": {"X-Other": "not supported"}}
with self.assertRaisesMessage(
AnymailUnsupportedFeature, "'X-Other' in merge_headers"
):
self.message.send()
def test_cc_unsupported_with_batch_send(self):
self.message.merge_data = {}
self.message.cc = ["cc@example.com"]
with self.assertRaisesMessage(
AnymailUnsupportedFeature,
"cc with batch send (merge_data or merge_metadata)",
"cc with batch send (merge_data, merge_metadata, or merge_headers)",
):
self.message.send()

View File

@@ -147,6 +147,18 @@ class UnisenderGoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"test+to1@anymail.dev": {"customer-id": "ZXK9123"},
"test+to2@anymail.dev": {"customer-id": "ZZT4192"},
},
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:unsubscribe@example.com>",
},
merge_headers={
"test+to1@anymail.dev": {
"List-Unsubscribe": "<https://example.com/a/>",
},
"test+to2@anymail.dev": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
)
message.from_email = None # use template sender
message.attach("attachment1.txt", "Here is some\ntext", "text/plain")