Skip to content

Commit

Permalink
Mailgun: support merge_headers
Browse files Browse the repository at this point in the history
  • Loading branch information
medmunds committed Jun 20, 2024
1 parent 10a5ee3 commit c7449fd
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 5 deletions.
44 changes: 42 additions & 2 deletions anymail/backends/mailgun.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
self.merge_global_data = {}
self.metadata = {}
self.merge_metadata = {}
self.merge_headers = {}
self.to_emails = []

super().__init__(message, defaults, backend, auth=auth, *args, **kwargs)
Expand Down Expand Up @@ -191,6 +192,8 @@ def serialize_data(self):
# (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks
# up its per-recipient value from Mailgun's
# `recipient-variables[to_email]["name"]`.)
# (6) Anymail's `merge_headers` (per-recipient headers) maps to recipient-variables
# prepended with 'h:'.
#
# If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or
# `merge_metadata`) are used together, there's a possibility of conflicting keys
Expand Down Expand Up @@ -268,6 +271,40 @@ def vkey(key): # 'v:key'
{key: "%recipient.{}%".format(key) for key in merge_data_keys}
)

# (6) merge_headers --> Mailgun recipient_variables via 'h:'-prefixed keys
if self.merge_headers:

def hkey(field_name): # 'h:Field-Name'
return "h:{}".format(field_name.title())

merge_header_fields = flatset(
recipient_headers.keys()
for recipient_headers in self.merge_headers.values()
)
merge_header_defaults = {
# existing h:Field-Name value (from extra_headers), or empty string
field: self.data.get(hkey(field), "")
for field in merge_header_fields
}
self.data.update(
# Set up 'h:Field-Name': '%recipient.h:Field-Name%' indirection
{
hvar: f"%recipient.{hvar}%"
for hvar in [hkey(field) for field in merge_header_fields]
}
)

for email in self.to_emails:
# Each recipient's recipient_variables needs _all_ merge header fields
recipient_headers = merge_header_defaults.copy()
recipient_headers.update(self.merge_headers.get(email, {}))
recipient_variables_for_headers = {
hkey(field): value for field, value in recipient_headers.items()
}
recipient_variables.setdefault(email, {}).update(
recipient_variables_for_headers
)

# populate Mailgun params
self.data.update({"v:%s" % key: value for key, value in custom_data.items()})
if recipient_variables or self.is_batch():
Expand Down Expand Up @@ -308,8 +345,8 @@ def set_reply_to(self, emails):
self.data["h:Reply-To"] = reply_to

def set_extra_headers(self, headers):
for key, value in headers.items():
self.data["h:%s" % key] = value
for field, value in headers.items():
self.data["h:%s" % field.title()] = value

def set_text_body(self, body):
self.data["text"] = body
Expand Down Expand Up @@ -385,6 +422,9 @@ def set_merge_metadata(self, merge_metadata):
# Processed at serialization time (to allow combining with merge_data)
self.merge_metadata = merge_metadata

def set_merge_headers(self, merge_headers):
self.merge_headers = merge_headers

def set_esp_extra(self, extra):
self.data.update(extra)
# Allow override of sender_domain via esp_extra
Expand Down
51 changes: 48 additions & 3 deletions tests/test_mailgun_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def test_email_message(self):
cc=["[email protected]", "Also CC <[email protected]>"],
headers={
"Reply-To": "[email protected]",
"X-MyHeader": "my value",
"x-my-header": "my value",
"Message-ID": "[email protected]",
},
)
Expand All @@ -126,8 +126,8 @@ def test_email_message(self):
)
self.assertEqual(data["cc"], ["[email protected]", "Also CC <[email protected]>"])
self.assertEqual(data["h:Reply-To"], "[email protected]")
self.assertEqual(data["h:X-MyHeader"], "my value")
self.assertEqual(data["h:Message-ID"], "[email protected]")
self.assertEqual(data["h:X-My-Header"], "my value")
self.assertEqual(data["h:Message-Id"], "[email protected]")
# multiple recipients, but not a batch send:
self.assertNotIn("recipient-variables", data)

Expand Down Expand Up @@ -816,6 +816,51 @@ def test_conflicting_merge_data_with_merge_metadata_and_template(self):
):
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 = ["[email protected]", "Bob <[email protected]>"]
self.message.extra_headers = {
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:[email protected]>",
"X-Custom": "custom-default",
}
self.message.merge_headers = {
"[email protected]": {
"List-Unsubscribe": "<https://example.com/a/>",
"X-No-Default": "custom-for-alice",
},
"[email protected]": {
"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"],
{
"[email protected]": {
"h:List-Unsubscribe": "<https://example.com/a/>",
"h:X-Custom": "custom-default", # from extra_headers
"h:X-No-Default": "custom-for-alice",
},
"[email protected]": {
"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 = ["[email protected]", "Bob <[email protected]>"]
Expand Down
30 changes: 30 additions & 0 deletions tests/test_mailgun_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,36 @@ def test_all_options(self):
# (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=["[email protected]", '"Recipient 2" <[email protected]>'],
subject="Anymail Mailgun per-recipient options test",
body="This is the text body",
merge_metadata={
"[email protected]": {"meta1": "one", "meta2": "two"},
"[email protected]": {"meta1": "recipient 2"},
},
headers={
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<mailto:[email protected]>",
"X-Custom-Header": "default",
},
merge_headers={
"[email protected]": {
"List-Unsubscribe": "<https://example.com/a/>",
"X-Custom-Header": "custom",
},
"[email protected]": {
"List-Unsubscribe": "<https://example.com/b/>",
},
},
)
message.send()
recipient_status = message.anymail_status.recipients
self.assertEqual(recipient_status["[email protected]"].status, "queued")
self.assertEqual(recipient_status["[email protected]"].status, "queued")

def test_stored_template(self):
message = AnymailMessage(
# name of a real template named in Anymail's Mailgun test account:
Expand Down

0 comments on commit c7449fd

Please sign in to comment.