Skip to content

Commit 75d7671

Browse files
authored
Add merge_metadata for other ESPs
Support merge_metadata in Mailgun, Mailjet, Mandrill, Postmark, SparkPost, and Test backends. (SendGrid covered in earlier PR.) Also: * Add `merge_metadata` to AnymailMessage, AnymailMessageMixin * Add `is_batch()` logic to BasePayload, for consistent handling * Docs Note: Mailjet implementation switches *all* batch sending from their "Recipients" field to to the "Messages" array bulk sending option. This allows an independent payload for each batch recipient. In addition to supporting merge_metadata, this also removes the prior limitation on mixing Cc/Bcc with merge_data. Closes #141.
1 parent 85dce5f commit 75d7671

22 files changed

+472
-136
lines changed

CHANGELOG.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ Breaking changes
3939
code is doing something like `message.anymail_status.recipients[email.lower()]`,
4040
you should remove the `.lower()`
4141

42+
Features
43+
~~~~~~~~
44+
45+
* Add new `merge_metadata` option for providing per-recipient metadata in batch
46+
sends. Available for all supported ESPs *except* Amazon SES and SendinBlue.
47+
See `docs <https://anymail.readthedocs.io/en/latest/sending/anymail_additions/#anymail.message.AnymailMessage.merge_metadata>`_.
48+
(Thanks `@janneThoft`_ for the idea and SendGrid implementation.)
49+
50+
* **Mailjet:** Remove limitation on using `cc` or `bcc` together with `merge_data`.
51+
52+
4253
Fixes
4354
~~~~~
4455

@@ -908,6 +919,7 @@ Features
908919
.. _@calvin: https://github.com/calvin
909920
.. _@costela: https://github.com/costela
910921
.. _@decibyte: https://github.com/decibyte
922+
.. _@janneThoft: https://github.com/janneThoft
911923
.. _@joshkersey: https://github.com/joshkersey
912924
.. _@Lekensteyn: https://github.com/Lekensteyn
913925
.. _@lewistaylor: https://github.com/lewistaylor

anymail/backends/base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,16 @@ class BasePayload(object):
250250
)
251251
esp_message_attrs = () # subclasses can override
252252

253+
# If any of these attrs are set on a message, treat the message
254+
# as a batch send (separate message for each `to` recipient):
255+
batch_attrs = ('merge_data', 'merge_metadata')
256+
253257
def __init__(self, message, defaults, backend):
254258
self.message = message
255259
self.defaults = defaults
256260
self.backend = backend
257261
self.esp_name = backend.esp_name
262+
self._batch_attrs_used = {attr: UNSET for attr in self.batch_attrs}
258263

259264
self.init_payload()
260265

@@ -287,6 +292,20 @@ def __init__(self, message, defaults, backend):
287292
# AttributeError here? Your Payload subclass is missing a set_<attr> implementation
288293
setter = getattr(self, 'set_%s' % attr)
289294
setter(value)
295+
if attr in self.batch_attrs:
296+
self._batch_attrs_used[attr] = (value is not UNSET)
297+
298+
def is_batch(self):
299+
"""
300+
Return True if the message should be treated as a batch send.
301+
302+
Intended to be used inside serialize_data or similar, after all relevant
303+
attributes have been processed. Will error if called before that (e.g.,
304+
inside a set_<attr> method or during __init__).
305+
"""
306+
batch_attrs_used = self._batch_attrs_used.values()
307+
assert UNSET not in batch_attrs_used, "Cannot call is_batch before all attributes processed"
308+
return any(batch_attrs_used)
290309

291310
def unsupported_feature(self, feature):
292311
if not self.backend.ignore_unsupported_features:

anymail/backends/mailgun.py

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
6868
self.all_recipients = [] # used for backend.parse_recipient_status
6969

7070
# late-binding of recipient-variables:
71-
self.merge_data = None
72-
self.merge_global_data = None
71+
self.merge_data = {}
72+
self.merge_global_data = {}
73+
self.metadata = {}
74+
self.merge_metadata = {}
7375
self.to_emails = []
7476

7577
super(MailgunPayload, self).__init__(message, defaults, backend, auth=auth, *args, **kwargs)
@@ -117,32 +119,51 @@ def get_request_params(self, api_url):
117119
return params
118120

119121
def serialize_data(self):
120-
self.populate_recipient_variables()
122+
if self.is_batch() or self.merge_global_data:
123+
self.populate_recipient_variables()
121124
return self.data
122125

123126
def populate_recipient_variables(self):
124-
"""Populate Mailgun recipient-variables header from merge data"""
125-
merge_data = self.merge_data
126-
127-
if self.merge_global_data is not None:
128-
# Mailgun doesn't support global variables.
129-
# We emulate them by populating recipient-variables for all recipients.
130-
if merge_data is not None:
131-
merge_data = merge_data.copy() # don't modify the original, which doesn't belong to us
132-
else:
133-
merge_data = {}
134-
for email in self.to_emails:
135-
try:
136-
recipient_data = merge_data[email]
137-
except KeyError:
138-
merge_data[email] = self.merge_global_data
139-
else:
140-
# Merge globals (recipient_data wins in conflict)
141-
merge_data[email] = self.merge_global_data.copy()
142-
merge_data[email].update(recipient_data)
143-
144-
if merge_data is not None:
145-
self.data['recipient-variables'] = self.serialize_json(merge_data)
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)
146167

147168
#
148169
# Payload construction
@@ -210,6 +231,7 @@ def set_envelope_sender(self, email):
210231
self.sender_domain = email.domain
211232

212233
def set_metadata(self, metadata):
234+
self.metadata = metadata # save for handling merge_metadata later
213235
for key, value in metadata.items():
214236
self.data["v:%s" % key] = value
215237

@@ -242,6 +264,10 @@ def set_merge_global_data(self, merge_global_data):
242264
# Processed at serialization time (to allow merging global data)
243265
self.merge_global_data = merge_global_data
244266

267+
def set_merge_metadata(self, merge_metadata):
268+
# Processed at serialization time (to allow combining with merge_data)
269+
self.merge_metadata = merge_metadata
270+
245271
def set_esp_extra(self, extra):
246272
self.data.update(extra)
247273
# Allow override of sender_domain via esp_extra

anymail/backends/mailjet.py

Lines changed: 53 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -80,31 +80,48 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
8080
'Content-Type': 'application/json',
8181
}
8282
# Late binding of recipients and their variables
83-
self.recipients = {}
84-
self.merge_data = None
83+
self.recipients = {'to': []}
84+
self.metadata = None
85+
self.merge_data = {}
86+
self.merge_metadata = {}
8587
super(MailjetPayload, self).__init__(message, defaults, backend,
8688
auth=auth, headers=http_headers, *args, **kwargs)
8789

8890
def get_api_endpoint(self):
8991
return "send"
9092

9193
def serialize_data(self):
92-
self._finish_recipients()
9394
self._populate_sender_from_template()
95+
if self.is_batch():
96+
self.data = {'Messages': [
97+
self._data_for_recipient(to_addr)
98+
for to_addr in self.recipients['to']
99+
]}
94100
return self.serialize_json(self.data)
95101

96-
#
97-
# Payload construction
98-
#
99-
100-
def _finish_recipients(self):
101-
# NOTE do not set both To and Recipients, it behaves specially: each
102-
# recipient receives a separate mail but the To address receives one
103-
# listing all recipients.
104-
if "cc" in self.recipients or "bcc" in self.recipients:
105-
self._finish_recipients_single()
106-
else:
107-
self._finish_recipients_with_vars()
102+
def _data_for_recipient(self, email):
103+
# Return send data for single recipient, without modifying self.data
104+
data = self.data.copy()
105+
data['To'] = self._format_email_for_mailjet(email)
106+
107+
if email.addr_spec in self.merge_data:
108+
recipient_merge_data = self.merge_data[email.addr_spec]
109+
if 'Vars' in data:
110+
data['Vars'] = data['Vars'].copy() # clone merge_global_data
111+
data['Vars'].update(recipient_merge_data)
112+
else:
113+
data['Vars'] = recipient_merge_data
114+
115+
if email.addr_spec in self.merge_metadata:
116+
recipient_metadata = self.merge_metadata[email.addr_spec]
117+
if self.metadata:
118+
metadata = self.metadata.copy() # clone toplevel metadata
119+
metadata.update(recipient_metadata)
120+
else:
121+
metadata = recipient_metadata
122+
data["Mj-EventPayLoad"] = self.serialize_json(metadata)
123+
124+
return data
108125

109126
def _populate_sender_from_template(self):
110127
# If no From address was given, use the address from the template.
@@ -137,42 +154,21 @@ def _populate_sender_from_template(self):
137154
email_message=self.message, response=response, backend=self.backend)
138155
self.set_from_email(parsed)
139156

140-
def _finish_recipients_with_vars(self):
141-
"""Send bulk mail with different variables for each mail."""
142-
assert "Cc" not in self.data and "Bcc" not in self.data
143-
recipients = []
144-
merge_data = self.merge_data or {}
145-
for email in self.recipients["to"]:
146-
recipient = {
147-
"Email": email.addr_spec,
148-
"Name": email.display_name,
149-
"Vars": merge_data.get(email.addr_spec)
150-
}
151-
# Strip out empty Name and Vars
152-
recipient = {k: v for k, v in recipient.items() if v}
153-
recipients.append(recipient)
154-
self.data["Recipients"] = recipients
155-
156-
def _finish_recipients_single(self):
157-
"""Send a single mail with some To, Cc and Bcc headers."""
158-
assert "Recipients" not in self.data
159-
if self.merge_data:
160-
# When Cc and Bcc headers are given, then merge data cannot be set.
161-
raise NotImplementedError("Cannot set merge data with bcc/cc")
162-
for recipient_type, emails in self.recipients.items():
163-
# Workaround Mailjet 3.0 bug parsing display-name with commas
164-
# (see test_comma_in_display_name in test_mailjet_backend for details)
165-
formatted_emails = [
166-
email.address if "," not in email.display_name
167-
# else name has a comma, so force it into MIME encoded-word utf-8 syntax:
168-
else EmailAddress(email.display_name.encode('utf-8'), email.addr_spec).formataddr('utf-8')
169-
for email in emails
170-
]
171-
self.data[recipient_type.capitalize()] = ", ".join(formatted_emails)
157+
def _format_email_for_mailjet(self, email):
158+
"""Return EmailAddress email converted to a string that Mailjet can parse properly"""
159+
# Workaround Mailjet 3.0 bug parsing display-name with commas
160+
# (see test_comma_in_display_name in test_mailjet_backend for details)
161+
if "," in email.display_name:
162+
return EmailAddress(email.display_name.encode('utf-8'), email.addr_spec).formataddr('utf-8')
163+
else:
164+
return email.address
165+
166+
#
167+
# Payload construction
168+
#
172169

173170
def init_payload(self):
174-
self.data = {
175-
}
171+
self.data = {}
176172

177173
def set_from_email(self, email):
178174
self.data["FromEmail"] = email.addr_spec
@@ -181,9 +177,10 @@ def set_from_email(self, email):
181177

182178
def set_recipients(self, recipient_type, emails):
183179
assert recipient_type in ["to", "cc", "bcc"]
184-
# Will be handled later in serialize_data
185180
if emails:
186-
self.recipients[recipient_type] = emails
181+
self.recipients[recipient_type] = emails # save for recipient_status processing
182+
self.data[recipient_type.capitalize()] = ", ".join(
183+
[self._format_email_for_mailjet(email) for email in emails])
187184

188185
def set_subject(self, subject):
189186
self.data["Subject"] = subject
@@ -225,8 +222,8 @@ def set_envelope_sender(self, email):
225222
self.data["Sender"] = email.addr_spec # ??? v3 docs unclear
226223

227224
def set_metadata(self, metadata):
228-
# Mailjet expects a single string payload
229225
self.data["Mj-EventPayLoad"] = self.serialize_json(metadata)
226+
self.metadata = metadata # keep original in case we need to merge with merge_metadata
230227

231228
def set_tags(self, tags):
232229
# The choices here are CustomID or Campaign, and Campaign seems closer
@@ -257,5 +254,9 @@ def set_merge_data(self, merge_data):
257254
def set_merge_global_data(self, merge_global_data):
258255
self.data["Vars"] = merge_global_data
259256

257+
def set_merge_metadata(self, merge_metadata):
258+
# Will be handled later in serialize_data
259+
self.merge_metadata = merge_metadata
260+
260261
def set_esp_extra(self, extra):
261262
self.data.update(extra)

anymail/backends/mandrill.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ def get_api_endpoint(self):
7979

8080
def serialize_data(self):
8181
self.process_esp_extra()
82+
if self.is_batch():
83+
# hide recipients from each other
84+
self.data['message']['preserve_recipients'] = False
8285
return self.serialize_json(self.data)
8386

8487
#
@@ -163,7 +166,6 @@ def set_template_id(self, template_id):
163166
self.data.setdefault("template_content", []) # Mandrill requires something here
164167

165168
def set_merge_data(self, merge_data):
166-
self.data['message']['preserve_recipients'] = False # if merge, hide recipients from each other
167169
self.data['message']['merge_vars'] = [
168170
{'rcpt': rcpt, 'vars': [{'name': key, 'content': rcpt_data[key]}
169171
for key in sorted(rcpt_data.keys())]} # sort for testing reproducibility
@@ -176,6 +178,13 @@ def set_merge_global_data(self, merge_global_data):
176178
for var, value in merge_global_data.items()
177179
]
178180

181+
def set_merge_metadata(self, merge_metadata):
182+
# recipient_metadata format is similar to, but not quite the same as, merge_vars:
183+
self.data['message']['recipient_metadata'] = [
184+
{'rcpt': rcpt, 'values': rcpt_data}
185+
for rcpt, rcpt_data in merge_metadata.items()
186+
]
187+
179188
def set_esp_extra(self, extra):
180189
# late bind in serialize_data, so that obsolete Djrill attrs can contribute
181190
self.esp_extra = extra

anymail/backends/postmark.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,11 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
156156
self.to_emails = []
157157
self.cc_and_bcc_emails = [] # need to track (separately) for parse_recipient_status
158158
self.merge_data = None
159+
self.merge_metadata = None
159160
super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs)
160161

161162
def get_api_endpoint(self):
162-
batch_send = self.merge_data is not None and len(self.to_emails) > 1
163+
batch_send = self.is_batch() and len(self.to_emails) > 1
163164
if 'TemplateAlias' in self.data or 'TemplateId' in self.data or 'TemplateModel' in self.data:
164165
if batch_send:
165166
return "email/batchWithTemplates"
@@ -197,6 +198,14 @@ def data_for_recipient(self, to):
197198
data["TemplateModel"].update(recipient_data)
198199
else:
199200
data["TemplateModel"] = recipient_data
201+
if self.merge_metadata and to.addr_spec in self.merge_metadata:
202+
recipient_metadata = self.merge_metadata[to.addr_spec]
203+
if "Metadata" in data:
204+
# merge recipient_metadata into toplevel metadata
205+
data["Metadata"] = data["Metadata"].copy()
206+
data["Metadata"].update(recipient_metadata)
207+
else:
208+
data["Metadata"] = recipient_metadata
200209
return data
201210

202211
#
@@ -298,6 +307,10 @@ def set_merge_data(self, merge_data):
298307
def set_merge_global_data(self, merge_global_data):
299308
self.data["TemplateModel"] = merge_global_data
300309

310+
def set_merge_metadata(self, merge_metadata):
311+
# late-bind
312+
self.merge_metadata = merge_metadata
313+
301314
def set_esp_extra(self, extra):
302315
self.data.update(extra)
303316
# Special handling for 'server_token':

0 commit comments

Comments
 (0)