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

@@ -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.