@@ -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 )
0 commit comments