diff --git a/README.rst b/README.rst index 1d3f0f2..e7b101f 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/anymail/backends/base.py b/anymail/backends/base.py index 1802fb0..7d0f8e3 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -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") diff --git a/anymail/backends/mailgun.py b/anymail/backends/mailgun.py index 201a10b..ae68cf3 100644 --- a/anymail/backends/mailgun.py +++ b/anymail/backends/mailgun.py @@ -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 diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index 240a6df..22415c9 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -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'] = [ diff --git a/anymail/backends/postmark.py b/anymail/backends/postmark.py index ef2dc56..60c487b 100644 --- a/anymail/backends/postmark.py +++ b/anymail/backends/postmark.py @@ -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': diff --git a/anymail/backends/sendgrid.py b/anymail/backends/sendgrid.py index e97e319..ee84b13 100644 --- a/anymail/backends/sendgrid.py +++ b/anymail/backends/sendgrid.py @@ -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) diff --git a/anymail/message.py b/anymail/message.py index 6bb6dc0..caab121 100644 --- a/anymail/message.py +++ b/anymail/message.py @@ -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 diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 18789a7..8cdf7d8 100644 --- a/docs/esps/index.rst +++ b/docs/esps/index.rst @@ -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 ` -------------------------------------------------------------------------------------------- -: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 ` and :ref:`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` diff --git a/docs/esps/mailgun.rst b/docs/esps/mailgun.rst index eeb2ee1..5e852f8 100644 --- a/docs/esps/mailgun.rst +++ b/docs/esps/mailgun.rst @@ -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 `, +so Anymail's :attr:`~anymail.message.AnymailMessage.template_id` message +attribute is not supported with the Mailgun backend. + +Mailgun *does* support :ref:`batch sending ` 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 "] + ) + # (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 diff --git a/docs/esps/mandrill.rst b/docs/esps/mandrill.rst index c213efd..5906a43 100644 --- a/docs/esps/mandrill.rst +++ b/docs/esps/mandrill.rst @@ -3,7 +3,7 @@ Mandrill ======== -Anymail integrates with the `Mandrill `_ +Anymail integrates with the `Mandrill `__ 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 ` +and :ref:`batch sending ` 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 "] + ) + # (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 ` - 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, diff --git a/docs/esps/postmark.rst b/docs/esps/postmark.rst index 9c60176..83dbf49 100644 --- a/docs/esps/postmark.rst +++ b/docs/esps/postmark.rst @@ -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 ` +populated with global merge data for all recipients, but does not +offer :ref:`batch sending ` 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: diff --git a/docs/esps/sendgrid.rst b/docs/esps/sendgrid.rst index e9decca..be169f0 100644 --- a/docs/esps/sendgrid.rst +++ b/docs/esps/sendgrid.rst @@ -89,6 +89,34 @@ Default ``True``. You can set to ``False`` to disable this behavior. See :ref:`Message-ID quirks ` below. +.. setting:: ANYMAIL_SENDGRID_MERGE_FIELD_FORMAT + +.. rubric:: SENDGRID_MERGE_FIELD_FORMAT + +If you use :ref:`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 ` +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 ` +and :ref:`batch sending ` 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 "] + ) + 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 ` + 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 diff --git a/docs/sending/anymail_additions.rst b/docs/sending/anymail_additions.rst index 06370aa..de12baf 100644 --- a/docs/sending/anymail_additions.rst +++ b/docs/sending/anymail_additions.rst @@ -1,7 +1,7 @@ -.. _anymail-send-features: - .. module:: anymail.message +.. _anymail-send-features: + Anymail additions ================= diff --git a/docs/sending/templates.rst b/docs/sending/templates.rst index 5b4baa5..bc2e771 100644 --- a/docs/sending/templates.rst +++ b/docs/sending/templates.rst @@ -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 ` 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 `_ - 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. ", "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 ` +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 `, + 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 ` 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': "track it" -.. } -.. 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 `_, -.. and will ignore any `body` text set on the `EmailMessage`. -.. -.. All of Djrill's other :ref:`Mandrill-specific options ` -.. 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 `.) -.. 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 ` + 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 `, 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 ` 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 ` can be a more +portable and maintainable approach for building transactional email. diff --git a/docs/tips/django_templates.rst b/docs/tips/django_templates.rst index 285e47d..09c1f42 100644 --- a/docs/tips/django_templates.rst +++ b/docs/tips/django_templates.rst @@ -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) diff --git a/tests/test_mailgun_backend.py b/tests/test_mailgun_backend.py index 23488e3..e4ac98e 100644 --- a/tests/test_mailgun_backend.py +++ b/tests/test_mailgun_backend.py @@ -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 '] + 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 '] + 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'}} diff --git a/tests/test_mandrill_backend.py b/tests/test_mandrill_backend.py index 8afbe2d..75f9c1a 100644 --- a/tests/test_mandrill_backend.py +++ b/tests/test_mandrill_backend.py @@ -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 '] + # 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. diff --git a/tests/test_mandrill_djrill_features.py b/tests/test_mandrill_djrill_features.py index 50a9e8e..7448588 100644 --- a/tests/test_mandrill_djrill_features.py +++ b/tests/test_mandrill_djrill_features.py @@ -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': "

Specials Just For *|FNAME|*

", 'OFFER_BLOCK': "

Half off all fruit

" } 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': "

Specials Just For *|FNAME|*

"}, {'name': "OFFER_BLOCK", 'content': "

Half off all fruit

"}]) - - 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) diff --git a/tests/test_postmark_backend.py b/tests/test_postmark_backend.py index b813b63..3f703da 100644 --- a/tests/test_postmark_backend.py +++ b/tests/test_postmark_backend.py @@ -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): diff --git a/tests/test_sendgrid_backend.py b/tests/test_sendgrid_backend.py index 3a3a9c3..3a6959c 100644 --- a/tests/test_sendgrid_backend.py +++ b/tests/test_sendgrid_backend.py @@ -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 '] + # 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 ']) + 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 '] + 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 '] + 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.