Expose most Mandrill send features on EmailMessage objects.

* Supports additional Mandrill send-API attributes on any ``EmailMessage``-derived object -- see details in readme
* Removes need for MANDRILL_API_URL in settings (since this is tightly tied to the code)
* Removes ``DjrillMessage`` from the readme (but not the code or tests) -- its functionality is now duplicated or exceeded by standard EmailMessage with additional attributes
* Ensures send(fail_silently=True) works as expected
This commit is contained in:
medmunds
2012-12-04 17:28:15 -08:00
parent 7d658f2f00
commit 8aab5e31b7
4 changed files with 291 additions and 91 deletions

View File

@@ -7,11 +7,16 @@ from django.utils import simplejson as json
from email.utils import parseaddr
import requests
# This backend was developed against this API endpoint.
# You can override in settings.py, if desired.
MANDRILL_API_URL = "http://mandrillapp.com/api/1.0"
class DjrillBackendHTTPError(Exception):
"""An exception that will turn into an HTTP error response."""
def __init__(self, status_code, log_message=None):
def __init__(self, status_code, response=None, log_message=None):
super(DjrillBackendHTTPError, self).__init__()
self.status_code = status_code
self.response = response # often contains helpful Mandrill info
self.log_message = log_message
def __str__(self):
@@ -33,14 +38,11 @@ class DjrillBackend(BaseEmailBackend):
"""
super(DjrillBackend, self).__init__(**kwargs)
self.api_key = getattr(settings, "MANDRILL_API_KEY", None)
self.api_url = getattr(settings, "MANDRILL_API_URL", None)
self.api_url = getattr(settings, "MANDRILL_API_URL", MANDRILL_API_URL)
if not self.api_key:
raise ImproperlyConfigured("You have not set your mandrill api key "
"in the settings.py file.")
if not self.api_url:
raise ImproperlyConfigured("You have not added the Mandrill api "
"url to your settings.py")
self.api_action = self.api_url + "/messages/send.json"
@@ -51,6 +53,7 @@ class DjrillBackend(BaseEmailBackend):
num_sent = 0
for message in email_messages:
sent = self._send(message)
if sent:
num_sent += 1
@@ -60,20 +63,11 @@ class DjrillBackend(BaseEmailBackend):
if not message.recipients():
return False
self.sender = sanitize_address(message.from_email, message.encoding)
recipients_list = [sanitize_address(addr, message.encoding)
for addr in message.recipients()]
self.recipients = [{"email": e, "name": n} for n,e in [
parseaddr(r) for r in recipients_list]]
self.msg_dict = self._build_standard_message_dict(message)
if getattr(message, "alternative_subtype", None):
if message.alternative_subtype == "mandrill":
self._build_advanced_message_dict(message)
try:
msg_dict = self._build_standard_message_dict(message)
self._add_mandrill_options(message, msg_dict)
if getattr(message, 'alternatives', None):
self._add_alternatives(message)
self._add_alternatives(message, msg_dict)
except ValueError:
if not self.fail_silently:
raise
@@ -81,61 +75,98 @@ class DjrillBackend(BaseEmailBackend):
djrill_it = requests.post(self.api_action, data=json.dumps({
"key": self.api_key,
"message": self.msg_dict
"message": msg_dict
}))
if djrill_it.status_code != 200:
if not self.fail_silently:
raise DjrillBackendHTTPError(
status_code=djrill_it.status_code,
response = djrill_it,
log_message="Failed to send a message to %s, from %s" %
(self.recipients, self.sender))
(msg_dict['to'], msg_dict['from_email']))
return False
return True
def _build_standard_message_dict(self, message):
"""
Build standard message dict.
"""Create a Mandrill send message struct from a Django EmailMessage.
Builds the standard dict that Django's send_mail and send_mass_mail
use by default. Standard text email messages sent through Django will
still work through Mandrill.
Raises ValueError for any standard EmailMessage features that cannot be
accurately communicated to Mandrill (e.g., prohibited headers).
"""
from_name, from_email = parseaddr(self.sender)
sender = sanitize_address(message.from_email, message.encoding)
from_name, from_email = parseaddr(sender)
recipients = [parseaddr(sanitize_address(addr, message.encoding))
for addr in message.recipients()]
to_list = [{"email": to_email, "name": to_name}
for (to_name, to_email) in recipients]
msg_dict = {
"text": message.body,
"subject": message.subject,
"from_email": from_email,
"to": self.recipients
"to": to_list
}
if from_name:
msg_dict["from_name"] = from_name
if message.extra_headers:
accepted_headers = {}
for k in message.extra_headers.keys():
if k.startswith("X-") or k == "Reply-To":
accepted_headers.update(
{"%s" % k: message.extra_headers[k]})
msg_dict.update({"headers": accepted_headers})
if k != "Reply-To" and not k.startswith("X-"):
raise ValueError("Invalid message header '%s' - Mandrill "
"only allows Reply-To and X-* headers" % k)
msg_dict["headers"] = message.extra_headers
return msg_dict
def _build_advanced_message_dict(self, message):
"""
Builds advanced message dict
"""
self.msg_dict.update({
"tags": message.tags,
"track_opens": message.track_opens,
"track_clicks": message.track_clicks,
"preserve_recipients": message.preserve_recipients,
})
if message.from_name:
self.msg_dict["from_name"] = message.from_name
def _add_mandrill_options(self, message, msg_dict):
"""Extend msg_dict to include Mandrill options set on message"""
# Mandrill attributes that can be copied directly:
mandrill_attrs = [
'from_name', # overrides display name parsed from from_email above
'track_opens', 'track_clicks', 'auto_text', 'url_strip_qs',
'tags', 'preserve_recipients',
'google_analytics_domains', 'google_analytics_campaign',
'metadata']
for attr in mandrill_attrs:
if hasattr(message, attr):
msg_dict[attr] = getattr(message, attr)
# Allow simple python dicts in place of Mandrill
# [{name:name, value:value},...] arrays...
if hasattr(message, 'global_merge_vars'):
msg_dict['global_merge_vars'] = \
self._expand_merge_vars(message.global_merge_vars)
if hasattr(message, 'merge_vars'):
# For testing reproducibility, we sort the recipients
msg_dict['merge_vars'] = [
{ 'rcpt': rcpt,
'vars': self._expand_merge_vars(message.merge_vars[rcpt]) }
for rcpt in sorted(message.merge_vars.keys())
]
if hasattr(message, 'recipient_metadata'):
# For testing reproducibility, we sort the recipients
msg_dict['recipient_metadata'] = [
{ 'rcpt': rcpt, 'values': message.recipient_metadata[rcpt] }
for rcpt in sorted(message.recipient_metadata.keys())
]
def _add_alternatives(self, message):
def _expand_merge_vars(self, vars):
"""Convert a Python dict to an array of name-value used by Mandrill.
{ name: value, ... } --> [ {'name': name, 'value': value }, ... ]
"""
# For testing reproducibility, we sort the keys
return [ { 'name': name, 'value': vars[name] }
for name in sorted(vars.keys()) ]
def _add_alternatives(self, message, msg_dict):
"""
There can be only one! ... alternative attachment, and it must be text/html.
@@ -154,6 +185,4 @@ class DjrillBackend(BaseEmailBackend):
"Mandrill only accepts plain text and html emails."
% mimetype)
self.msg_dict.update({
"html": content
})
msg_dict['html'] = content

View File

@@ -10,6 +10,8 @@ from django.test import TestCase
from django.utils import simplejson as json
from djrill.mail import DjrillMessage
from djrill.mail.backends.djrill import DjrillBackendHTTPError
class DjrillBackendMockAPITestCase(TestCase):
"""TestCase that uses Djrill EmailBackend with a mocked Mandrill API"""
@@ -26,7 +28,6 @@ class DjrillBackendMockAPITestCase(TestCase):
self.mock_post.return_value = self.MockResponse()
settings.MANDRILL_API_KEY = "FAKE_API_KEY_FOR_TESTING"
settings.MANDRILL_API_URL = "http://mandrillapp.com/api/1.0"
# Django TestCase sets up locmem EmailBackend; override it here
self.original_email_backend = settings.EMAIL_BACKEND
@@ -93,8 +94,7 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
bcc=['bcc1@example.com', 'Also BCC <bcc2@example.com>'],
cc=['cc1@example.com', 'Also CC <cc2@example.com>'],
headers={'Reply-To': 'another@example.com',
'X-MyHeader': 'my value',
'Errors-To': 'silently stripped'})
'X-MyHeader': 'my value'})
email.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['subject'], "Subject")
@@ -124,6 +124,22 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
self.assertEqual(data['message']['text'], text_content)
self.assertEqual(data['message']['html'], html_content)
def test_extra_header_errors(self):
email = mail.EmailMessage('Subject', 'Body', 'from@example.com',
['to@example.com'],
headers={'Non-X-Non-Reply-To-Header': 'not permitted'})
with self.assertRaises(ValueError):
email.send()
# Make sure fail_silently is respected
email = mail.EmailMessage('Subject', 'Body', 'from@example.com',
['to@example.com'],
headers={'Non-X-Non-Reply-To-Header': 'not permitted'})
sent = email.send(fail_silently=True)
self.assertFalse(self.mock_post.called,
msg="Mandrill API should not be called when send fails silently")
self.assertEqual(sent, 0)
def test_alternative_errors(self):
# Multiple alternatives not allowed
email = mail.EmailMultiAlternatives('Subject', 'Body',
@@ -149,6 +165,112 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
msg="Mandrill API should not be called when send fails silently")
self.assertEqual(sent, 0)
def test_mandrill_api_failure(self):
self.mock_post.return_value = self.MockResponse(status_code=400)
with self.assertRaises(DjrillBackendHTTPError):
sent = mail.send_mail('Subject', 'Body', 'from@example.com',
['to@example.com'])
self.assertEqual(sent, 0)
# Make sure fail_silently is respected
self.mock_post.return_value = self.MockResponse(status_code=400)
sent = mail.send_mail('Subject', 'Body', 'from@example.com',
['to@example.com'], fail_silently=True)
self.assertEqual(sent, 0)
class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
"""Test Djrill backend support for Mandrill-specific features"""
def setUp(self):
super(DjrillMandrillFeatureTests, self).setUp()
self.message = mail.EmailMessage('Subject', 'Text Body',
'from@example.com', ['to@example.com'])
def test_tracking(self):
# First make sure we're not setting the API param if the track_click
# attr isn't there. (The Mandrill account option of True for html,
# False for plaintext can't be communicated through the API, other than
# by omitting the track_clicks API param to use your account default.)
self.message.send()
data = self.get_api_call_data()
self.assertFalse('track_clicks' in data['message'])
# Now re-send with the params set
self.message.track_opens = True
self.message.track_clicks = True
self.message.url_strip_qs = True
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['track_opens'], True)
self.assertEqual(data['message']['track_clicks'], True)
self.assertEqual(data['message']['url_strip_qs'], True)
def test_message_options(self):
self.message.auto_text = True
self.message.preserve_recipients = True
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['auto_text'], True)
self.assertEqual(data['message']['preserve_recipients'], True)
def test_merge(self):
# Djrill expands simple python dicts into the more-verbose name/value
# structures the Mandrill API uses
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_data()
self.assertEqual(data['message']['global_merge_vars'],
[ {'name': 'ACCOUNT_TYPE', 'value': "Basic"},
{'name': "GREETING", 'value': "Hello"} ])
self.assertEqual(data['message']['merge_vars'],
[ { 'rcpt': "customer@example.com",
'vars': [{ 'name': 'ACCOUNT_TYPE', 'value': "Premium" },
{ 'name': "GREETING", 'value': "Dear Customer"}] },
{ 'rcpt': "guest@example.com",
'vars': [{ 'name': "GREETING", 'value': "Dear Guest"}] }
])
def test_tags(self):
self.message.tags = ["receipt", "repeat-user"]
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['tags'], ["receipt", "repeat-user"])
def test_google_analytics(self):
self.message.google_analytics_domains = ["example.com"]
self.message.google_analytics_campaign = "Email Receipts"
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['google_analytics_domains'],
["example.com"])
self.assertEqual(data['message']['google_analytics_campaign'],
"Email Receipts")
def test_metadata(self):
self.message.metadata = { 'batch_num': "12345", 'type': "Receipts" }
self.message.recipient_metadata = {
# Djrill expands simple python dicts into the more-verbose
# name/value structures the Mandrill API uses
"customer@example.com": { 'cust_id': "67890", 'order_id': "54321" },
"guest@example.com": { 'cust_id': "94107", 'order_id': "43215" }
}
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['metadata'], { 'batch_num': "12345",
'type': "Receipts" })
self.assertEqual(data['message']['recipient_metadata'],
[ { 'rcpt': "customer@example.com",
'values': { 'cust_id': "67890", 'order_id': "54321" } },
{ 'rcpt': "guest@example.com",
'values': { 'cust_id': "94107", 'order_id': "43215" } }
])
def reset_admin_site():
"""Return the Django admin globals to their original state"""

View File

@@ -5,6 +5,8 @@ from django.core.exceptions import ImproperlyConfigured
from django.utils import simplejson as json
from django.views.generic import TemplateView
from djrill.mail.backends.djrill import MANDRILL_API_URL
import requests
@@ -23,14 +25,11 @@ class DjrillApiMixin(object):
"""
def __init__(self):
self.api_key = getattr(settings, "MANDRILL_API_KEY", None)
self.api_url = getattr(settings, "MANDRILL_API_URL", None)
self.api_url = getattr(settings, "MANDRILL_API_URL", MANDRILL_API_URL)
if not self.api_key:
raise ImproperlyConfigured("You have not set your mandrill api key "
"in the settings file.")
if not self.api_url:
raise ImproperlyConfigured("You have not added the Mandrill api "
"url to your settings.py")
def get_context_data(self, **kwargs):
kwargs = super(DjrillApiMixin, self).get_context_data(**kwargs)
@@ -53,7 +52,7 @@ class DjrillApiJsonObjectsMixin(object):
def get_api_uri(self):
if self.api_uri is None:
raise ImproperlyConfigured(u"%(cls)s is missing an api_uri. Define "
raise NotImplementedError(u"%(cls)s is missing an api_uri. Define "
u"%(cls)s.api_uri or override %(cls)s.get_api_uri()." % {
"cls": self.__class__.__name__
})