Skip to content

Commit 75730e8

Browse files
committed
Add ESP templates, batch send and merge
* message.template_id to use ESP stored templates * message.merge_data and merge_global_data to supply per-recipient/global merge variables (with or without an ESP stored template) * When using per-recipient merge_data, tell ESP to use batch send: individual message per "to" address. (Mailgun does this automatically; SendGrid requires using a different "to" field; Mandrill requires `preserve_recipients=False`; Postmark doesn't support *this type* of batch sending with merge data.) * Allow message.from_email=None (must be set after init) and message.subject=None to suppress those fields in API calls (for ESPs that allow "From" and "Subject" in their template definitions). Mailgun: * Emulate merge_global_data by copying to recipient-variables for each recipient. SendGrid: * Add delimiters to merge field names via esp_extra['merge_field_format'] or ANYMAIL_SENDGRID_MERGE_FIELD_FORMAT setting. Mandrill: * Remove Djrill versions of these features; update migration notes. Closes #5.
1 parent 271eb5c commit 75730e8

20 files changed

+884
-247
lines changed

README.rst

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,10 @@ built-in `django.core.mail` package. It includes:
4343
* Simplified inline images for HTML email
4444
* Normalized sent-message status and tracking notification, by connecting
4545
your ESP's webhooks to Django signals
46+
* "Batch transactional" sends using your ESP's merge and template features
4647

47-
Support is planned for:
48+
Support is also planned for:
4849

49-
* "Bulk-transactional" sends using your ESP's template facility,
50-
with portable declaration of substitution/merge data
5150
* Normalized inbound email processing through your ESP
5251

5352
Anymail is released under the BSD license. It is extensively tested against Django 1.8--1.9

anymail/backends/base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,9 @@ class BasePayload(object):
197197
('tags', combine, None),
198198
('track_clicks', last, None),
199199
('track_opens', last, None),
200+
('template_id', last, None),
201+
('merge_data', combine, None),
202+
('merge_global_data', combine, None),
200203
('esp_extra', combine, None),
201204
)
202205
esp_message_attrs = () # subclasses can override
@@ -356,6 +359,15 @@ def set_track_clicks(self, track_clicks):
356359
def set_track_opens(self, track_opens):
357360
self.unsupported_feature("track_opens")
358361

362+
def set_template_id(self, template_id):
363+
self.unsupported_feature("template_id")
364+
365+
def set_merge_data(self, merge_data):
366+
self.unsupported_feature("merge_data")
367+
368+
def set_merge_global_data(self, merge_global_data):
369+
self.unsupported_feature("merge_global_data")
370+
359371
# ESP-specific payload construction
360372
def set_esp_extra(self, extra):
361373
self.unsupported_feature("esp_extra")

anymail/backends/mailgun.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
5656
auth = ("api", backend.api_key)
5757
self.sender_domain = None
5858
self.all_recipients = [] # used for backend.parse_recipient_status
59+
60+
# late-binding of recipient-variables:
61+
self.merge_data = None
62+
self.merge_global_data = None
63+
self.to_emails = []
64+
5965
super(MailgunPayload, self).__init__(message, defaults, backend, auth=auth, *args, **kwargs)
6066

6167
def get_api_endpoint(self):
@@ -66,6 +72,34 @@ def get_api_endpoint(self):
6672
backend=self.backend, email_message=self.message, payload=self)
6773
return "%s/messages" % self.sender_domain
6874

75+
def serialize_data(self):
76+
self.populate_recipient_variables()
77+
return self.data
78+
79+
def populate_recipient_variables(self):
80+
"""Populate Mailgun recipient-variables header from merge data"""
81+
merge_data = self.merge_data
82+
83+
if self.merge_global_data is not None:
84+
# Mailgun doesn't support global variables.
85+
# We emulate them by populating recipient-variables for all recipients.
86+
if merge_data is not None:
87+
merge_data = merge_data.copy() # don't modify the original, which doesn't belong to us
88+
else:
89+
merge_data = {}
90+
for email in self.to_emails:
91+
try:
92+
recipient_data = merge_data[email]
93+
except KeyError:
94+
merge_data[email] = self.merge_global_data
95+
else:
96+
# Merge globals (recipient_data wins in conflict)
97+
merge_data[email] = self.merge_global_data.copy()
98+
merge_data[email].update(recipient_data)
99+
100+
if merge_data is not None:
101+
self.data['recipient-variables'] = self.serialize_json(merge_data)
102+
69103
#
70104
# Payload construction
71105
#
@@ -87,8 +121,10 @@ def set_from_email(self, email):
87121
def set_recipients(self, recipient_type, emails):
88122
assert recipient_type in ["to", "cc", "bcc"]
89123
if emails:
90-
self.data[recipient_type] = [str(email) for email in emails]
124+
self.data[recipient_type] = [email.address for email in emails]
91125
self.all_recipients += emails # used for backend.parse_recipient_status
126+
if recipient_type == 'to':
127+
self.to_emails = [email.email for email in emails] # used for populate_recipient_variables
92128

93129
def set_subject(self, subject):
94130
self.data["subject"] = subject
@@ -145,6 +181,17 @@ def set_track_clicks(self, track_clicks):
145181
def set_track_opens(self, track_opens):
146182
self.data["o:tracking-opens"] = "yes" if track_opens else "no"
147183

184+
# template_id: Mailgun doesn't offer stored templates.
185+
# (The message body and other fields *are* the template content.)
186+
187+
def set_merge_data(self, merge_data):
188+
# Processed at serialization time (to allow merging global data)
189+
self.merge_data = merge_data
190+
191+
def set_merge_global_data(self, merge_global_data):
192+
# Processed at serialization time (to allow merging global data)
193+
self.merge_global_data = merge_global_data
194+
148195
def set_esp_extra(self, extra):
149196
self.data.update(extra)
150197
# Allow override of sender_domain via esp_extra

anymail/backends/mandrill.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,24 @@ def set_track_clicks(self, track_clicks):
147147
def set_track_opens(self, track_opens):
148148
self.data["message"]["track_opens"] = track_opens
149149

150+
def set_template_id(self, template_id):
151+
self.data["template_name"] = template_id
152+
self.data.setdefault("template_content", []) # Mandrill requires something here
153+
154+
def set_merge_data(self, merge_data):
155+
self.data['message']['preserve_recipients'] = False # if merge, hide recipients from each other
156+
self.data['message']['merge_vars'] = [
157+
{'rcpt': rcpt, 'vars': [{'name': key, 'content': rcpt_data[key]}
158+
for key in sorted(rcpt_data.keys())]} # sort for testing reproducibility
159+
for rcpt, rcpt_data in merge_data.items()
160+
]
161+
162+
def set_merge_global_data(self, merge_global_data):
163+
self.data['message']['global_merge_vars'] = [
164+
{'name': var, 'content': value}
165+
for var, value in merge_global_data.items()
166+
]
167+
150168
def set_esp_extra(self, extra):
151169
pass
152170

@@ -170,10 +188,7 @@ def set_esp_extra(self, extra):
170188
('subaccount', last, None),
171189
('google_analytics_domains', last, None),
172190
('google_analytics_campaign', last, None),
173-
('global_merge_vars', combine, _expand_merge_vars),
174-
('merge_vars', combine, None),
175191
('recipient_metadata', combine, None),
176-
('template_name', last, None),
177192
('template_content', combine, _expand_merge_vars),
178193
)
179194

@@ -183,20 +198,9 @@ def set_async(self, async):
183198
def set_ip_pool(self, ip_pool):
184199
self.data["ip_pool"] = ip_pool
185200

186-
def set_template_name(self, template_name):
187-
self.data["template_name"] = template_name
188-
self.data.setdefault("template_content", []) # Mandrill requires something here
189-
190201
def set_template_content(self, template_content):
191202
self.data["template_content"] = template_content
192203

193-
def set_merge_vars(self, merge_vars):
194-
# For testing reproducibility, we sort the recipients
195-
self.data['message']['merge_vars'] = [
196-
{'rcpt': rcpt, 'vars': _expand_merge_vars(merge_vars[rcpt])}
197-
for rcpt in sorted(merge_vars.keys())
198-
]
199-
200204
def set_recipient_metadata(self, recipient_metadata):
201205
# For testing reproducibility, we sort the recipients
202206
self.data['message']['recipient_metadata'] = [

anymail/backends/postmark.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
100100
super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs)
101101

102102
def get_api_endpoint(self):
103-
return "email"
103+
if 'TemplateId' in self.data or 'TemplateModel' in self.data:
104+
# This is the one Postmark API documented to have a trailing slash. (Typo?)
105+
return "email/withTemplate/"
106+
else:
107+
return "email"
104108

105109
def get_request_params(self, api_url):
106110
params = super(PostmarkPayload, self).get_request_params(api_url)
@@ -185,6 +189,14 @@ def set_tags(self, tags):
185189
def set_track_opens(self, track_opens):
186190
self.data["TrackOpens"] = track_opens
187191

192+
def set_template_id(self, template_id):
193+
self.data["TemplateId"] = template_id
194+
195+
# merge_data: Postmark doesn't support per-recipient substitutions
196+
197+
def set_merge_global_data(self, merge_global_data):
198+
self.data["TemplateModel"] = merge_global_data
199+
188200
def set_esp_extra(self, extra):
189201
self.data.update(extra)
190202
# Special handling for 'server_token':

anymail/backends/sendgrid.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import warnings
2+
13
from django.core.mail import make_msgid
24
from requests.structures import CaseInsensitiveDict
35

4-
from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError
6+
from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning
57
from ..message import AnymailRecipientStatus
68
from ..utils import get_anymail_setting, timestamp
79

@@ -31,6 +33,8 @@ def __init__(self, **kwargs):
3133

3234
self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name,
3335
kwargs=kwargs, default=True)
36+
self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name,
37+
kwargs=kwargs, default=None)
3438

3539
# This is SendGrid's Web API v2 (because the Web API v3 doesn't support sending)
3640
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
@@ -65,6 +69,10 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
6569
self.generate_message_id = backend.generate_message_id
6670
self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers
6771
self.smtpapi = {} # SendGrid x-smtpapi field
72+
self.to_list = [] # late-bound 'to' field
73+
self.merge_field_format = backend.merge_field_format
74+
self.merge_data = None # late-bound per-recipient data
75+
self.merge_global_data = None
6876

6977
http_headers = kwargs.pop('headers', {})
7078
query_params = kwargs.pop('params', {})
@@ -86,6 +94,15 @@ def serialize_data(self):
8694
if self.generate_message_id:
8795
self.ensure_message_id()
8896

97+
self.build_merge_data()
98+
if self.merge_data is None:
99+
# Standard 'to' and 'toname' headers
100+
self.set_recipients('to', self.to_list)
101+
else:
102+
# Merge-friendly smtpapi 'to' field
103+
self.smtpapi['to'] = [email.address for email in self.to_list]
104+
self.all_recipients += self.to_list
105+
89106
# Serialize x-smtpapi to json:
90107
if len(self.smtpapi) > 0:
91108
# If esp_extra was also used to set x-smtpapi, need to merge it
@@ -132,6 +149,41 @@ def make_message_id(self):
132149
domain = None
133150
return make_msgid(domain=domain)
134151

152+
def build_merge_data(self):
153+
"""Set smtpapi['sub'] and ['section']"""
154+
if self.merge_data is not None:
155+
# Convert from {to1: {a: A1, b: B1}, to2: {a: A2}} (merge_data format)
156+
# to {a: [A1, A2], b: [B1, ""]} ({field: [data in to-list order], ...})
157+
all_fields = set()
158+
for recipient_data in self.merge_data.values():
159+
all_fields = all_fields.union(recipient_data.keys())
160+
recipients = [email.email for email in self.to_list]
161+
162+
if self.merge_field_format is None and all(field.isalnum() for field in all_fields):
163+
warnings.warn(
164+
"Your SendGrid merge fields don't seem to have delimiters, "
165+
"which can cause unexpected results with Anymail's merge_data. "
166+
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
167+
AnymailWarning)
168+
169+
sub_field_fmt = self.merge_field_format or '{}'
170+
sub_fields = {field: sub_field_fmt.format(field) for field in all_fields}
171+
172+
self.smtpapi['sub'] = {
173+
# If field data is missing for recipient, use (formatted) field as the substitution.
174+
# (This allows default to resolve from global "section" substitutions.)
175+
sub_fields[field]: [self.merge_data.get(recipient, {}).get(field, sub_fields[field])
176+
for recipient in recipients]
177+
for field in all_fields
178+
}
179+
180+
if self.merge_global_data is not None:
181+
section_field_fmt = self.merge_field_format or '{}'
182+
self.smtpapi['section'] = {
183+
section_field_fmt.format(field): data
184+
for field, data in self.merge_global_data.items()
185+
}
186+
135187
#
136188
# Payload construction
137189
#
@@ -146,6 +198,11 @@ def set_from_email(self, email):
146198
if email.name:
147199
self.data["fromname"] = email.name
148200

201+
def set_to(self, emails):
202+
# late-bind in self.serialize_data, because whether it goes in smtpapi
203+
# depends on whether there is merge_data
204+
self.to_list = emails
205+
149206
def set_recipients(self, recipient_type, emails):
150207
assert recipient_type in ["to", "cc", "bcc"]
151208
if emails:
@@ -229,5 +286,18 @@ def set_track_opens(self, track_opens):
229286
# (You could add it through esp_extra.)
230287
self.add_filter('opentrack', 'enable', int(track_opens))
231288

289+
def set_template_id(self, template_id):
290+
self.add_filter('templates', 'enable', 1)
291+
self.add_filter('templates', 'template_id', template_id)
292+
293+
def set_merge_data(self, merge_data):
294+
# Becomes smtpapi['sub'] in build_merge_data, after we know recipients and merge_field_format.
295+
self.merge_data = merge_data
296+
297+
def set_merge_global_data(self, merge_global_data):
298+
# Becomes smtpapi['section'] in build_merge_data, after we know merge_field_format.
299+
self.merge_global_data = merge_global_data
300+
232301
def set_esp_extra(self, extra):
302+
self.merge_field_format = extra.pop('merge_field_format', self.merge_field_format)
233303
self.data.update(extra)

anymail/message.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ def __init__(self, *args, **kwargs):
2525
self.tags = kwargs.pop('tags', UNSET)
2626
self.track_clicks = kwargs.pop('track_clicks', UNSET)
2727
self.track_opens = kwargs.pop('track_opens', UNSET)
28+
self.template_id = kwargs.pop('template_id', UNSET)
29+
self.merge_data = kwargs.pop('merge_data', UNSET)
30+
self.merge_global_data = kwargs.pop('merge_global_data', UNSET)
2831
self.anymail_status = None
2932

3033
# noinspection PyArgumentList

docs/esps/index.rst

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,28 @@ The table below summarizes the Anymail features supported for each ESP.
2525

2626
.. currentmodule:: anymail.message
2727

28-
=========================================== ========= ========== ========== ==========
29-
Email Service Provider |Mailgun| |Mandrill| |Postmark| |SendGrid|
30-
=========================================== ========= ========== ========== ==========
28+
============================================ ========== ========== ========== ==========
29+
Email Service Provider |Mailgun| |Mandrill| |Postmark| |SendGrid|
30+
============================================ ========== ========== ========== ==========
3131
.. rubric:: :ref:`Anymail send options <anymail-send-options>`
32-
-------------------------------------------------------------------------------------------
33-
:attr:`~AnymailMessage.metadata` Yes Yes No Yes
34-
:attr:`~AnymailMessage.send_at` Yes Yes No Yes
35-
:attr:`~AnymailMessage.tags` Yes Yes Max 1 tag Yes
36-
:attr:`~AnymailMessage.track_clicks` Yes Yes No Yes
37-
:attr:`~AnymailMessage.track_opens` Yes Yes Yes Yes
32+
--------------------------------------------------------------------------------------------
33+
:attr:`~AnymailMessage.metadata` Yes Yes No Yes
34+
:attr:`~AnymailMessage.send_at` Yes Yes No Yes
35+
:attr:`~AnymailMessage.tags` Yes Yes Max 1 tag Yes
36+
:attr:`~AnymailMessage.track_clicks` Yes Yes No Yes
37+
:attr:`~AnymailMessage.track_opens` Yes Yes Yes Yes
38+
39+
.. rubric:: :ref:`templates-and-merge`
40+
--------------------------------------------------------------------------------------------
41+
:attr:`~AnymailMessage.template_id` No Yes Yes Yes
42+
:attr:`~AnymailMessage.merge_data` Yes Yes No Yes
43+
:attr:`~AnymailMessage.merge_global_data` (emulated) Yes Yes Yes
3844

3945
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`
40-
-------------------------------------------------------------------------------------------
41-
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes
42-
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes
43-
=========================================== ========= ========== ========== ==========
46+
--------------------------------------------------------------------------------------------
47+
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes
48+
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes
49+
============================================ ========== ========== ========== ==========
4450

4551

4652
.. .. rubric:: :ref:`inbound`

0 commit comments

Comments
 (0)