mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-22 21:01:05 -05:00
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:
@@ -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'}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user