Skip to content

Commit a0b92be

Browse files
committed
Mandrill: support esp_extra
* Merge esp_extra with Mandrill send payload * Handle pythonic forms of `recipient_metadata` and `template_content` in esp_extra * DeprecationWarning for Mandrill EmailMessage attributes inherited from Djrill
1 parent d1da685 commit a0b92be

File tree

4 files changed

+388
-216
lines changed

4 files changed

+388
-216
lines changed

anymail/backends/mandrill.py

Lines changed: 93 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import warnings
12
from datetime import datetime
23

3-
from ..exceptions import AnymailRequestsAPIError
4+
from ..exceptions import AnymailRequestsAPIError, AnymailWarning
45
from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES
56
from ..utils import last, combine, get_anymail_setting
67

@@ -43,14 +44,8 @@ def parse_recipient_status(self, response, payload, message):
4344
return recipient_status
4445

4546

46-
def _expand_merge_vars(vardict):
47-
"""Convert a Python dict to an array of name-content used by Mandrill.
48-
49-
{ name: value, ... } --> [ {'name': name, 'content': value }, ... ]
50-
"""
51-
# For testing reproducibility, we sort the keys
52-
return [{'name': name, 'content': vardict[name]}
53-
for name in sorted(vardict.keys())]
47+
class DjrillDeprecationWarning(AnymailWarning, DeprecationWarning):
48+
"""Warning for features carried over from Djrill that will be removed soon"""
5449

5550

5651
def encode_date_for_mandrill(dt):
@@ -69,13 +64,18 @@ def encode_date_for_mandrill(dt):
6964

7065
class MandrillPayload(RequestsPayload):
7166

67+
def __init__(self, *args, **kwargs):
68+
self.esp_extra = {} # late-bound in serialize_data
69+
super(MandrillPayload, self).__init__(*args, **kwargs)
70+
7271
def get_api_endpoint(self):
7372
if 'template_name' in self.data:
7473
return "messages/send-template.json"
7574
else:
7675
return "messages/send.json"
7776

7877
def serialize_data(self):
78+
self.process_esp_extra()
7979
return self.serialize_json(self.data)
8080

8181
#
@@ -89,7 +89,9 @@ def init_payload(self):
8989
}
9090

9191
def set_from_email(self, email):
92-
if not getattr(self.message, "use_template_from", False): # Djrill compat!
92+
if getattr(self.message, "use_template_from", False):
93+
self.deprecation_warning('message.use_template_from', 'message.from_email = None')
94+
else:
9395
self.data["message"]["from_email"] = email.email
9496
if email.name:
9597
self.data["message"]["from_name"] = email.name
@@ -100,7 +102,9 @@ def add_recipient(self, recipient_type, email):
100102
to_list.append({"email": email.email, "name": email.name, "type": recipient_type})
101103

102104
def set_subject(self, subject):
103-
if not getattr(self.message, "use_template_subject", False): # Djrill compat!
105+
if getattr(self.message, "use_template_subject", False):
106+
self.deprecation_warning('message.use_template_subject', 'message.subject = None')
107+
else:
104108
self.data["message"]["subject"] = subject
105109

106110
def set_reply_to(self, emails):
@@ -166,9 +170,59 @@ def set_merge_global_data(self, merge_global_data):
166170
]
167171

168172
def set_esp_extra(self, extra):
169-
pass
173+
# late bind in serialize_data, so that obsolete Djrill attrs can contribute
174+
self.esp_extra = extra
170175

171-
# Djrill leftovers
176+
def process_esp_extra(self):
177+
if self.esp_extra is not None and len(self.esp_extra) > 0:
178+
esp_extra = self.esp_extra
179+
# Convert pythonic template_content dict to Mandrill name/content list
180+
try:
181+
template_content = esp_extra['template_content']
182+
except KeyError:
183+
pass
184+
else:
185+
if hasattr(template_content, 'items'): # if it's dict-like
186+
if esp_extra is self.esp_extra:
187+
esp_extra = self.esp_extra.copy() # don't modify caller's value
188+
esp_extra['template_content'] = [
189+
{'name': var, 'content': value}
190+
for var, value in template_content.items()]
191+
# Convert pythonic recipient_metadata dict to Mandrill rcpt/values list
192+
try:
193+
recipient_metadata = esp_extra['message']['recipient_metadata']
194+
except KeyError:
195+
pass
196+
else:
197+
if hasattr(recipient_metadata, 'keys'): # if it's dict-like
198+
if esp_extra['message'] is self.esp_extra['message']:
199+
esp_extra['message'] = self.esp_extra['message'].copy() # don't modify caller's value
200+
# For testing reproducibility, we sort the recipients
201+
esp_extra['message']['recipient_metadata'] = [
202+
{'rcpt': rcpt, 'values': recipient_metadata[rcpt]}
203+
for rcpt in sorted(recipient_metadata.keys())]
204+
# Merge esp_extra with payload data: shallow merge within ['message'] and top-level keys
205+
self.data.update({k:v for k,v in esp_extra.items() if k != 'message'})
206+
try:
207+
self.data['message'].update(esp_extra['message'])
208+
except KeyError:
209+
pass
210+
211+
# Djrill deprecated message attrs
212+
213+
def deprecation_warning(self, feature, replacement=None):
214+
msg = "Djrill's `%s` will be removed in an upcoming Anymail release." % feature
215+
if replacement:
216+
msg += " Use `%s` instead." % replacement
217+
warnings.warn(msg, DjrillDeprecationWarning)
218+
219+
def deprecated_to_esp_extra(self, attr, in_message_dict=False):
220+
feature = "message.%s" % attr
221+
if in_message_dict:
222+
replacement = "message.esp_extra = {'message': {'%s': <value>}}" % attr
223+
else:
224+
replacement = "message.esp_extra = {'%s': <value>}" % attr
225+
self.deprecation_warning(feature, replacement)
172226

173227
esp_message_attrs = (
174228
('async', last, None),
@@ -188,25 +242,40 @@ def set_esp_extra(self, extra):
188242
('subaccount', last, None),
189243
('google_analytics_domains', last, None),
190244
('google_analytics_campaign', last, None),
245+
('global_merge_vars', combine, None),
246+
('merge_vars', combine, None),
191247
('recipient_metadata', combine, None),
192-
('template_content', combine, _expand_merge_vars),
248+
('template_name', last, None),
249+
('template_content', combine, None),
193250
)
194251

195252
def set_async(self, async):
196-
self.data["async"] = async
253+
self.deprecated_to_esp_extra('async')
254+
self.esp_extra['async'] = async
197255

198256
def set_ip_pool(self, ip_pool):
199-
self.data["ip_pool"] = ip_pool
257+
self.deprecated_to_esp_extra('ip_pool')
258+
self.esp_extra['ip_pool'] = ip_pool
259+
260+
def set_global_merge_vars(self, global_merge_vars):
261+
self.deprecation_warning('message.global_merge_vars', 'message.merge_global_data')
262+
self.set_merge_global_data(global_merge_vars)
263+
264+
def set_merge_vars(self, merge_vars):
265+
self.deprecation_warning('message.merge_vars', 'message.merge_data')
266+
self.set_merge_data(merge_vars)
267+
268+
def set_template_name(self, template_name):
269+
self.deprecation_warning('message.template_name', 'message.template_id')
270+
self.set_template_id(template_name)
200271

201272
def set_template_content(self, template_content):
202-
self.data["template_content"] = template_content
273+
self.deprecated_to_esp_extra('template_content')
274+
self.esp_extra['template_content'] = template_content
203275

204276
def set_recipient_metadata(self, recipient_metadata):
205-
# For testing reproducibility, we sort the recipients
206-
self.data['message']['recipient_metadata'] = [
207-
{'rcpt': rcpt, 'values': recipient_metadata[rcpt]}
208-
for rcpt in sorted(recipient_metadata.keys())
209-
]
277+
self.deprecated_to_esp_extra('recipient_metadata', in_message_dict=True)
278+
self.esp_extra.setdefault('message', {})['recipient_metadata'] = recipient_metadata
210279

211280
# Set up simple set_<attr> functions for any missing esp_message_attrs attrs
212281
# (avoids dozens of simple `self.data["message"][<attr>] = value` functions)
@@ -225,7 +294,8 @@ def define_message_attr_setters(cls):
225294
def make_setter(attr, setter_name):
226295
# sure wish we could use functools.partial to create instance methods (descriptors)
227296
def setter(self, value):
228-
self.data["message"][attr] = value
297+
self.deprecated_to_esp_extra(attr, in_message_dict=True)
298+
self.esp_extra.setdefault('message', {})[attr] = value
229299
setter.__name__ = setter_name
230300
return setter
231301

docs/esps/mandrill.rst

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,38 @@ which is the secure, production version of Mandrill's 1.0 API.
9797
esp_extra support
9898
-----------------
9999

100-
Anymail's Mandrill backend does not yet implement the
101-
:attr:`~anymail.message.AnymailMessage.esp_extra` feature.
100+
To use Mandrill features not directly supported by Anymail, you can
101+
set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to
102+
a `dict` of parameters to merge into Mandrill's `messages/send API`_ call.
103+
Note that a few parameters go at the top level, but Mandrill expects
104+
most options within a `'message'` sub-dict---be sure to check their
105+
API docs:
102106

107+
.. code-block:: python
108+
109+
message.esp_extra = {
110+
# Mandrill expects 'ip_pool' at top level...
111+
'ip_pool': 'Bulk Pool',
112+
# ... but 'subaccount' must be within a 'message' dict:
113+
'message': {
114+
'subaccount': 'Marketing Dept.'
115+
}
116+
}
117+
118+
Anymail has special handling that lets you specify Mandrill's
119+
`'recipient_metadata'` as a simple, pythonic `dict` (similar in form
120+
to Anymail's :attr:`~anymail.message.AnymailMessage.merge_data`),
121+
rather than Mandrill's more complex list of rcpt/values dicts.
122+
You can use whichever style you prefer (but either way,
123+
recipient_metadata must be in `esp_extra['message']`).
124+
125+
Similary, Anymail allows Mandrill's `'template_content'` in esp_extra
126+
(top level) either as a pythonic `dict` (similar to Anymail's
127+
:attr:`~anymail.message.AnymailMessage.merge_global_data`) or
128+
as Mandrill's more complex list of name/content dicts.
129+
130+
.. _messages/send API:
131+
https://mandrillapp.com/api/docs/messages.JSON.html#method=send
103132

104133
.. _mandrill-templates:
105134

@@ -222,14 +251,19 @@ Changes to settings
222251
the values from :setting:`ANYMAIL_SEND_DEFAULTS`.
223252

224253
``MANDRILL_SUBACCOUNT``
225-
Use :setting:`ANYMAIL_MANDRILL_SEND_DEFAULTS`:
254+
Set :ref:`esp_extra <mandrill-esp-extra>`
255+
globally in :setting:`ANYMAIL_SEND_DEFAULTS`:
226256

227257
.. code-block:: python
228258
229259
ANYMAIL = {
230260
...
231261
"MANDRILL_SEND_DEFAULTS": {
232-
"subaccount": "<your subaccount>"
262+
"esp_extra": {
263+
"message": {
264+
"subaccount": "<your subaccount>"
265+
}
266+
}
233267
}
234268
}
235269
@@ -290,13 +324,30 @@ Changes to EmailMessage attributes
290324
to use the values from the stored template.
291325

292326
**Other Mandrill-specific attributes**
293-
Are currently still supported by Anymail's Mandrill backend,
294-
but will be ignored by other Anymail backends.
327+
Djrill allowed nearly all Mandrill API parameters to be set
328+
as attributes directly on an EmailMessage. With Anymail, you
329+
should instead set these in the message's
330+
:ref:`esp_extra <mandrill-esp-extra>` dict as described above.
331+
332+
Although the Djrill style attributes are still supported (for now),
333+
Anymail will issue a :exc:`DeprecationWarning` if you try to use them.
334+
These warnings are visible during tests (with Django's default test
335+
runner), and will explain how to update your code.
336+
337+
You can also use the following git grep expression to find potential
338+
problems:
339+
340+
.. code-block:: console
341+
342+
git grep -w \
343+
-e 'async' -e 'auto_html' -e 'auto_text' -e 'from_name' -e 'global_merge_vars' \
344+
-e 'google_analytics_campaign' -e 'google_analytics_domains' -e 'important' \
345+
-e 'inline_css' -e 'ip_pool' -e 'merge_language' -e 'merge_vars' \
346+
-e 'preserve_recipients' -e 'recipient_metadata' -e 'return_path_domain' \
347+
-e 'signing_domain' -e 'subaccount' -e 'template_content' -e 'template_name' \
348+
-e 'tracking_domain' -e 'url_strip_qs' -e 'use_template_from' -e 'use_template_subject' \
349+
-e 'view_content_link'
295350
296-
It's best to eliminate them if they're not essential
297-
to your code. In the future, the Mandrill-only attributes
298-
will be moved into the
299-
:attr:`~anymail.message.AnymailMessage.esp_extra` dict.
300351
301352
**Inline images**
302353
Djrill (incorrectly) used the presence of a :mailheader:`Content-ID`

tests/test_mandrill_backend.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,69 @@ def test_missing_subject(self):
401401
data = self.get_api_call_json()
402402
self.assertNotIn('subject', data['message'])
403403

404+
def test_esp_extra(self):
405+
self.message.esp_extra = {
406+
'ip_pool': 'Bulk Pool', # Mandrill send param that goes at top level of API payload
407+
'message': {
408+
'subaccount': 'Marketing Dept.' # param that goes within message dict
409+
}
410+
}
411+
self.message.tags = ['test-tag'] # make sure non-esp_extra params are merged
412+
self.message.send()
413+
data = self.get_api_call_json()
414+
self.assertEqual(data['ip_pool'], 'Bulk Pool')
415+
self.assertEqual(data['message']['subaccount'], 'Marketing Dept.')
416+
self.assertEqual(data['message']['tags'], ['test-tag'])
417+
418+
def test_esp_extra_recipient_metadata(self):
419+
"""Anymail allows pythonic recipient_metadata dict"""
420+
self.message.esp_extra = {'message': {'recipient_metadata': {
421+
# Anymail expands simple python dicts into the more-verbose
422+
# rcpt/values lists the Mandrill API uses
423+
"[email protected]": {'cust_id': "67890", 'order_id': "54321"},
424+
"[email protected]": {'cust_id': "94107", 'order_id': "43215"} ,
425+
}}}
426+
self.message.send()
427+
data = self.get_api_call_json()
428+
self.assertCountEqual(data['message']['recipient_metadata'], [
429+
{'rcpt': "[email protected]", 'values': {'cust_id': "67890", 'order_id': "54321"}},
430+
{'rcpt': "[email protected]", 'values': {'cust_id': "94107", 'order_id': "43215"}}])
431+
432+
# You can also just supply it in Mandrill's native form
433+
self.message.esp_extra = {'message': {'recipient_metadata': [
434+
{'rcpt': "[email protected]", 'values': {'cust_id': "80806", 'order_id': "70701"}},
435+
{'rcpt': "[email protected]", 'values': {'cust_id': "21212", 'order_id': "10305"}}]}}
436+
self.message.send()
437+
data = self.get_api_call_json()
438+
self.assertCountEqual(data['message']['recipient_metadata'], [
439+
{'rcpt': "[email protected]", 'values': {'cust_id': "80806", 'order_id': "70701"}},
440+
{'rcpt': "[email protected]", 'values': {'cust_id': "21212", 'order_id': "10305"}}])
441+
442+
def test_esp_extra_template_content(self):
443+
"""Anymail allows pythonic template_content dict"""
444+
self.message.template_id = "welcome_template" # forces send-template API and default template_content
445+
self.message.esp_extra = {'template_content': {
446+
# Anymail expands simple python dicts into the more-verbose name/content
447+
# structures the Mandrill API uses
448+
'HEADLINE': "<h1>Specials Just For *|FNAME|*</h1>",
449+
'OFFER_BLOCK': "<p><em>Half off</em> all fruit</p>",
450+
}}
451+
self.message.send()
452+
data = self.get_api_call_json()
453+
self.assertCountEqual(data['template_content'], [
454+
{'name': "HEADLINE", 'content': "<h1>Specials Just For *|FNAME|*</h1>"},
455+
{'name': "OFFER_BLOCK", 'content': "<p><em>Half off</em> all fruit</p>"}])
456+
457+
# You can also just supply it in Mandrill's native form
458+
self.message.esp_extra = {'template_content': [
459+
{'name': "HEADLINE", 'content': "<h1>Exciting offers for *|FNAME|*</h1>"},
460+
{'name': "OFFER_BLOCK", 'content': "<p><em>25% off</em> all fruit</p>"}]}
461+
self.message.send()
462+
data = self.get_api_call_json()
463+
self.assertCountEqual(data['template_content'], [
464+
{'name': "HEADLINE", 'content': "<h1>Exciting offers for *|FNAME|*</h1>"},
465+
{'name': "OFFER_BLOCK", 'content': "<p><em>25% off</em> all fruit</p>"}])
466+
404467
def test_default_omits_options(self):
405468
"""Make sure by default we don't send any ESP-specific options.
406469
@@ -411,11 +474,15 @@ def test_default_omits_options(self):
411474
self.message.send()
412475
self.assert_esp_called("/messages/send.json")
413476
data = self.get_api_call_json()
477+
self.assertNotIn('global_merge_vars', data['message'])
478+
self.assertNotIn('merge_vars', data['message'])
414479
self.assertNotIn('metadata', data['message'])
415480
self.assertNotIn('send_at', data)
416481
self.assertNotIn('tags', data['message'])
417-
self.assertNotIn('track_opens', data['message'])
482+
self.assertNotIn('template_content', data['message'])
483+
self.assertNotIn('template_name', data['message'])
418484
self.assertNotIn('track_clicks', data['message'])
485+
self.assertNotIn('track_opens', data['message'])
419486

420487
# noinspection PyUnresolvedReferences
421488
def test_send_attaches_anymail_status(self):

0 commit comments

Comments
 (0)