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.
This commit is contained in:
medmunds
2016-05-03 18:25:37 -07:00
parent 271eb5c926
commit 75730e8219
20 changed files with 882 additions and 245 deletions

View File

@@ -43,11 +43,10 @@ built-in `django.core.mail` package. It includes:
* Simplified inline images for HTML email
* Normalized sent-message status and tracking notification, by connecting
your ESP's webhooks to Django signals
* "Batch transactional" sends using your ESP's merge and template features
Support is planned for:
Support is also planned for:
* "Bulk-transactional" sends using your ESP's template facility,
with portable declaration of substitution/merge data
* Normalized inbound email processing through your ESP
Anymail is released under the BSD license. It is extensively tested against Django 1.8--1.9

View File

@@ -197,6 +197,9 @@ class BasePayload(object):
('tags', combine, None),
('track_clicks', last, None),
('track_opens', last, None),
('template_id', last, None),
('merge_data', combine, None),
('merge_global_data', combine, None),
('esp_extra', combine, None),
)
esp_message_attrs = () # subclasses can override
@@ -356,6 +359,15 @@ class BasePayload(object):
def set_track_opens(self, track_opens):
self.unsupported_feature("track_opens")
def set_template_id(self, template_id):
self.unsupported_feature("template_id")
def set_merge_data(self, merge_data):
self.unsupported_feature("merge_data")
def set_merge_global_data(self, merge_global_data):
self.unsupported_feature("merge_global_data")
# ESP-specific payload construction
def set_esp_extra(self, extra):
self.unsupported_feature("esp_extra")

View File

@@ -56,6 +56,12 @@ class MailgunPayload(RequestsPayload):
auth = ("api", backend.api_key)
self.sender_domain = None
self.all_recipients = [] # used for backend.parse_recipient_status
# late-binding of recipient-variables:
self.merge_data = None
self.merge_global_data = None
self.to_emails = []
super(MailgunPayload, self).__init__(message, defaults, backend, auth=auth, *args, **kwargs)
def get_api_endpoint(self):
@@ -66,6 +72,34 @@ class MailgunPayload(RequestsPayload):
backend=self.backend, email_message=self.message, payload=self)
return "%s/messages" % self.sender_domain
def serialize_data(self):
self.populate_recipient_variables()
return self.data
def populate_recipient_variables(self):
"""Populate Mailgun recipient-variables header from merge data"""
merge_data = self.merge_data
if self.merge_global_data is not None:
# Mailgun doesn't support global variables.
# We emulate them by populating recipient-variables for all recipients.
if merge_data is not None:
merge_data = merge_data.copy() # don't modify the original, which doesn't belong to us
else:
merge_data = {}
for email in self.to_emails:
try:
recipient_data = merge_data[email]
except KeyError:
merge_data[email] = self.merge_global_data
else:
# Merge globals (recipient_data wins in conflict)
merge_data[email] = self.merge_global_data.copy()
merge_data[email].update(recipient_data)
if merge_data is not None:
self.data['recipient-variables'] = self.serialize_json(merge_data)
#
# Payload construction
#
@@ -87,8 +121,10 @@ class MailgunPayload(RequestsPayload):
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]
if emails:
self.data[recipient_type] = [str(email) for email in emails]
self.data[recipient_type] = [email.address for email in emails]
self.all_recipients += emails # used for backend.parse_recipient_status
if recipient_type == 'to':
self.to_emails = [email.email for email in emails] # used for populate_recipient_variables
def set_subject(self, subject):
self.data["subject"] = subject
@@ -145,6 +181,17 @@ class MailgunPayload(RequestsPayload):
def set_track_opens(self, track_opens):
self.data["o:tracking-opens"] = "yes" if track_opens else "no"
# template_id: Mailgun doesn't offer stored templates.
# (The message body and other fields *are* the template content.)
def set_merge_data(self, merge_data):
# Processed at serialization time (to allow merging global data)
self.merge_data = merge_data
def set_merge_global_data(self, merge_global_data):
# Processed at serialization time (to allow merging global data)
self.merge_global_data = merge_global_data
def set_esp_extra(self, extra):
self.data.update(extra)
# Allow override of sender_domain via esp_extra

View File

@@ -147,6 +147,24 @@ class MandrillPayload(RequestsPayload):
def set_track_opens(self, track_opens):
self.data["message"]["track_opens"] = track_opens
def set_template_id(self, template_id):
self.data["template_name"] = template_id
self.data.setdefault("template_content", []) # Mandrill requires something here
def set_merge_data(self, merge_data):
self.data['message']['preserve_recipients'] = False # if merge, hide recipients from each other
self.data['message']['merge_vars'] = [
{'rcpt': rcpt, 'vars': [{'name': key, 'content': rcpt_data[key]}
for key in sorted(rcpt_data.keys())]} # sort for testing reproducibility
for rcpt, rcpt_data in merge_data.items()
]
def set_merge_global_data(self, merge_global_data):
self.data['message']['global_merge_vars'] = [
{'name': var, 'content': value}
for var, value in merge_global_data.items()
]
def set_esp_extra(self, extra):
pass
@@ -170,10 +188,7 @@ class MandrillPayload(RequestsPayload):
('subaccount', last, None),
('google_analytics_domains', last, None),
('google_analytics_campaign', last, None),
('global_merge_vars', combine, _expand_merge_vars),
('merge_vars', combine, None),
('recipient_metadata', combine, None),
('template_name', last, None),
('template_content', combine, _expand_merge_vars),
)
@@ -183,20 +198,9 @@ class MandrillPayload(RequestsPayload):
def set_ip_pool(self, ip_pool):
self.data["ip_pool"] = ip_pool
def set_template_name(self, template_name):
self.data["template_name"] = template_name
self.data.setdefault("template_content", []) # Mandrill requires something here
def set_template_content(self, template_content):
self.data["template_content"] = template_content
def set_merge_vars(self, merge_vars):
# For testing reproducibility, we sort the recipients
self.data['message']['merge_vars'] = [
{'rcpt': rcpt, 'vars': _expand_merge_vars(merge_vars[rcpt])}
for rcpt in sorted(merge_vars.keys())
]
def set_recipient_metadata(self, recipient_metadata):
# For testing reproducibility, we sort the recipients
self.data['message']['recipient_metadata'] = [

View File

@@ -100,7 +100,11 @@ class PostmarkPayload(RequestsPayload):
super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs)
def get_api_endpoint(self):
return "email"
if 'TemplateId' in self.data or 'TemplateModel' in self.data:
# This is the one Postmark API documented to have a trailing slash. (Typo?)
return "email/withTemplate/"
else:
return "email"
def get_request_params(self, api_url):
params = super(PostmarkPayload, self).get_request_params(api_url)
@@ -185,6 +189,14 @@ class PostmarkPayload(RequestsPayload):
def set_track_opens(self, track_opens):
self.data["TrackOpens"] = track_opens
def set_template_id(self, template_id):
self.data["TemplateId"] = template_id
# merge_data: Postmark doesn't support per-recipient substitutions
def set_merge_global_data(self, merge_global_data):
self.data["TemplateModel"] = merge_global_data
def set_esp_extra(self, extra):
self.data.update(extra)
# Special handling for 'server_token':

View File

@@ -1,7 +1,9 @@
import warnings
from django.core.mail import make_msgid
from requests.structures import CaseInsensitiveDict
from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError
from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, timestamp
@@ -31,6 +33,8 @@ class SendGridBackend(AnymailRequestsBackend):
self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name,
kwargs=kwargs, default=True)
self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name,
kwargs=kwargs, default=None)
# This is SendGrid's Web API v2 (because the Web API v3 doesn't support sending)
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
@@ -65,6 +69,10 @@ class SendGridPayload(RequestsPayload):
self.generate_message_id = backend.generate_message_id
self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers
self.smtpapi = {} # SendGrid x-smtpapi field
self.to_list = [] # late-bound 'to' field
self.merge_field_format = backend.merge_field_format
self.merge_data = None # late-bound per-recipient data
self.merge_global_data = None
http_headers = kwargs.pop('headers', {})
query_params = kwargs.pop('params', {})
@@ -86,6 +94,15 @@ class SendGridPayload(RequestsPayload):
if self.generate_message_id:
self.ensure_message_id()
self.build_merge_data()
if self.merge_data is None:
# Standard 'to' and 'toname' headers
self.set_recipients('to', self.to_list)
else:
# Merge-friendly smtpapi 'to' field
self.smtpapi['to'] = [email.address for email in self.to_list]
self.all_recipients += self.to_list
# Serialize x-smtpapi to json:
if len(self.smtpapi) > 0:
# If esp_extra was also used to set x-smtpapi, need to merge it
@@ -132,6 +149,41 @@ class SendGridPayload(RequestsPayload):
domain = None
return make_msgid(domain=domain)
def build_merge_data(self):
"""Set smtpapi['sub'] and ['section']"""
if self.merge_data is not None:
# Convert from {to1: {a: A1, b: B1}, to2: {a: A2}} (merge_data format)
# to {a: [A1, A2], b: [B1, ""]} ({field: [data in to-list order], ...})
all_fields = set()
for recipient_data in self.merge_data.values():
all_fields = all_fields.union(recipient_data.keys())
recipients = [email.email for email in self.to_list]
if self.merge_field_format is None and all(field.isalnum() for field in all_fields):
warnings.warn(
"Your SendGrid merge fields don't seem to have delimiters, "
"which can cause unexpected results with Anymail's merge_data. "
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
AnymailWarning)
sub_field_fmt = self.merge_field_format or '{}'
sub_fields = {field: sub_field_fmt.format(field) for field in all_fields}
self.smtpapi['sub'] = {
# If field data is missing for recipient, use (formatted) field as the substitution.
# (This allows default to resolve from global "section" substitutions.)
sub_fields[field]: [self.merge_data.get(recipient, {}).get(field, sub_fields[field])
for recipient in recipients]
for field in all_fields
}
if self.merge_global_data is not None:
section_field_fmt = self.merge_field_format or '{}'
self.smtpapi['section'] = {
section_field_fmt.format(field): data
for field, data in self.merge_global_data.items()
}
#
# Payload construction
#
@@ -146,6 +198,11 @@ class SendGridPayload(RequestsPayload):
if email.name:
self.data["fromname"] = email.name
def set_to(self, emails):
# late-bind in self.serialize_data, because whether it goes in smtpapi
# depends on whether there is merge_data
self.to_list = emails
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]
if emails:
@@ -229,5 +286,18 @@ class SendGridPayload(RequestsPayload):
# (You could add it through esp_extra.)
self.add_filter('opentrack', 'enable', int(track_opens))
def set_template_id(self, template_id):
self.add_filter('templates', 'enable', 1)
self.add_filter('templates', 'template_id', template_id)
def set_merge_data(self, merge_data):
# Becomes smtpapi['sub'] in build_merge_data, after we know recipients and merge_field_format.
self.merge_data = merge_data
def set_merge_global_data(self, merge_global_data):
# Becomes smtpapi['section'] in build_merge_data, after we know merge_field_format.
self.merge_global_data = merge_global_data
def set_esp_extra(self, extra):
self.merge_field_format = extra.pop('merge_field_format', self.merge_field_format)
self.data.update(extra)

View File

@@ -25,6 +25,9 @@ class AnymailMessageMixin(object):
self.tags = kwargs.pop('tags', UNSET)
self.track_clicks = kwargs.pop('track_clicks', UNSET)
self.track_opens = kwargs.pop('track_opens', UNSET)
self.template_id = kwargs.pop('template_id', UNSET)
self.merge_data = kwargs.pop('merge_data', UNSET)
self.merge_global_data = kwargs.pop('merge_global_data', UNSET)
self.anymail_status = None
# noinspection PyArgumentList

View File

@@ -25,22 +25,28 @@ The table below summarizes the Anymail features supported for each ESP.
.. currentmodule:: anymail.message
=========================================== ========= ========== ========== ==========
Email Service Provider |Mailgun| |Mandrill| |Postmark| |SendGrid|
=========================================== ========= ========== ========== ==========
============================================ ========== ========== ========== ==========
Email Service Provider |Mailgun| |Mandrill| |Postmark| |SendGrid|
============================================ ========== ========== ========== ==========
.. rubric:: :ref:`Anymail send options <anymail-send-options>`
-------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.metadata` Yes Yes No Yes
:attr:`~AnymailMessage.send_at` Yes Yes No Yes
:attr:`~AnymailMessage.tags` Yes Yes Max 1 tag Yes
:attr:`~AnymailMessage.track_clicks` Yes Yes No Yes
:attr:`~AnymailMessage.track_opens` Yes Yes Yes Yes
--------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.metadata` Yes Yes No Yes
:attr:`~AnymailMessage.send_at` Yes Yes No Yes
:attr:`~AnymailMessage.tags` Yes Yes Max 1 tag Yes
:attr:`~AnymailMessage.track_clicks` Yes Yes No Yes
:attr:`~AnymailMessage.track_opens` Yes Yes Yes Yes
.. rubric:: :ref:`templates-and-merge`
--------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.template_id` No Yes Yes Yes
:attr:`~AnymailMessage.merge_data` Yes Yes No Yes
:attr:`~AnymailMessage.merge_global_data` (emulated) Yes Yes Yes
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`
-------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes
=========================================== ========= ========== ========== ==========
--------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes
============================================ ========== ========== ========== ==========
.. .. rubric:: :ref:`inbound`

View File

@@ -105,6 +105,51 @@ values directly to Mailgun. You can use any of the (non-file) parameters listed
.. _Mailgun sending docs: https://documentation.mailgun.com/api-sending.html#sending
.. _mailgun-templates:
Batch sending/merge and ESP templates
-------------------------------------
Mailgun does not offer :ref:`ESP stored templates <esp-stored-templates>`,
so Anymail's :attr:`~anymail.message.AnymailMessage.template_id` message
attribute is not supported with the Mailgun backend.
Mailgun *does* support :ref:`batch sending <batch-send>` with per-recipient
merge data. You can refer to Mailgun "recipient variables" in your
message subject and body, and supply the values with Anymail's
normalized :attr:`~anymail.message.AnymailMessage.merge_data`
and :attr:`~anymail.message.AnymailMessage.merge_global_data`
message attributes:
.. code-block:: python
message = EmailMessage(
...
subject="Your order %recipient.order_no% has shipped",
body="""Hi %recipient.name%,
We shipped your order %recipient.order_no%
on %recipient.ship_date%.""",
to=["alice@example.com", "Bob <bob@example.com>"]
)
# (you'd probably also set a similar html body with %recipient.___% variables)
message.merge_data = {
'alice@example.com': {'name': "Alice", 'order_no': "12345"},
'bob@example.com': {'name': "Bob", 'order_no': "54321"},
}
message.merge_global_data = {
'ship_date': "May 15" # Anymail maps globals to all recipients
}
Mailgun does not natively support global merge data. Anymail emulates
the capability by copying any `merge_global_data` values to each
recipient's section in Mailgun's "recipient-variables" API parameter.
See the `Mailgun batch sending`_ docs for more information.
.. _Mailgun batch sending:
https://documentation.mailgun.com/user_manual.html#batch-sending
.. _mailgun-webhooks:
Status tracking webhooks

View File

@@ -3,7 +3,7 @@
Mandrill
========
Anymail integrates with the `Mandrill <http://mandrill.com/>`_
Anymail integrates with the `Mandrill <http://mandrill.com/>`__
transactional email service from MailChimp.
.. note:: **Limited Support for Mandrill**
@@ -101,6 +101,61 @@ Anymail's Mandrill backend does not yet implement the
:attr:`~anymail.message.AnymailMessage.esp_extra` feature.
.. _mandrill-templates:
Batch sending/merge and ESP templates
-------------------------------------
Mandrill offers both :ref:`ESP stored templates <esp-stored-templates>`
and :ref:`batch sending <batch-send>` with per-recipient merge data.
You can use a Mandrill stored template by setting a message's
:attr:`~anymail.message.AnymailMessage.template_id` to the
template's name. Alternatively, you can refer to merge fields
directly in an EmailMessage's subject and body---the message itself
is used as an on-the-fly template.
In either case, supply the merge data values with Anymail's
normalized :attr:`~anymail.message.AnymailMessage.merge_data`
and :attr:`~anymail.message.AnymailMessage.merge_global_data`
message attributes.
.. code-block:: python
# This example defines the template inline, using Mandrill's
# default MailChimp merge *|field|* syntax.
# You could use a stored template, instead, with:
# message.template_id = "template name"
message = EmailMessage(
...
subject="Your order *|order_no|* has shipped",
body="""Hi *|name|*,
We shipped your order *|order_no|*
on *|ship_date|*.""",
to=["alice@example.com", "Bob <bob@example.com>"]
)
# (you'd probably also set a similar html body with merge fields)
message.merge_data = {
'alice@example.com': {'name': "Alice", 'order_no': "12345"},
'bob@example.com': {'name': "Bob", 'order_no': "54321"},
}
message.merge_global_data = {
'ship_date': "May 15",
}
When you supply per-recipient :attr:`~anymail.message.AnymailMessage.merge_data`,
Anymail automatically forces Mandrill's `preserve_recipients` option to false,
so that each person in the message's "to" list sees only their own email address.
To use the subject or from address defined with a Mandrill template, set the message's
`subject` or `from_email` attribute to `None`.
See the `Mandrill's template docs`_ for more information.
.. _Mandrill's template docs:
https://mandrill.zendesk.com/hc/en-us/articles/205582507-Getting-Started-with-Templates
.. _mandrill-webhooks:
Status tracking webhooks
@@ -198,7 +253,8 @@ Changes to EmailMessage attributes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``message.send_at``
If you are using an aware datetime for :attr:`send_at`,
If you are using an aware datetime for
:attr:`~anymail.message.AnymailMessage.send_at`,
it will keep working unchanged with Anymail.
If you are using a date (without a time), or a naive datetime,
@@ -211,7 +267,8 @@ Changes to EmailMessage attributes
``message.mandrill_response``
Anymail normalizes ESP responses, so you don't have to be familiar
with the format of Mandrill's JSON. See :attr:`anymail_status`.
with the format of Mandrill's JSON.
See :attr:`~anymail.message.AnymailMessage.anymail_status`.
The *raw* ESP response is attached to a sent message as
``anymail_status.esp_response``, so the direct replacement
@@ -221,14 +278,16 @@ Changes to EmailMessage attributes
mandrill_response = message.anymail_status.esp_response.json()
**Templates and merge variables**
Coming to Anymail soon.
``message.template_name``
Anymail renames this to :attr:`~anymail.message.AnymailMessage.template_id`.
However, no other ESPs support MailChimp's templating language, so
you'll need to rewrite your templates as you switch ESPs.
``message.merge_vars`` and ``message.global_merge_vars``
Anymail renames these to :attr:`~anymail.message.AnymailMessage.merge_data`
and :attr:`~anymail.message.AnymailMessage.merge_global_data`, respectively.
Consider converting to :ref:`Django templates <django-templates>`
instead, as these can be used with any email backend.
``message.use_template_from`` and ``message.use_template_subject``
With Anymail, set ``message.from_email = None`` or ``message.subject = None``
to use the values from the stored template.
**Other Mandrill-specific attributes**
Are currently still supported by Anymail's Mandrill backend,

View File

@@ -115,6 +115,52 @@ see :ref:`unsupported-features`.
Postmark does not support :attr:`~anymail.message.AnymailMessage.send_at`.
.. _postmark-templates:
Batch sending/merge and ESP templates
-------------------------------------
Postmark supports :ref:`ESP stored templates <esp-stored-templates>`
populated with global merge data for all recipients, but does not
offer :ref:`batch sending <batch-send>` with per-recipient merge data.
Anymail's :attr:`~anymail.message.AnymailMessage.merge_data`
message attribute is not supported with the Postmark backend.
To use a Postmark template, set the message's
:attr:`~anymail.message.AnymailMessage.template_id` to the numeric
Postmark "TemplateID" and supply the "TemplateModel" using
the :attr:`~anymail.message.AnymailMessage.merge_global_data`
message attribute:
.. code-block:: python
message = EmailMessage(
...
subject=None, # use template subject
to=["alice@example.com"] # single recipient...
# ...multiple to emails would all get the same message
# (and would all see each other's emails in the "to" header)
)
message.template_id = 80801 # use this Postmark template
message.merge_global_data = {
'name': "Alice",
'order_no': "12345",
'ship_date': "May 15",
'items': [
{'product': "Widget", 'price': "9.99"},
{'product': "Gadget", 'price': "17.99"},
],
}
Set the EmailMessage's subject to `None` to use the subject from
your Postmark template, or supply a subject with the message to override
the template value.
See this `Postmark blog post on templates`_ for more information.
.. _Postmark blog post on templates:
https://postmarkapp.com/blog/special-delivery-postmark-templates
.. _postmark-webhooks:

View File

@@ -89,6 +89,34 @@ Default ``True``. You can set to ``False`` to disable this behavior.
See :ref:`Message-ID quirks <sendgrid-message-id>` below.
.. setting:: ANYMAIL_SENDGRID_MERGE_FIELD_FORMAT
.. rubric:: SENDGRID_MERGE_FIELD_FORMAT
If you use :ref:`merge data <merge-data>`, set this to a :meth:`str.format`
formatting string that indicates how merge fields are delimited
in your SendGrid templates.
For example, if your templates use the ``-field-`` hyphen delimiters
suggested in some SendGrid docs, you would set:
.. code-block:: python
ANYMAIL = {
...
"SENDGRID_MERGE_FIELD_FORMAT": "-{}-",
}
The placeholder `{}` will become the merge field name. If you need to include
a literal brace character, double it up. (For example, Handlebars-style
``{{field}}`` delimiters would take the format string `"{{{{{}}}}}"`.)
The default `None` requires you include the delimiters directly in your
:attr:`~anymail.message.AnymailMessage.merge_data` keys.
You can also override this setting for individual messages.
See the notes on SendGrid :ref:`templates and merge <sendgrid-templates>`
below.
.. setting:: ANYMAIL_SENDGRID_API_URL
.. rubric:: SENDGRID_API_URL
@@ -185,6 +213,84 @@ Limitations and quirks
(Tested March, 2016)
.. _sendgrid-templates:
Batch sending/merge and ESP templates
-------------------------------------
SendGrid offers both :ref:`ESP stored templates <esp-stored-templates>`
and :ref:`batch sending <batch-send>` with per-recipient merge data.
You can use a SendGrid stored template by setting a message's
:attr:`~anymail.message.AnymailMessage.template_id` to the
template's unique id. Alternatively, you can refer to merge fields
directly in an EmailMessage's subject and body---the message itself
is used as an on-the-fly template.
In either case, supply the merge data values with Anymail's
normalized :attr:`~anymail.message.AnymailMessage.merge_data`
and :attr:`~anymail.message.AnymailMessage.merge_global_data`
message attributes.
.. code-block:: python
message = EmailMessage(
...
subject="", # don't add any additional subject content to the template
body="", # (same thing for additional body content)
to=["alice@example.com", "Bob <bob@example.com>"]
)
message.template_id = "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f" # SendGrid id
message.merge_data = {
'alice@example.com': {'name': "Alice", 'order_no': "12345"},
'bob@example.com': {'name': "Bob", 'order_no': "54321"},
}
message.merge_global_data = {
'ship_date': "May 15",
}
message.esp_extra = {
# Tell Anymail this SendGrid template uses "-field-" to refer to merge fields.
# (We could also just set SENDGRID_MERGE_FIELD_FORMAT in our ANYMAIL settings.)
'merge_field_format': "-{}-"
}
SendGrid doesn't have a pre-defined merge field syntax, so you
must tell Anymail how substitution fields are delimited in your templates.
There are three ways you can do this:
* Set `'merge_field_format'` in the message's
:attr:`~anymail.message.AnymailMessage.esp_extra` to a python :meth:`str.format`
string, as shown in the example above. (This applies only to that
particular EmailMessage.)
* *Or* set :setting:`SENDGRID_MERGE_FIELD_FORMAT <ANYMAIL_SENDGRID_MERGE_FIELD_FORMAT>`
in your Anymail settings. This is usually the best approach, and will apply to all messages
sent through SendGrid. (You can still use esp_extra to override for individual messages.)
* *Or* include the field delimiters directly in *all* your
:attr:`~anymail.message.AnymailMessage.merge_data` and
:attr:`~anymail.message.AnymailMessage.merge_global_data` keys.
E.g.: ``{'-name-': "Alice", '-order_no-': "12345"}``.
(This can be error-prone, and difficult to move to other ESPs.)
When you supply per-recipient :attr:`~anymail.message.AnymailMessage.merge_data`,
Anymail automatically changes how it communicates the "to" list to SendGrid, so that
so that each recipient sees only their own email address. (Anymail moves the recipients
from top-level "to" and "toname" API parameters into the "x-smtpapi" section "to" list.)
SendGrid templates allow you to mix your EmailMessage's `subject` and `body`
with the template subject and body (by using `<%subject%>` and `<%body%>` in
your SendGrid template definition where you want the message-specific versions
to appear). If you don't want to supply any additional subject or body content
from your Django app, set those EmailMessage attributes to empty strings.
See the `SendGrid's template overview`_ and `transactional template docs`_
for more information.
.. _SendGrid's template overview:
https://sendgrid.com/docs/User_Guide/Transactional_Templates/index.html
.. _transactional template docs:
https://sendgrid.com/docs/API_Reference/Web_API_v3/Transactional_Templates/smtpapi.html
.. _sendgrid-webhooks:
Status tracking webhooks

View File

@@ -1,7 +1,7 @@
.. _anymail-send-features:
.. module:: anymail.message
.. _anymail-send-features:
Anymail additions
=================

View File

@@ -1,120 +1,216 @@
.. _merge-vars:
Mail merge and ESP templates
============================
Anymail has some features to simplify using your ESP's email
templates and merge-variable features in a portable way.
However, ESP templating languages are generally proprietary,
which makes them inherently non-portable. Although Anymail
can normalize the Django code you write to supply merge
variables to your ESP, it can't help you avoid needing
to rewrite your email templates if you switch ESPs.
:ref:`Using Django templates <django-templates>` can be a
better, portable and maintainable option.
.. currentmodule:: anymail.message
.. _esp-templates:
.. _templates-and-merge:
ESP templates
-------------
Batch sending/merge and ESP templates
=====================================
.. warning::
If your ESP offers templates and batch-sending/merge capabilities,
Anymail can simplify using them in a portable way. Anymail doesn't
translate template syntax between ESPs, but it does normalize using
templates and providing merge data for batch sends.
These normalized ESP-template attributes aren't implemented yet.
but are planned for a future Anymail update. If you are using
your ESP's transactional templates,
`your input <https://github.com/anymail/django-anymail/issues/5>`_
would be appreciated.
Here's an example using both an ESP stored template and merge data:
.. code-block:: python
from django.core.mail import EmailMessage
message = EmailMessage(
subject=None, # use the subject in our stored template
from_email="marketing@example.com",
to=["Wile E. <wile@example.com>", "rr@example.com"])
message.template_id = "after_sale_followup_offer" # use this ESP stored template
message.merge_data = { # per-recipient data to merge into the template
'wile@example.com': {'NAME': "Wile E.",
'OFFER': "15% off anvils"},
'rr@example.com': {'NAME': "Mr. Runner"},
}
message.merge_global_data = { # merge data for all recipients
'PARTNER': "Acme, Inc.",
'OFFER': "5% off any Acme product", # a default if OFFER missing for recipient
}
message.send()
The message's :attr:`~AnymailMessage.template_id` identifies a template stored
at your ESP which provides the message body and subject. (Assuming the
ESP supports those features.)
The message's :attr:`~AnymailMessage.merge_data` supplies the per-recipient
data to substitute for merge fields in your template. Setting this attribute
also lets Anymail know it should use the ESP's :ref:`batch sending <batch-send>`
feature to deliver separate, individually-customized messages
to each address on the "to" list. (Again, assuming your ESP
supports that.)
.. note::
Templates and batch sending capabilities can vary widely
between ESPs, as can the syntax for merge fields. Be sure
to read the notes for :ref:`your specific ESP <supported-esps>`,
and test carefully with a small recipient list before
launching a gigantic batch send.
Although related and often used together, :ref:`esp-stored-templates`
and :ref:`merge data <merge-data>` are actually independent features.
For example, some ESPs will let you use merge field syntax
directly in your :class:`~django.core.mail.EmailMessage`
body, so you can do customized batch sending without needing
to define a stored template at the ESP.
.. To use a *Mandrill* (MailChimp) template stored in your Mandrill account,
.. set a :attr:`template_name` and (optionally) :attr:`template_content`
.. on your :class:`~django.core.mail.EmailMessage` object::
..
.. from django.core.mail import EmailMessage
..
.. msg = EmailMessage(subject="Shipped!", from_email="store@example.com",
.. to=["customer@example.com", "accounting@example.com"])
.. msg.template_name = "SHIPPING_NOTICE" # A Mandrill template name
.. msg.template_content = { # Content blocks to fill in
.. 'TRACKING_BLOCK': "<a href='.../*|TRACKINGNO|*'>track it</a>"
.. }
.. msg.global_merge_vars = { # Merge tags in your template
.. 'ORDERNO': "12345", 'TRACKINGNO': "1Z987"
.. }
.. msg.merge_vars = { # Per-recipient merge tags
.. 'accounting@example.com': {'NAME': "Pat"},
.. 'customer@example.com': {'NAME': "Kim"}
.. }
.. msg.send()
..
.. If :attr:`template_name` is set, Djrill will use Mandrill's
.. `messages/send-template API <https://mandrillapp.com/api/docs/messages.html#method=send-template>`_,
.. and will ignore any `body` text set on the `EmailMessage`.
..
.. All of Djrill's other :ref:`Mandrill-specific options <anymail-send-features>`
.. can be used with templates.
.. _esp-stored-templates:
ESP stored templates
--------------------
Many ESPs support transactional email templates that are stored and
managed within your ESP account. To use an ESP stored template
with Anymail, set :attr:`~AnymailMessage.template_id`
on an :class:`~django.core.mail.EmailMessage`.
.. attribute:: AnymailMessage.template_id
(not yet implemented)
The identifier of the ESP stored template you want to use.
For most ESPs, this is a `str` name or unique id.
(See the notes for your :ref:`specific ESP <supported-esps>`.)
.. attribute:: AnymailMessage.global_merge_vars
.. code-block:: python
(not yet implemented)
message.template_id = "after_sale_followup_offer"
.. ``dict``: merge variables to use for all recipients (most useful with :ref:`mandrill-templates`). ::
..
.. message.global_merge_vars = {'company': "ACME", 'offer': "10% off"}
..
.. Merge data must be strings or other JSON-serializable types.
.. (See :ref:`formatting-merge-data` for details.)
With most ESPs, using a stored template will ignore any
body (plain-text or HTML) from the :class:`~django.core.mail.EmailMessage`
object.
.. attribute:: AnymailMessage.merge_vars
A few ESPs also allow you to define the message's subject as part of the template,
but any subject you set on the :class:`~django.core.mail.EmailMessage`
will override the template subject. To use the subject stored with the ESP template,
set the message's `subject` to `None`:
(not yet implemented)
.. code-block:: python
.. ``dict``: per-recipient merge variables (most useful with :ref:`mandrill-templates`). The keys
.. in the dict are the recipient email addresses, and the values are dicts of merge vars for
.. each recipient::
..
.. message.merge_vars = {
.. 'wiley@example.com': {'offer': "15% off anvils"},
.. 'rr@example.com': {'offer': "instant tunnel paint"}
.. }
..
.. Merge data must be strings or other JSON-serializable types.
.. (See :ref:`formatting-merge-data` for details.)
message.subject = None # use subject from template (if supported)
Similarly, some ESPs can also specify the "from" address in the template
definition. Set `message.from_email = None` to use the template's "from."
(You must set this attribute *after* constructing an
:class:`~django.core.mail.EmailMessage` object; passing
`from_email=None` to the constructor will use Django's
:setting:`DEFAULT_FROM_EMAIL` setting, overriding your template value.)
.. _batch-send:
.. _merge-data:
Batch sending with merge data
-----------------------------
Several ESPs support "batch transactional sending," where a single API call can send messages
to multiple recipients. The message is customized for each email on the "to" list
by merging per-recipient data into the body and other message fields.
To use batch sending with Anymail (for ESPs that support it):
* Use "merge fields" (sometimes called "substitution variables" or similar)
in your message. This could be in an :ref:`ESP stored template <esp-stored-templates>`
referenced by :attr:`~AnymailMessage.template_id`,
or with some ESPs you can use merge fields directly in your
:class:`~django.core.mail.EmailMessage` (meaning the message itself
is treated as an on-the-fly template).
* Set the message's :attr:`~AnymailMessage.merge_data` attribute to define merge field
substitutions for each recipient, and optionally set :attr:`~AnymailMessage.merge_global_data`
to defaults or values to use for all recipients.
* Specify all of the recipients for the batch in the message's `to` list.
.. caution::
It's critical to set the :attr:`~AnymailMessage.merge_data` attribute:
this is how Anymail recognizes the message as a batch send.
When you provide merge_data, Anymail will tell the ESP to send an individual customized
message to each "to" address. Without it, you may get a single message to everyone,
exposing all of the email addresses to all recipients.
(If you don't have any per-recipient customizations, but still want individual messages,
just set merge_data to an empty dict.)
The exact syntax for merge fields varies by ESP. It might be something like
`*|NAME|*` or `-name-` or `<%name%>`. (Check the notes for
:ref:`your ESP <supported-esps>`, and remember you'll need to change
the template if you later switch ESPs.)
.. attribute:: AnymailMessage.merge_data
A `dict` of *per-recipient* template substitution/merge data. Each key in the
dict is a recipient email address, and its value is a `dict` of merge field
names and values to use for that recipient:
.. code-block:: python
message.merge_data = {
'wile@example.com': {'NAME': "Wile E.",
'OFFER': "15% off anvils"},
'rr@example.com': {'NAME': "Mr. Runner",
'OFFER': "instant tunnel paint"},
}
When `merge_data` is set, Anymail will use the ESP's batch sending option,
so that each `to` recipient gets an individual message (and doesn't see the
other emails on the `to` list).
.. attribute:: AnymailMessage.merge_global_data
A `dict` of template substitution/merge data to use for *all* recipients.
Keys are merge field names in your message template:
.. code-block:: python
message.merge_global_data = {
'PARTNER': "Acme, Inc.",
'OFFER': "5% off any Acme product", # a default OFFER
}
Merge data values must be strings. (Some ESPs also allow other
JSON-serializable types like lists or dicts.)
See :ref:`formatting-merge-data` for more information.
Like all :ref:`anymail-send-features`, you can use these extended template and
merge attributes with any :class:`~django.core.mail.EmailMessage` or subclass object.
(It doesn't have to be an :class:`AnymailMessage`.)
Tip: you can add :attr:`~!AnymailMessage.merge_global_data` to your
global Anymail :ref:`send defaults <send-defaults>` to supply merge data
available to all batch sends (e.g, site name, contact info). The global
defaults will be merged with any per-message :attr:`~!AnymailMessage.merge_global_data`.
.. _formatting-merge-data:
Formatting merge data
~~~~~~~~~~~~~~~~~~~~~
---------------------
If you're using a `date`, `datetime`, `Decimal`, or anything other
than strings and integers,
you'll need to format them into strings for use as merge data::
than strings and integers, you'll need to format them into strings
for use as merge data:
.. code-block:: python
product = Product.objects.get(123) # A Django model
total_cost = Decimal('19.99')
ship_date = date(2015, 11, 18)
# Won't work -- you'll get "not JSON serializable" exceptions:
msg.global_merge_vars = {
# Won't work -- you'll get "not JSON serializable" errors at send time:
message.merge_global_data = {
'PRODUCT': product,
'TOTAL_COST': total_cost,
'SHIP_DATE': ship_date
}
# Do something this instead:
msg.global_merge_vars = {
message.merge_global_data = {
'PRODUCT': product.name, # assuming name is a CharField
'TOTAL_COST': "%.2f" % total_cost,
'SHIP_DATE': ship_date.strftime('%B %d, %Y') # US-style "March 15, 2015"
@@ -123,32 +219,38 @@ you'll need to format them into strings for use as merge data::
These are just examples. You'll need to determine the best way to format
your merge data as strings.
Although floats are allowed in merge vars, you'll generally want to format them
Although floats are usually allowed in merge data, you'll generally want to format them
into strings yourself to avoid surprises with floating-point precision.
Anymail will raise :exc:`~anymail.exceptions.AnymailSerializationError` if you attempt
to send a message with non-json-serializable data.
to send a message with merge data (or metadata) that can't be sent to your ESP.
.. How To Use Default Mandrill Subject and From fields
.. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
..
.. To use default Mandrill "subject" or "from" field from your template definition
.. (overriding your EmailMessage and Django defaults), set the following attrs:
.. :attr:`use_template_subject` and/or :attr:`use_template_from` on
.. your :class:`~django.core.mail.EmailMessage` object::
..
.. msg.use_template_subject = True
.. msg.use_template_from = True
.. msg.send()
..
.. .. attribute:: use_template_subject
..
.. If `True`, Djrill will omit the subject, and Mandrill will
.. use the default subject from the template.
..
.. .. attribute:: use_template_from
..
.. If `True`, Djrill will omit the "from" field, and Mandrill will
.. use the default "from" from the template.
ESP templates vs. Django templates
----------------------------------
ESP templating languages are generally proprietary,
which makes them inherently non-portable.
Anymail only exposes the stored template capabilities that your ESP
already offers, and then simplifies providing merge data in a portable way.
It won't translate between different ESP template syntaxes, and it
can't do a batch send if your ESP doesn't support it.
There are two common cases where ESP template
and merge features are particularly useful with Anymail:
* When the people who develop and maintain your transactional
email templates are different from the people who maintain
your Django page templates. (For example, you use a single
ESP for both marketing and transactional email, and your
marketing team manages all the ESP email templates.)
* When you want to use your ESP's batch-sending capabilities
for performance reasons, where a single API call can
trigger individualized messages to hundreds or thousands of recipients.
(For example, sending a daily batch of shipping notifications.)
If neither of these cases apply, you may find that
:ref:`using Django templates <django-templates>` can be a more
portable and maintainable approach for building transactional email.

View File

@@ -27,14 +27,14 @@ Example that builds an email from the templates ``message_subject.txt``,
from django.template import Context
from django.template.loader import render_to_string
template_data = {
merge_data = {
'ORDERNO': "12345", 'TRACKINGNO': "1Z987"
}
plaintext_context = Context(autoescape=False) # HTML escaping not appropriate in plaintext
subject = render_to_string("message_subject.txt", template_data, plaintext_context)
text_body = render_to_string("message_body.txt", template_data, plaintext_context)
html_body = render_to_string("message_body.html", template_data)
subject = render_to_string("message_subject.txt", merge_data, plaintext_context)
text_body = render_to_string("message_body.txt", merge_data, plaintext_context)
html_body = render_to_string("message_body.html", merge_data)
msg = EmailMultiAlternatives(subject=subject, from_email="store@example.com",
to=["customer@example.com"], body=text_body)

View File

@@ -329,6 +329,44 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase):
self.assertEqual(data['o:tracking-opens'], 'no')
self.assertEqual(data['o:tracking-clicks'], 'yes')
# template_id: Mailgun doesn't support stored templates
def test_merge_data(self):
self.message.to = ['alice@example.com', 'Bob <bob@example.com>']
self.message.body = "Hi %recipient.name%. Welcome to %recipient.group% at %recipient.site%."
self.message.merge_data = {
'alice@example.com': {'name': "Alice", 'group': "Developers"},
'bob@example.com': {'name': "Bob"}, # and leave group undefined
}
self.message.merge_global_data = {
'group': "Users", # default
'site': "ExampleCo",
}
self.message.send()
data = self.get_api_call_data()
self.assertJSONEqual(data['recipient-variables'], {
'alice@example.com': {'name': "Alice", 'group': "Developers", 'site': "ExampleCo"},
'bob@example.com': {'name': "Bob", 'group': "Users", 'site': "ExampleCo"},
})
# Make sure we didn't modify original dicts on message:
self.assertEqual(self.message.merge_data, {
'alice@example.com': {'name': "Alice", 'group': "Developers"},
'bob@example.com': {'name': "Bob"},
})
self.assertEqual(self.message.merge_global_data, {'group': "Users", 'site': "ExampleCo"})
def test_only_merge_global_data(self):
# Make sure merge_global_data distributed to recipient-variables
# even when merge_data not set
self.message.to = ['alice@example.com', 'Bob <bob@example.com>']
self.message.merge_global_data = {'test': "value"}
self.message.send()
data = self.get_api_call_data()
self.assertJSONEqual(data['recipient-variables'], {
'alice@example.com': {'test': "value"},
'bob@example.com': {'test': "value"},
})
def test_sender_domain(self):
"""Mailgun send domain can come from from_email or esp_extra"""
# You could also use ANYMAIL_SEND_DEFAULTS={'esp_extra': {'sender_domain': 'your-domain.com'}}

View File

@@ -2,7 +2,6 @@
from __future__ import unicode_literals
import unittest
from datetime import date, datetime
from decimal import Decimal
from email.mime.base import MIMEBase
@@ -340,6 +339,68 @@ class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase):
self.assertEqual(data['message']['track_opens'], False)
self.assertEqual(data['message']['track_clicks'], True)
def test_template_id(self):
self.message.template_id = "welcome_template"
self.message.send()
data = self.get_api_call_json()
self.assert_esp_called("/messages/send-template.json") # template requires different send API
self.assertEqual(data['template_name'], "welcome_template")
self.assertEqual(data['template_content'], []) # Mandrill requires this field with send-template
def test_merge_data(self):
self.message.to = ['alice@example.com', 'Bob <bob@example.com>']
# Mandrill template_id is not required to use merge.
# You can just supply template content as the message (e.g.):
self.message.body = "Hi *|name|*. Welcome to *|group|* at *|site|*."
self.message.merge_data = {
'alice@example.com': {'name': "Alice", 'group': "Developers"},
'bob@example.com': {'name': "Bob"}, # and leave :group undefined
}
self.message.merge_global_data = {
'group': "Users",
'site': "ExampleCo",
}
self.message.send()
self.assert_esp_called("/messages/send.json") # didn't specify template_id, so use normal send
data = self.get_api_call_json()
self.assertCountEqual(data['message']['merge_vars'], [
{'rcpt': "alice@example.com", 'vars': [
{'name': "group", 'content': "Developers"},
{'name': "name", 'content': "Alice"}
]},
{'rcpt': "bob@example.com", 'vars': [
{'name': "name", 'content': "Bob"}
]},
])
self.assertCountEqual(data['message']['global_merge_vars'], [
{'name': "group", 'content': "Users"},
{'name': "site", 'content': "ExampleCo"},
])
self.assertEqual(data['message']['preserve_recipients'], False) # we force with merge_data
def test_missing_from(self):
"""Make sure a missing from_email omits from* from API call.
(Allows use of from email/name from template)
"""
# You must set from_email=None after constructing the EmailMessage
# (or you will end up with Django's settings.DEFAULT_FROM_EMAIL instead)
self.message.from_email = None
self.message.send()
data = self.get_api_call_json()
self.assertNotIn('from_email', data['message'])
self.assertNotIn('from_name', data['message'])
def test_missing_subject(self):
"""Make sure a missing subject omits subject from API call.
(Allows use of template subject)
"""
self.message.subject = None
self.message.send()
data = self.get_api_call_json()
self.assertNotIn('subject', data['message'])
def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options.

View File

@@ -142,7 +142,6 @@ class MandrillBackendDjrillFeatureTests(MandrillBackendMockAPITestCase):
'google_analytics_domains': ['example.com/test'],
'google_analytics_campaign': ['UA-00000000-1'],
'merge_language': 'mailchimp',
'global_merge_vars': {'TEST': 'djrill'},
'async': True,
'ip_pool': 'Pool1',
'invalid': 'invalid',
@@ -170,9 +169,6 @@ class MandrillBackendDjrillSendDefaultsTests(MandrillBackendMockAPITestCase):
self.assertEqual(data['message']['google_analytics_domains'], ['example.com/test'])
self.assertEqual(data['message']['google_analytics_campaign'], ['UA-00000000-1'])
self.assertEqual(data['message']['merge_language'], 'mailchimp')
self.assertEqual(data['message']['global_merge_vars'],
[{'name': 'TEST', 'content': 'djrill'}])
self.assertFalse('merge_vars' in data['message'])
self.assertFalse('recipient_metadata' in data['message'])
# Options at top level of api params (not in message dict):
self.assertTrue(data['async'])
@@ -217,69 +213,29 @@ class MandrillBackendDjrillSendDefaultsTests(MandrillBackendMockAPITestCase):
self.assertEqual(data['message']['google_analytics_domains'], ['override.example.com'])
self.assertEqual(data['message']['google_analytics_campaign'], ['UA-99999999-1'])
self.assertEqual(data['message']['merge_language'], 'handlebars')
self.assertEqual(data['message']['global_merge_vars'], [{'name': 'TEST', 'content': 'djrill'}])
# Options at top level of api params (not in message dict):
self.assertFalse(data['async'])
self.assertEqual(data['ip_pool'], 'Bulk Pool')
def test_global_merge(self):
# Test that global settings merge in
self.message.global_merge_vars = {'GREETING': "Hello"}
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['message']['global_merge_vars'],
[{'name': "GREETING", 'content': "Hello"},
{'name': 'TEST', 'content': 'djrill'}])
def test_global_merge_overwrite(self):
# Test that global merge settings are overwritten
self.message.global_merge_vars = {'TEST': "Hello"}
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['message']['global_merge_vars'],
[{'name': 'TEST', 'content': 'Hello'}])
class MandrillBackendDjrillTemplateTests(MandrillBackendMockAPITestCase):
"""Test backend support for ESP templating features"""
# Holdovers from Djrill, until we design Anymail's normalized esp-template support
def test_merge_data(self):
# Anymail expands simple python dicts into the more-verbose name/content
# structures the Mandrill API uses
def test_merge_language(self):
self.message.merge_language = "mailchimp"
self.message.global_merge_vars = {'GREETING': "Hello",
'ACCOUNT_TYPE': "Basic"}
self.message.merge_vars = {
"customer@example.com": {'GREETING': "Dear Customer",
'ACCOUNT_TYPE': "Premium"},
"guest@example.com": {'GREETING': "Dear Guest"},
}
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['message']['merge_language'], "mailchimp")
self.assertEqual(data['message']['global_merge_vars'],
[{'name': 'ACCOUNT_TYPE', 'content': "Basic"},
{'name': "GREETING", 'content': "Hello"}])
self.assertEqual(data['message']['merge_vars'],
[{'rcpt': "customer@example.com",
'vars': [{'name': 'ACCOUNT_TYPE', 'content': "Premium"},
{'name': "GREETING", 'content': "Dear Customer"}]},
{'rcpt': "guest@example.com",
'vars': [{'name': "GREETING", 'content': "Dear Guest"}]}
])
def test_send_template(self):
self.message.template_name = "PERSONALIZED_SPECIALS"
def test_template_content(self):
self.message.template_content = {
'HEADLINE': "<h1>Specials Just For *|FNAME|*</h1>",
'OFFER_BLOCK': "<p><em>Half off</em> all fruit</p>"
}
self.message.send()
self.assert_esp_called("/messages/send-template.json")
data = self.get_api_call_json()
self.assertEqual(data['template_name'], "PERSONALIZED_SPECIALS")
# Anymail expands simple python dicts into the more-verbose name/content
# structures the Mandrill API uses
self.assertEqual(data['template_content'],
@@ -287,47 +243,3 @@ class MandrillBackendDjrillTemplateTests(MandrillBackendMockAPITestCase):
'content': "<h1>Specials Just For *|FNAME|*</h1>"},
{'name': "OFFER_BLOCK",
'content': "<p><em>Half off</em> all fruit</p>"}])
def test_send_template_without_from_field(self):
self.message.template_name = "PERSONALIZED_SPECIALS"
self.message.use_template_from = True
self.message.send()
self.assert_esp_called("/messages/send-template.json")
data = self.get_api_call_json()
self.assertEqual(data['template_name'], "PERSONALIZED_SPECIALS")
self.assertFalse('from_email' in data['message'])
self.assertFalse('from_name' in data['message'])
def test_send_template_without_from_field_api_failure(self):
self.set_mock_response(status_code=400)
self.message.template_name = "PERSONALIZED_SPECIALS"
self.message.use_template_from = True
with self.assertRaises(AnymailAPIError):
self.message.send()
def test_send_template_without_subject_field(self):
self.message.template_name = "PERSONALIZED_SPECIALS"
self.message.use_template_subject = True
self.message.send()
self.assert_esp_called("/messages/send-template.json")
data = self.get_api_call_json()
self.assertEqual(data['template_name'], "PERSONALIZED_SPECIALS")
self.assertFalse('subject' in data['message'])
def test_no_template_content(self):
# Just a template, without any template_content to be merged
self.message.template_name = "WELCOME_MESSAGE"
self.message.send()
self.assert_esp_called("/messages/send-template.json")
data = self.get_api_call_json()
self.assertEqual(data['template_name'], "WELCOME_MESSAGE")
self.assertEqual(data['template_content'], []) # Mandrill requires this field
def test_non_template_send(self):
# Make sure the non-template case still uses /messages/send.json
self.message.send()
self.assert_esp_called("/messages/send.json")
data = self.get_api_call_json()
self.assertFalse('template_name' in data)
self.assertFalse('template_content' in data)
self.assertFalse('async' in data)

View File

@@ -332,6 +332,34 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase):
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'track_clicks'):
self.message.send()
def test_template(self):
self.message.template_id = 1234567
# Postmark doesn't support per-recipient merge_data
self.message.merge_global_data = {'name': "Alice", 'group': "Developers"}
self.message.send()
self.assert_esp_called('/email/withTemplate/')
data = self.get_api_call_json()
self.assertEqual(data['TemplateId'], 1234567)
self.assertEqual(data['TemplateModel'], {'name': "Alice", 'group': "Developers"})
def test_merge_data(self):
self.message.merge_data = {
'alice@example.com': {'name': "Alice", 'group': "Developers"},
}
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'merge_data'):
self.message.send()
def test_missing_subject(self):
"""Make sure a missing subject omits Subject from API call.
(Allows use of template subject)
"""
self.message.template_id = 1234567
self.message.subject = None
self.message.send()
data = self.get_api_call_json()
self.assertNotIn('Subject', data)
def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options.
@@ -342,6 +370,8 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase):
self.message.send()
data = self.get_api_call_json()
self.assertNotIn('Tag', data)
self.assertNotIn('TemplateId', data)
self.assertNotIn('TemplateModel', data)
self.assertNotIn('TrackOpens', data)
def test_esp_extra(self):

View File

@@ -15,7 +15,7 @@ from django.test import SimpleTestCase
from django.test.utils import override_settings
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature
from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature, AnymailWarning
from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
@@ -403,6 +403,91 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase):
self.assertEqual(smtpapi['filters']['clicktrack'], {'settings': {'enable': 1}})
self.assertEqual(smtpapi['filters']['opentrack'], {'settings': {'enable': 0}})
def test_template_id(self):
self.message.template_id = "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f"
self.message.send()
smtpapi = self.get_smtpapi()
self.assertEqual(smtpapi['filters']['templates'], {
'settings': {'enable': 1,
'template_id': "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f"}
})
def test_merge_data(self):
self.message.to = ['alice@example.com', 'Bob <bob@example.com>']
# SendGrid template_id is not required to use merge.
# You can just supply template content as the message (e.g.):
self.message.body = "Hi :name. Welcome to :group at :site."
self.message.merge_data = {
# You must either include merge field delimiters in the keys (':name' rather than just 'name')
# as shown here, or use one of the merge_field_format options shown in the test cases below
'alice@example.com': {':name': "Alice", ':group': "Developers"},
'bob@example.com': {':name': "Bob"}, # and leave :group undefined
}
self.message.merge_global_data = {
':group': "Users",
':site': "ExampleCo",
}
self.message.send()
data = self.get_api_call_data()
smtpapi = self.get_smtpapi()
self.assertNotIn('to', data) # recipients should be moved to smtpapi-to with merge_data
self.assertNotIn('toname', data)
self.assertEqual(smtpapi['to'], ['alice@example.com', 'Bob <bob@example.com>'])
self.assertEqual(smtpapi['sub'], {
':name': ["Alice", "Bob"],
':group': ["Developers", ":group"], # missing value gets replaced with var name...
})
self.assertEqual(smtpapi['section'], {
':group': "Users", # ... which SG should then try to resolve from here
':site': "ExampleCo",
})
@override_settings(ANYMAIL_SENDGRID_MERGE_FIELD_FORMAT=":{}") # :field as shown in SG examples
def test_merge_field_format_setting(self):
# Provide merge field delimiters in settings.py
self.message.to = ['alice@example.com', 'Bob <bob@example.com>']
self.message.merge_data = {
'alice@example.com': {'name': "Alice", 'group': "Developers"},
'bob@example.com': {'name': "Bob"}, # and leave group undefined
}
self.message.merge_global_data = {'site': "ExampleCo"}
self.message.send()
smtpapi = self.get_smtpapi()
self.assertEqual(smtpapi['sub'], {
':name': ["Alice", "Bob"],
':group': ["Developers", ":group"] # substitutes formatted field name if missing for recipient
})
self.assertEqual(smtpapi['section'], {':site': "ExampleCo"})
def test_merge_field_format_esp_extra(self):
# Provide merge field delimiters for an individual message
self.message.to = ['alice@example.com', 'Bob <bob@example.com>']
self.message.merge_data = {
'alice@example.com': {'name': "Alice", 'group': "Developers"},
'bob@example.com': {'name': "Bob"}, # and leave group undefined
}
self.message.merge_global_data = {'site': "ExampleCo"}
self.message.esp_extra = {'merge_field_format': '*|{}|*'} # match Mandrill/MailChimp delimiters
self.message.send()
smtpapi = self.get_smtpapi()
self.assertEqual(smtpapi['sub'], {
'*|name|*': ["Alice", "Bob"],
'*|group|*': ["Developers", '*|group|*'] # substitutes formatted field name if missing for recipient
})
self.assertEqual(smtpapi['section'], {'*|site|*': "ExampleCo"})
# Make sure our esp_extra merge_field_format doesn't get sent to SendGrid API:
data = self.get_api_call_data()
self.assertNotIn('merge_field_format', data)
def test_warn_if_no_merge_field_delimiters(self):
self.message.to = ['alice@example.com']
self.message.merge_data = {
'alice@example.com': {'name': "Alice", 'group': "Developers"},
}
with self.assertWarnsRegex(AnymailWarning, r'SENDGRID_MERGE_FIELD_FORMAT'):
self.message.send()
@override_settings(ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False) # else we force unique_args
def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options.