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

@@ -17,38 +17,45 @@ Djrill is made available under the BSD license.
Installation
------------
::
Install from PyPI::
pip install djrill
The only dependency other than Django is the requests_ library from Kenneth Reitz. If you do not install through PyPI you will
need to do ::
The only dependency other than Django is the requests_ library from Kenneth
Reitz. (If you do not install Djrill using pip or setuptools, you will also
need to ``pip install requests``.)
pip install requests
Configuration
-------------
In ``settings.py``:
1. Add ``djrill`` to your ``INSTALLED_APPS``. ::
1. Add ``djrill`` to your ``INSTALLED_APPS``:
.. code:: python
INSTALLED_APPS = (
...
"djrill"
)
2. Add the following two lines, substituting your own ``MANDRILL_API_KEY``::
2. Add the following line, substituting your own ``MANDRILL_API_KEY``:
.. code:: python
MANDRILL_API_KEY = "brack3t-is-awesome"
MANDRILL_API_URL = "http://mandrillapp.com/api/1.0"
3. Override your existing email backend with the following line::
3. Override your existing email backend with the following line:
.. code:: python
EMAIL_BACKEND = "djrill.mail.backends.djrill.DjrillBackend"
4. (optional) If you want to be able to add senders through Django's admin or view stats about your
messages, do the following in your base ``urls.py`` ::
4. (optional) If you want to be able to add senders through Django's admin or
view stats about your messages, do the following in your base ``urls.py``:
.. code:: python
...
from django.contrib import admin
@@ -69,46 +76,86 @@ Usage
Since you are replacing the global ``EMAIL_BACKEND``, **all** emails are sent through Mandrill's service.
If you just want to use Mandrill for sending emails through Django's built-in ``send_mail`` and ``send_mass_mail`` methods, all
you need to do is follow steps 1 through 3 of the above Configuration.
In general, Djrill "just works" with Django's built-in `django.core.mail`_
package, including ``send_mail``, ``send_mass_mail``, ``EmailMessage`` and
``EmailMultiAlternatives``.
If, however, you want more control over the messages, to include an HTML version, or to attach tags or tracked URLs to an email,
usage of our ``DjrillMessage`` class, which is a thin wrapper around Django's ``EmailMultiAlternatives`` is required.
You can also take advantage of Mandrill-specific features like tags, metadata,
and tracking by creating a ``django.mail.EmailMessage`` (or for HTML,
``django.mail.EmailMultiAlternatives``) object and setting Mandrill-specific
properties on it before calling its ``send`` method.
Example, in a view: ::
Example:
from django.views.generic import View
.. code:: python
from djrill.mail import DjrillMessage
from django.core.mail import EmailMultiAlternatives # or just EmailMessage if you don't need HTML
class SendEmailView(View):
def get(self, request):
subject = "Djrill Message"
from_email = "djrill@example.com" # this has to be one of your approved senders
from_name = "Djrill" # optional
from_email = "Djrill Sender <djrill@example.com>" # this has to be in your Mandrill account's sending domains
to = ["Djrill Receiver <djrill.receiver@example.com>", "djrill.two@example.com"]
reply_email = "Customer Service <support@example.com>" # optional
text_content = "This is the text version of your email"
html_content = "<p>This is the HTML version of your email</p>" # optional, requires the ``attach_alternative`` line below
tags = ["one tag", "two tag", "red tag", "blue tag"] # optional, can't be over 50 chars or start with an underscore
html_content = "<p>This is the HTML version of your email</p>" # optional, use with ``attach_alternative`` below
msg = DjrillMessage(subject, text_content, from_email, to, tags=tags, from_name=from_name)
msg = EmailMultiAlternatives(subject, text_content, from_email, to, headers={'Reply-To': reply_email})
msg.tags = ["one tag", "two tag", "red tag", "blue tag"] # optional, Mandrill-specific message extension
msg.metadata = {'user_id': "8675309"} # optional, Mandrill-specific message extension
msg.attach_alternative(html_content, "text/html")
msg.send()
... # you'll want to return some sort of HttpResponse
Any tags over 50 characters in length are silently ignored since Mandrill doesn't support them. Any tags starting with an underscore will raise an ``ImproperlyConfigured``
exception. Tags with an underscore are reserved by Mandrill.
If the Mandrill API returns an error response for any reason, the send call will
raise a ``djrill.mail.backends.djrill.DjrillBackendHTTPError`` exception
(unless called with fail_silently=True).
If you attach more than one alternative type, an ``ImproperlyConfigured`` exception will be raised. Mandrill does not support attaching
files to an email, so attachments will be silently ignored.
Djrill supports most of the functionality of Django's ``EmailMessage`` and
``EmailMultiAlternatives``. Some limitations:
Not shown above, but settable, are the two options, ``track_clicks`` and ``track_opens``. They are both set to ``True`` by default, but can be set to ``False`` and passed in when you instantiate your ``DjrillMessage``
object.
* Djrill accepts additional headers, but only ``Reply-To`` and ``X-*`` (since
that is all that Mandrill accepts). Any other extra headers will raise a
``ValueError`` exception when you attempt to send the message.
* Djrill requires that if you ``attach_alternative`` to a message, there must be
only one alternative type, and it must be text/html. Otherwise, Djrill will
raise a ``ValueError`` exception when you attempt to send the message.
(Mandrill doesn't support sending multiple html alternative parts, or any
non-html alternatives.)
* Djrill (currently) silently ignores all attachments on a message.
* Djrill treats all cc and bcc recipients as if they were additional "to"
addresses. (Mandrill does not distinguish cc, and only allows a single bcc --
which Djrill doesn't use. *Caution:* depending on the ``preserve_recipients``
setting, this could result in exposing bcc addresses to all recipients. It's
probably best to just avoid bcc.)
Many of the options from the Mandrill `messages/send.json API`_ ``message``
struct can be set directly on an ``EmailMessage`` (or subclass) object:
* ``track_opens`` - Boolean
* ``track_clicks`` - Boolean (If you want to track clicks in HTML only, not
plaintext mail, you must *not* set this property, and instead just set the
default in your Mandrill account sending options.)
* ``auto_text`` - Boolean
* ``url_strip_qs`` - Boolean
* ``preserve_recipients`` - Boolean -- see the caution about bcc addresses above
* ``global_merge_vars`` - a dict -- e.g.,
``{ 'company': "ACME", 'offer': "10% off" }``
* ``recipient_merge_vars`` - a dict whose keys are the recipient email addresses
and whose values are dicts of merge vars for each recipient -- e.g.,
``{ 'wiley@example.com': { 'offer': "15% off anvils" } }``
* ``tags`` - a list of strings
* ``google_analytics_domains`` - a list of string domain names
* ``google_analytics_campaign`` - a string or list of strings
* ``metadata`` - a dict
* ``recipient_metadata`` - a dict whose keys are the recipient email addresses,
and whose values are dicts of metadata for each recipient (similar to
``recipient_merge_vars``)
These Mandrill-specific properties work with *any* ``EmailMessage``-derived
object, so you can use them with many other apps that add Django mail
functionality (such as Django template-based messages).
If you have any questions about the python syntax for any of these properties,
see ``DjrillMandrillFeatureTests`` in tests.py for examples.
Just like Django's ``EmailMessage`` and ``EmailMultiAlternatives``, ``DjrillMessage`` accepts extra headers through the
``headers`` argument. Currently it only accepts ``Reply-To`` and ``X-*`` headers since that is all that Mandrill accepts. Any
extra headers are silently discarded.
Testing
-------
@@ -145,3 +192,6 @@ the awesome ``requests`` library.
.. _requests: http://docs.python-requests.org
.. _django-adminplus: https://github.com/jsocol/django-adminplus
.. _mock: http://www.voidspace.org.uk/python/mock/index.html
.. _django.core.mail: https://docs.djangoproject.com/en/dev/topics/email/
.. _messages/send.json API: https://mandrillapp.com/api/docs/messages.html#method=send

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__
})