Skip to content

Commit df29ee2

Browse files
authored
Mailgun: make merge_data work with stored handlebars templates
Mailgun has two different template mechanisms and two different ways of providing substitution variables to them. Update Anymail's normalized merge_data handling to work with either (while preserving existing batch send and metadata capabilities that also use Mailgun's custom data and recipient variables parameters). Completes work started by @anstosa in #156. Closes #155.
1 parent 8143b76 commit df29ee2

File tree

6 files changed

+354
-58
lines changed

6 files changed

+354
-58
lines changed

CHANGELOG.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ Release history
2525
^^^^^^^^^^^^^^^
2626
.. This extra heading level keeps the ToC from becoming unmanageably long
2727
28+
vNext
29+
-----
30+
31+
*Not yet released*
32+
33+
Features
34+
~~~~~~~~
35+
36+
* **Mailgun:** Support Mailgun's new (ESP stored) handlebars templates via `template_id`.
37+
See `docs <https://anymail.readthedocs.io/en/latest/esps/mailgun/#batch-sending-merge-and-esp-templates>`__.
38+
(Thanks `@anstosa`_.)
39+
40+
2841
v6.1
2942
----
3043

@@ -964,6 +977,7 @@ Features
964977
.. _#153: https://github.com/anymail/issues/153
965978

966979
.. _@ailionx: https://github.com/ailionx
980+
.. _@anstosa: https://github.com/anstosa
967981
.. _@calvin: https://github.com/calvin
968982
.. _@costela: https://github.com/costela
969983
.. _@decibyte: https://github.com/decibyte

anymail/backends/mailgun.py

Lines changed: 116 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -119,58 +119,122 @@ def get_request_params(self, api_url):
119119
return params
120120

121121
def serialize_data(self):
122-
if self.is_batch() or self.merge_global_data:
123-
self.populate_recipient_variables()
122+
self.populate_recipient_variables()
124123
return self.data
125124

125+
# A not-so-brief digression about Mailgun's batch sending, template personalization,
126+
# and metadata tracking capabilities...
127+
#
128+
# Mailgun has two kinds of templates:
129+
# * ESP-stored templates (handlebars syntax), referenced by template name in the
130+
# send API, with substitution data supplied as "custom data" variables.
131+
# Anymail's `template_id` maps to this feature.
132+
# * On-the-fly templating (`%recipient.KEY%` syntax), with template variables
133+
# appearing directly in the message headers and/or body, and data supplied
134+
# as "recipient variables" per-recipient personalizations. Mailgun docs also
135+
# sometimes refer to this data as "template variables," but it's distinct from
136+
# the substitution data used for stored handelbars templates.
137+
#
138+
# Mailgun has two mechanisms for supplying additional data with a message:
139+
# * "Custom data" is supplied via `v:KEY` and/or `h:X-Mailgun-Variables` fields.
140+
# Custom data is passed to tracking webhooks (as 'user-variables') and is
141+
# available for `{{substitutions}}` in ESP-stored handlebars templates.
142+
# Normally, the same custom data is applied to every recipient of a message.
143+
# * "Recipient variables" are supplied via the `recipient-variables` field, and
144+
# provide per-recipient data for batch sending. The recipient specific values
145+
# are available as `%recipient.KEY%` virtually anywhere in the message
146+
# (including header fields and other parameters).
147+
#
148+
# Anymail needs both mechanisms to map its normalized metadata and template merge_data
149+
# features to Mailgun:
150+
# (1) Anymail's `metadata` maps directly to Mailgun's custom data, where it can be
151+
# accessed from webhooks.
152+
# (2) Anymail's `merge_metadata` (per-recipient metadata for batch sends) maps
153+
# *indirectly* through recipient-variables to Mailgun's custom data. To avoid
154+
# conflicts, the recipient-variables mapping prepends 'v:' to merge_metadata keys.
155+
# (E.g., Mailgun's custom-data "user" is set to "%recipient.v:user", which picks
156+
# up its per-recipient value from Mailgun's `recipient-variables[to_email]["v:user"]`.)
157+
# (3) Anymail's `merge_data` (per-recipient template substitutions) maps directly to
158+
# Mailgun's `recipient-variables`, where it can be referenced in on-the-fly templates.
159+
# (4) Anymail's `merge_global_data` (global template substitutions) is copied to
160+
# Mailgun's `recipient-variables` for every recipient, as the default for missing
161+
# `merge_data` keys.
162+
# (5) Only if a stored template is used, `merge_data` and `merge_global_data` are
163+
# *also* mapped *indirectly* through recipient-variables to Mailgun's custom data,
164+
# where they can be referenced in handlebars {{substitutions}}.
165+
# (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks
166+
# up its per-recipient value from Mailgun's `recipient-variables[to_email]["name"]`.)
167+
#
168+
# If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or
169+
# `merge_metadata`) are used together, there's a possibility of conflicting keys in
170+
# Mailgun's custom data. Anymail treats that conflict as an unsupported feature error.
171+
126172
def populate_recipient_variables(self):
127-
"""Populate Mailgun recipient-variables from merge data and metadata"""
128-
merge_metadata_keys = set() # all keys used in any recipient's merge_metadata
129-
for recipient_metadata in self.merge_metadata.values():
130-
merge_metadata_keys.update(recipient_metadata.keys())
131-
metadata_vars = {key: "v:%s" % key for key in merge_metadata_keys} # custom-var for key
132-
133-
# Set up custom-var substitutions for merge metadata
134-
# data['v:SomeMergeMetadataKey'] = '%recipient.v:SomeMergeMetadataKey%'
135-
for var in metadata_vars.values():
136-
self.data[var] = "%recipient.{var}%".format(var=var)
137-
138-
# Any (toplevel) metadata that is also in (any) merge_metadata must be be moved
139-
# into recipient-variables; and all merge_metadata vars must have defaults
140-
# (else they'll get the '%recipient.v:SomeMergeMetadataKey%' literal string).
141-
base_metadata = {metadata_vars[key]: self.metadata.get(key, '')
142-
for key in merge_metadata_keys}
143-
144-
recipient_vars = {}
145-
for addr in self.to_emails:
146-
# For each recipient, Mailgun recipient-variables[addr] is merger of:
147-
# 1. metadata, for any keys that appear in merge_metadata
148-
recipient_data = base_metadata.copy()
149-
150-
# 2. merge_metadata[addr], with keys prefixed with 'v:'
151-
if addr in self.merge_metadata:
152-
recipient_data.update({
153-
metadata_vars[key]: value for key, value in self.merge_metadata[addr].items()
154-
})
155-
156-
# 3. merge_global_data (because Mailgun doesn't support global variables)
157-
recipient_data.update(self.merge_global_data)
158-
159-
# 4. merge_data[addr]
160-
if addr in self.merge_data:
161-
recipient_data.update(self.merge_data[addr])
162-
163-
if recipient_data:
164-
recipient_vars[addr] = recipient_data
165-
166-
self.data['recipient-variables'] = self.serialize_json(recipient_vars)
173+
"""Populate Mailgun recipient-variables and custom data from merge data and metadata"""
174+
# (numbers refer to detailed explanation above)
175+
# Mailgun parameters to construct:
176+
recipient_variables = {}
177+
custom_data = {}
178+
179+
# (1) metadata --> Mailgun custom_data
180+
custom_data.update(self.metadata)
181+
182+
# (2) merge_metadata --> Mailgun custom_data via recipient_variables
183+
if self.merge_metadata:
184+
def vkey(key): # 'v:key'
185+
return 'v:{}'.format(key)
186+
187+
merge_metadata_keys = flatset( # all keys used in any recipient's merge_metadata
188+
recipient_data.keys() for recipient_data in self.merge_metadata.values())
189+
custom_data.update({ # custom_data['key'] = '%recipient.v:key%' indirection
190+
key: '%recipient.{}%'.format(vkey(key))
191+
for key in merge_metadata_keys})
192+
base_recipient_data = { # defaults for each recipient must cover all keys
193+
vkey(key): self.metadata.get(key, '')
194+
for key in merge_metadata_keys}
195+
for email in self.to_emails:
196+
this_recipient_data = base_recipient_data.copy()
197+
this_recipient_data.update({
198+
vkey(key): value
199+
for key, value in self.merge_metadata.get(email, {}).items()})
200+
recipient_variables.setdefault(email, {}).update(this_recipient_data)
201+
202+
# (3) and (4) merge_data, merge_global_data --> Mailgun recipient_variables
203+
if self.merge_data or self.merge_global_data:
204+
merge_data_keys = flatset( # all keys used in any recipient's merge_data
205+
recipient_data.keys() for recipient_data in self.merge_data.values())
206+
merge_data_keys = merge_data_keys.union(self.merge_global_data.keys())
207+
base_recipient_data = { # defaults for each recipient must cover all keys
208+
key: self.merge_global_data.get(key, '')
209+
for key in merge_data_keys}
210+
for email in self.to_emails:
211+
this_recipient_data = base_recipient_data.copy()
212+
this_recipient_data.update(self.merge_data.get(email, {}))
213+
recipient_variables.setdefault(email, {}).update(this_recipient_data)
214+
215+
# (5) if template, also map Mailgun custom_data to per-recipient_variables
216+
if self.data.get('template') is not None:
217+
conflicts = merge_data_keys.intersection(custom_data.keys())
218+
if conflicts:
219+
self.unsupported_feature(
220+
"conflicting merge_data and metadata keys (%s) when using template_id"
221+
% ', '.join("'%s'" % key for key in conflicts))
222+
custom_data.update({ # custom_data['key'] = '%recipient.key%' indirection
223+
key: '%recipient.{}%'.format(key)
224+
for key in merge_data_keys})
225+
226+
# populate Mailgun params
227+
self.data.update({'v:%s' % key: value
228+
for key, value in custom_data.items()})
229+
if recipient_variables or self.is_batch():
230+
self.data['recipient-variables'] = self.serialize_json(recipient_variables)
167231

168232
#
169233
# Payload construction
170234
#
171235

172236
def init_payload(self):
173-
self.data = {} # {field: [multiple, values]}
237+
self.data = {} # {field: [multiple, values]}
174238
self.files = [] # [(field, multiple), (field, values)]
175239
self.headers = {}
176240

@@ -285,3 +349,12 @@ def isascii(s):
285349
except UnicodeEncodeError:
286350
return False
287351
return True
352+
353+
354+
def flatset(iterables):
355+
"""Return a set of the items in a single-level flattening of iterables
356+
357+
>>> flatset([1, 2], [2, 3])
358+
set(1, 2, 3)
359+
"""
360+
return set(item for iterable in iterables for item in iterable)

docs/esps/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Email Service Provider |Amazon SES| |Mailgun| |Mailje
4646

4747
.. rubric:: :ref:`templates-and-merge`
4848
---------------------------------------------------------------------------------------------------------------------------------------------------
49-
:attr:`~AnymailMessage.template_id` Yes No Yes Yes Yes Yes Yes Yes
49+
:attr:`~AnymailMessage.template_id` Yes Yes Yes Yes Yes Yes Yes Yes
5050
:attr:`~AnymailMessage.merge_data` Yes Yes Yes Yes No Yes No Yes
5151
:attr:`~AnymailMessage.merge_global_data` Yes (emulated) Yes Yes Yes Yes Yes Yes
5252

docs/esps/mailgun.rst

Lines changed: 125 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,13 @@ Limitations and quirks
217217
the message to send, so it won't be present in your Mailgun API logs or the metadata
218218
that is sent to tracking webhooks.)
219219

220+
**Additional limitations on merge_data with template_id**
221+
If you are using Mailgun's stored handlebars templates (Anymail's
222+
:attr:`~anymail.message.AnymailMessage.template_id`), :attr:`~anymail.message.AnymailMessage.merge_data`
223+
cannot contain complex types or have any keys that conflict with
224+
:attr:`~anymail.message.AnymailMessage.metadata`. See :ref:`mailgun-template-limitations`
225+
below for more details.
226+
220227
**merge_metadata values default to empty string**
221228
If you use Anymail's :attr:`~anymail.message.AnymailMessage.merge_metadata` feature,
222229
and you supply metadata keys for some recipients but not others, Anymail will first
@@ -233,20 +240,43 @@ Limitations and quirks
233240

234241
.. _mailgun-templates:
235242

236-
Batch sending/merge
243+
Batch sending/merge and ESP templates
237244
-------------------------------------
238245

239-
Mailgun supports :ref:`batch sending <batch-send>` with per-recipient
240-
merge data. You can refer to Mailgun "recipient variables" in your
241-
message subject and body, and supply the values with Anymail's
242-
normalized :attr:`~anymail.message.AnymailMessage.merge_data`
243-
and :attr:`~anymail.message.AnymailMessage.merge_global_data`
244-
message attributes:
246+
Mailgun supports :ref:`ESP stored templates <esp-stored-templates>`, on-the-fly
247+
templating, and :ref:`batch sending <batch-send>` with per-recipient merge data.
248+
249+
.. versionchanged:: 6.2
250+
251+
Added support for Mailgun's stored (handlebars) templates.
252+
253+
Mailgun has two different syntaxes for substituting data into templates:
254+
255+
* "Recipient variables" look like ``%recipient.name%``, and are used with on-the-fly
256+
templates. You can refer to a recipient variable inside a message's body, subject,
257+
or other message attributes defined in your Django code. See `Mailgun batch sending`_
258+
for more information. (Note that Mailgun's docs also sometimes refer to recipient
259+
variables as "template *variables*," and there are some additional predefined ones
260+
described in their docs.)
261+
262+
* "Template *substitutions*" look like ``{{ name }}``, and can *only* be used in
263+
handlebars templates that are defined and stored in your Mailgun account (via
264+
the Mailgun dashboard or API). You refer to a stored template using Anymail's
265+
:attr:`~anymail.message.AnymailMessage.template_id` in your Django code.
266+
See `Mailgun templates`_ for more information.
267+
268+
With either type of template, you supply the substitution data using Anymail's
269+
normalized :attr:`~anymail.message.AnymailMessage.merge_data` and
270+
:attr:`~anymail.message.AnymailMessage.merge_global_data` message attributes. Anymail
271+
will figure out the correct Mailgun API parameters to use.
272+
273+
Here's an example defining an on-the-fly template that uses Mailgun recipient variables:
245274

246275
.. code-block:: python
247276
248277
message = EmailMessage(
249-
...
278+
from_email="[email protected]",
279+
# Use %recipient.___% syntax in subject and body:
250280
subject="Your order %recipient.order_no% has shipped",
251281
body="""Hi %recipient.name%,
252282
We shipped your order %recipient.order_no%
@@ -262,16 +292,98 @@ message attributes:
262292
'ship_date': "May 15" # Anymail maps globals to all recipients
263293
}
264294
295+
And here's an example that uses the same data with a stored template, which could refer
296+
to ``{{ name }}``, ``{{ order_no }}``, and ``{{ ship_date }}`` in its definition:
297+
298+
.. code-block:: python
299+
300+
message = EmailMessage(
301+
from_email="[email protected]",
302+
# The message body and html_body come from from the stored template.
303+
# (You can still use %recipient.___% fields in the subject:)
304+
subject="Your order %recipient.order_no% has shipped",
305+
306+
)
307+
message.template_id = 'shipping-notification' # name of template in our account
308+
# The substitution data is exactly the same as in the previous example:
309+
message.merge_data = {
310+
'[email protected]': {'name': "Alice", 'order_no': "12345"},
311+
'[email protected]': {'name': "Bob", 'order_no': "54321"},
312+
}
313+
message.merge_global_data = {
314+
'ship_date': "May 15" # Anymail maps globals to all recipients
315+
}
316+
317+
When you supply per-recipient :attr:`~anymail.message.AnymailMessage.merge_data`,
318+
Anymail supplies Mailgun's ``recipient-variables`` parameter, which puts Mailgun
319+
in batch sending mode so that each "to" recipient sees only their own email address.
320+
(Any cc's or bcc's will be duplicated for *every* to-recipient.)
321+
322+
If you want to use batch sending with a regular message (without a template), set
323+
merge data to an empty dict: `message.merge_data = {}`.
324+
265325
Mailgun does not natively support global merge data. Anymail emulates
266-
the capability by copying any `merge_global_data` values to each
267-
recipient's section in Mailgun's "recipient-variables" API parameter.
326+
the capability by copying any :attr:`~anymail.message.AnymailMessage.merge_global_data`
327+
values to every recipient.
328+
329+
.. _mailgun-template-limitations:
330+
331+
Limitations with stored handlebars templates
332+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
333+
334+
Although Anymail tries to insulate you from Mailgun's relatively complicated API
335+
parameters for template substitutions in batch sends, there are two cases it can't
336+
handle. These *only* apply to stored handlebars templates (when you've set Anymail's
337+
:attr:`~anymail.message.AnymailMessage.template_id` attribute).
338+
339+
First, metadata and template merge data substitutions use the same underlying
340+
"custom data" API parameters when a handlebars template is used. If you have any
341+
duplicate keys between your tracking metadata
342+
(:attr:`~anymail.message.AnymailMessage.metadata`/:attr:`~anymail.message.AnymailMessage.merge_metadata`)
343+
and your template merge data
344+
(:attr:`~anymail.message.AnymailMessage.merge_data`/:attr:`~anymail.message.AnymailMessage.merge_global_data`),
345+
Anymail will raise an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error.
346+
347+
Second, Mailgun's API does not allow complex data types like lists or dicts to be
348+
passed as template substitutions for a batch send (confirmed with Mailgun support
349+
8/2019). Your Anymail :attr:`~anymail.message.AnymailMessage.merge_data` and
350+
:attr:`~anymail.message.AnymailMessage.merge_global_data` should only use simple
351+
types like string or number. This means you cannot use the handlebars ``{{#each item}}``
352+
block helper or dotted field notation like ``{{object.field}}`` with data passed
353+
through Anymail's normalized merge data attributes.
354+
355+
Most ESPs do not support complex merge data types, so trying to do that is not recommended
356+
anyway, for portability reasons. But if you *do* want to pass complex types to Mailgun
357+
handlebars templates, and you're only sending to one recipient at a time, here's a
358+
(non-portable!) workaround:
268359

269-
See the `Mailgun batch sending`_ docs for more information.
360+
.. code-block:: python
270361
271-
.. _Mailgun batch sending:
272-
https://documentation.mailgun.com/user_manual.html#batch-sending
362+
# Using complex substitutions with Mailgun handlebars templates.
363+
# This works only for a single recipient, and is not at all portable between ESPs.
364+
message = EmailMessage(
365+
from_email="[email protected]",
366+
to=["[email protected]"] # single recipient *only* (no batch send)
367+
subject="Your order has shipped", # recipient variables *not* available
368+
)
369+
message.template_id = 'shipping-notification' # name of template in our account
370+
substitutions = {
371+
'items': [ # complex substitution data
372+
{'product': "Anvil", 'quantity': 1},
373+
{'product': "Tacks", 'quantity': 100},
374+
],
375+
'ship_date': "May 15",
376+
}
377+
# Do *not* set Anymail's message.merge_data, merge_global_data, or merge_metadata.
378+
# Instead add Mailgun custom variables directly:
379+
message.extra_headers['X-Mailgun-Variables'] = json.dumps(substitutions)
273380
274381
382+
.. _Mailgun batch sending:
383+
https://documentation.mailgun.com/en/latest/user_manual.html#batch-sending
384+
.. _Mailgun templates:
385+
https://documentation.mailgun.com/en/latest/user_manual.html#templates
386+
275387
.. _mailgun-webhooks:
276388

277389
Status tracking webhooks

0 commit comments

Comments
 (0)