Merge pull request #23 from brack3t/v030

Version 0.3.0
This commit is contained in:
Mike Edmunds
2013-01-12 14:41:28 -08:00
15 changed files with 687 additions and 276 deletions

View File

@@ -2,7 +2,7 @@ language: python
python:
- "2.6"
- "2.7"
# - "3.2" # Requests setup currently broken under python 3
- "3.2"
env:
- DJANGO=django==1.3
- DJANGO=django==1.4

View File

@@ -8,3 +8,4 @@ ArnaudF
Théo Crevon
Rafael E. Belliard
Jared Morse
peillis

View File

@@ -7,6 +7,10 @@ Djrill, for Mandrill
Djrill is an email backend for Django users who want to take advantage of the
Mandrill_ transactional email service from MailChimp_.
In general, Djrill "just works" with Django's built-in `django.core.mail`_
package. You can also take advantage of Mandrill-specific features like tags,
metadata, and tracking.
An optional Django admin interface is included. The admin interface allows you to:
* Check the status of your Mandrill API connection.
@@ -74,7 +78,9 @@ In ``settings.py``:
Usage
-----
Since you are replacing the global ``EMAIL_BACKEND``, **all** emails are sent through Mandrill's service.
Since you are replacing the global ``EMAIL_BACKEND``, **all** emails are sent
through Mandrill's service. (To selectively use Mandrill for some messages, see
`Using Multiple Email Backends`_ below.)
In general, Djrill "just works" with Django's built-in `django.core.mail`_
package, including ``send_mail``, ``send_mass_mail``, ``EmailMessage`` and
@@ -83,7 +89,8 @@ package, including ``send_mail``, ``send_mass_mail``, ``EmailMessage`` and
You can also take advantage of Mandrill-specific features like tags, metadata,
and tracking by creating a Django EmailMessage_ (or for HTML,
EmailMultiAlternatives_) object and setting Mandrill-specific
properties on it before calling its ``send`` method.
properties on it before calling its ``send`` method. (See
`Mandrill Message Options`_ below.)
Example, sending HTML email with Mandrill tags and metadata:
@@ -107,33 +114,54 @@ Example, sending HTML email with Mandrill tags and metadata:
# Send it:
msg.send()
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 the email tries to use features that aren't supported by Mandrill, the send
call will raise a ``djrill.NotSupportedByMandrillError`` exception (a subclass
of ValueError).
If the Mandrill API fails or returns an error response, the send call will
raise a ``djrill.MandrillAPIError`` exception (a subclass of
requests.HTTPError).
Django EmailMessage Support
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Djrill supports most of the functionality of Django's `EmailMessage`_ and
`EmailMultiAlternatives`_ classes. Some limitations:
`EmailMultiAlternatives`_ classes. Some notes and limitations:
* 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.)
* All email addresses (from, to, cc) can be simple ("email@example.com") or
can include a display name ("Real Name <email@example.com>").
* The ``from_email`` must be in one of the approved sending domains in your
Mandrill account.
* **Display Names:** All email addresses (from, to, cc) can be simple
("email@example.com") or can include a display name
("Real Name <email@example.com>").
* **From Address:** The ``from_email`` must be in one of the approved sending
domains in your Mandrill account.
* **CC Recipients:** Djrill treats all "cc" recipients as if they were
additional "to" addresses. (Mandrill does not distinguish "cc" from "to".)
Note that you will also need to set ``preserve_recipients`` True if you want
each recipient to see the other recipients listed in the email headers.
* **BCC Recipients:** Mandrill does not permit more than one "bcc" address.
Djrill raises ``djrill.NotSupportedByMandrillError`` if you attempt to send a
message with multiple bcc's. (Mandrill's bcc option seems intended primarily
for logging. To send a single message to multiple recipients without exposing
their email addresses to each other, simply include them all in the "to" list
and leave ``preserve_recipients`` set to False.)
* **Attachments:** Djrill includes a message's attachments, but only with the
mimetypes "text/\*", "image/\*", or "application/pdf" (since that is all
Mandrill allows). Any other attachment types will raise
``djrill.NotSupportedByMandrillError`` when you attempt to send the message.
* **Headers:** Djrill accepts additional headers, but only ``Reply-To`` and
``X-*`` (since that is all that Mandrill accepts). Any other extra headers
will raise ``djrill.NotSupportedByMandrillError`` when you attempt to send the
message.
* **Alternative Parts:** Djrill requires that if you ``attach_alternative`` to a
message, there must be only one alternative part, and it must be text/html.
Otherwise, Djrill will raise ``djrill.NotSupportedByMandrillError`` when you
attempt to send the message. (Mandrill doesn't support sending multiple html
alternative parts, or any non-html alternatives.)
Many of the options from the Mandrill `messages/send.json API`_ ``message``
Mandrill Message Options
~~~~~~~~~~~~~~~~~~~~~~~~
Many of the options from the Mandrill `messages/send API`_ ``message``
struct can be set directly on an ``EmailMessage`` (or subclass) object:
* ``track_opens`` - Boolean
@@ -142,7 +170,7 @@ struct can be set directly on an ``EmailMessage`` (or subclass) object:
default in your Mandrill account sending options.)
* ``auto_text`` - Boolean
* ``url_strip_qs`` - Boolean
* ``preserve_recipients`` - Boolean -- see the caution about bcc addresses above
* ``preserve_recipients`` - Boolean
* ``global_merge_vars`` - a dict -- e.g.,
``{ 'company': "ACME", 'offer': "10% off" }``
* ``recipient_merge_vars`` - a dict whose keys are the recipient email addresses
@@ -161,14 +189,67 @@ 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.
see ``DjrillMandrillFeatureTests`` in tests/test_mandrill_send.py for examples.
Mandrill Templates
~~~~~~~~~~~~~~~~~~
To use a Mandrill (MailChimp) template, set a ``template_name`` and (optionally)
``template_content`` on your ``EmailMessage`` object:
.. code:: python
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': "<a href='.../\*\|TRACKINGNO\|\*'>track it</a>" }
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 template_name is set, Djrill will use Mandrill's `messages/send-template API`_,
rather than messages/send. All of the other options listed above can be used.
(This is for *MailChimp* templates stored in your Mandrill account. If you
want to use a *Django* template, you can use Django's render_to_string_ template
shortcut to build the body and html, and send using EmailMultiAlternatives as
in the earlier examples.)
Using Multiple Email Backends
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can use Django mail's optional ``connection`` argument to send some mail
through Mandrill and others through a different system. This can be useful to
send customer emails with Mandrill, but admin emails directly through an SMTP
server. Example:
.. code:: python
from django.core.mail import send_mail, get_connection
# send_mail connection defaults to the settings EMAIL_BACKEND, which
# we've set to DjrillBackend. This will be sent using Mandrill:
send_mail("Subject", "Body", "support@example.com", ["user@example.com"])
# Get a connection to an SMTP backend, and send using that instead:
smtp_backend = get_connection('django.core.mail.backends.smtp.EmailBackend')
send_mail("Subject", "Body", "admin@example.com", ["alert@example.com"],
connection=smtp_backend)
You can supply a different connection to Django's `django.core.mail`_
``send_mail`` and ``send_mass_mail`` helpers, and in the constructor for an
EmailMessage_ or EmailMultiAlternatives_.
Testing
-------
Djrill is tested against Django 1.3 and 1.4 on Python 2.6 and 2.7, and
Django 1.5beta on Python 2.7.
Django 1.5RC on Python 2.7 and 3.2.
(It may also work with Django 1.2 and Python 2.5, if you use an older
version of requests compatible with that code.)
@@ -180,16 +261,41 @@ calls, without actually calling Mandrill or sending any email. So the tests
don't require a Mandrill API key, but they *do* require mock_
(``pip install mock``). To run the tests, either::
python setup.py test
python -Wall setup.py test
or::
python runtests.py
python -Wall runtests.py
Contributing
------------
Djrill is maintained by its users -- it's not managed by the folks at MailChimp.
Pull requests are always welcome to improve support for Mandrill and Django
features.
Please include test cases with pull requests. (And by submitting a pull request,
you're agreeing to release your changes under under the same BSD license as the
rest of this project.)
Release Notes
-------------
Version 0.3.0:
* Attachments are now supported
* Mandrill templates are now supported
* A bcc address is now passed to Mandrill as bcc, rather than being lumped in
with the "to" recipients. Multiple bcc recipients will now raise an exception,
as Mandrill only allows one.
* Python 3 support (with Django 1.5)
* Exceptions should be more useful: ``djrill.NotSupportedByMandrillError``
replaces generic ValueError; ``djrill.MandrillAPIError`` replaces
DjrillBackendHTTPError, and is now derived from requests.HTTPError. (New
exceptions are backwards compatible with old ones for existing code.)
Version 0.2.0:
* ``MANDRILL_API_URL`` is no longer required in settings.py
@@ -215,5 +321,7 @@ the awesome ``requests`` library.
.. _django.core.mail: https://docs.djangoproject.com/en/dev/topics/email/
.. _EmailMessage: https://docs.djangoproject.com/en/dev/topics/email/#django.core.mail.EmailMessage
.. _EmailMultiAlternatives: https://docs.djangoproject.com/en/dev/topics/email/#sending-alternative-content-types
.. _messages/send.json API: https://mandrillapp.com/api/docs/messages.html#method=send
.. _render_to_string: https://docs.djangoproject.com/en/dev/ref/templates/api/#the-render-to-string-shortcut
.. _messages/send API: https://mandrillapp.com/api/docs/messages.html#method=send
.. _messages/send-template API: https://mandrillapp.com/api/docs/messages.html#method=send-template

View File

@@ -1,9 +1,16 @@
from django.conf import settings
from django.contrib.admin.sites import AdminSite
from django.utils.text import capfirst
VERSION = (0, 2, 0)
from djrill.exceptions import MandrillAPIError, NotSupportedByMandrillError
VERSION = (0, 3, 0)
__version__ = '.'.join([str(x) for x in VERSION])
# This backend was developed against this API endpoint.
# You can override in settings.py, if desired.
MANDRILL_API_URL = getattr(settings, "MANDRILL_API_URL",
"http://mandrillapp.com/api/1.0")
class DjrillAdminSite(AdminSite):
index_template = "djrill/index.html"
@@ -31,6 +38,7 @@ class DjrillAdminSite(AdminSite):
from django.conf.urls import include, patterns, url
except ImportError:
# Django 1.3
#noinspection PyDeprecation
from django.conf.urls.defaults import include, patterns, url
for path, view, name, display_name in self.custom_views:
urls += patterns('',

33
djrill/exceptions.py Normal file
View File

@@ -0,0 +1,33 @@
from requests import HTTPError
class MandrillAPIError(HTTPError):
"""Exception for unsuccessful response from Mandrill API."""
def __init__(self, status_code, response=None, log_message=None):
super(MandrillAPIError, self).__init__()
self.status_code = status_code
self.response = response # often contains helpful Mandrill info
self.log_message = log_message
def __str__(self):
message = "Mandrill API response %d" % self.status_code
if self.log_message:
message += "\n" + self.log_message
if self.response:
message += "\nResponse: " + getattr(self.response, 'content', "")
return message
class NotSupportedByMandrillError(ValueError):
"""Exception for email features that Mandrill doesn't support.
This is typically raised when attempting to send a Django EmailMessage that
uses options or values you might expect to work, but that are silently
ignored by or can't be communicated to Mandrill's API. (E.g., unsupported
attachment types, multiple bcc recipients.)
It's generally *not* raised for Mandrill-specific features, like limitations
on Mandrill tag names or restrictions on from emails. (Djrill expects
Mandrill to return an API error for these where appropriate, and tries to
avoid duplicating Mandrill's validation logic locally.)
"""

View File

@@ -1,31 +1,20 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail.message import sanitize_address
from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE
from django.utils import simplejson as json
# Oops: this file has the same name as our app, and cannot be renamed.
#from djrill import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
from ... import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
from base64 import b64encode
from email.mime.base import MIMEBase
from email.utils import parseaddr
import mimetypes
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, 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):
message = "DjrillBackendHTTP %d" % self.status_code
if self.log_message:
return message + " " + self.log_message
else:
return message
DjrillBackendHTTPError = MandrillAPIError # Backwards-compat Djrill<=0.2.0
class DjrillBackend(BaseEmailBackend):
"""
@@ -38,13 +27,14 @@ 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", MANDRILL_API_URL)
self.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.")
self.api_action = self.api_url + "/messages/send.json"
self.api_send = self.api_url + "/messages/send.json"
self.api_send_template = self.api_url + "/messages/send-template.json"
def send_messages(self, email_messages):
if not email_messages:
@@ -68,21 +58,34 @@ class DjrillBackend(BaseEmailBackend):
self._add_mandrill_options(message, msg_dict)
if getattr(message, 'alternatives', None):
self._add_alternatives(message, msg_dict)
except ValueError:
self._add_attachments(message, msg_dict)
except NotSupportedByMandrillError:
if not self.fail_silently:
raise
return False
djrill_it = requests.post(self.api_action, data=json.dumps({
api_url = self.api_send
api_params = {
"key": self.api_key,
"message": msg_dict
}))
}
if djrill_it.status_code != 200:
# check if template is set in message to send it via
# api url: /messages/send-template.json
if hasattr(message, 'template_name'):
api_url = self.api_send_template
api_params['template_name'] = message.template_name
if hasattr(message, 'template_content'):
api_params['template_content'] = \
self._expand_merge_vars(message.template_content)
response = requests.post(api_url, data=json.dumps(api_params))
if response.status_code != 200:
if not self.fail_silently:
raise DjrillBackendHTTPError(
status_code=djrill_it.status_code,
response = djrill_it,
raise MandrillAPIError(
status_code=response.status_code,
response=response,
log_message="Failed to send a message to %s, from %s" %
(msg_dict['to'], msg_dict['from_email']))
return False
@@ -95,16 +98,18 @@ class DjrillBackend(BaseEmailBackend):
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).
Raises NotSupportedByMandrillError for any standard EmailMessage
features that cannot be accurately communicated to Mandrill
(e.g., prohibited headers).
"""
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()]
recipients = message.to + message.cc # message.recipients() w/o bcc
parsed_rcpts = [parseaddr(sanitize_address(addr, message.encoding))
for addr in recipients]
to_list = [{"email": to_email, "name": to_name}
for (to_name, to_email) in recipients]
for (to_name, to_email) in parsed_rcpts]
msg_dict = {
"text": message.body,
@@ -115,10 +120,20 @@ class DjrillBackend(BaseEmailBackend):
if from_name:
msg_dict["from_name"] = from_name
if len(message.bcc) == 1:
bcc = message.bcc[0]
_, bcc_addr = parseaddr(sanitize_address(bcc, message.encoding))
msg_dict['bcc_address'] = bcc_addr
elif len(message.bcc) > 1:
raise NotSupportedByMandrillError(
"Too many bcc addresses (%d) - Mandrill only allows one"
% len(message.bcc))
if message.extra_headers:
for k in message.extra_headers.keys():
if k != "Reply-To" and not k.startswith("X-"):
raise ValueError("Invalid message header '%s' - Mandrill "
raise NotSupportedByMandrillError(
"Invalid message header '%s' - Mandrill "
"only allows Reply-To and X-* headers" % k)
msg_dict["headers"] = message.extra_headers
@@ -175,14 +190,74 @@ class DjrillBackend(BaseEmailBackend):
the HTML output for your email.
"""
if len(message.alternatives) > 1:
raise ValueError(
raise NotSupportedByMandrillError(
"Too many alternatives attached to the message. "
"Mandrill only accepts plain text and html emails.")
(content, mimetype) = message.alternatives[0]
if mimetype != 'text/html':
raise ValueError("Invalid alternative mimetype '%s'. "
raise NotSupportedByMandrillError(
"Invalid alternative mimetype '%s'. "
"Mandrill only accepts plain text and html emails."
% mimetype)
msg_dict['html'] = content
def _add_attachments(self, message, msg_dict):
"""Extend msg_dict to include any attachments in message"""
if message.attachments:
str_encoding = message.encoding or settings.DEFAULT_CHARSET
attachments = [
self._make_mandrill_attachment(attachment, str_encoding)
for attachment in message.attachments
]
if len(attachments) > 0:
msg_dict['attachments'] = attachments
def _make_mandrill_attachment(self, attachment, str_encoding=None):
"""Return a Mandrill dict for an EmailMessage.attachments item"""
# Note that an attachment can be either a tuple of (filename, content,
# mimetype) or a MIMEBase object. (Also, both filename and mimetype may
# be missing.)
if isinstance(attachment, MIMEBase):
filename = attachment.get_filename()
content = attachment.get_payload(decode=True)
mimetype = attachment.get_content_type()
else:
(filename, content, mimetype) = attachment
# Guess missing mimetype, borrowed from
# django.core.mail.EmailMessage._create_attachment()
if mimetype is None and filename is not None:
mimetype, _ = mimetypes.guess_type(filename)
if mimetype is None:
mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
# Mandrill silently filters attachments with unsupported mimetypes.
# This can be confusing, so we raise an exception instead.
(main, sub) = mimetype.lower().split('/')
attachment_allowed = (
main == 'text' or main == 'image'
or (main == 'application' and sub == 'pdf'))
if not attachment_allowed:
raise NotSupportedByMandrillError(
"Invalid attachment mimetype '%s'. Mandrill only supports "
"text/*, image/*, and application/pdf attachments."
% mimetype)
try:
content_b64 = b64encode(content)
except TypeError:
# Python 3 b64encode requires bytes. Convert str attachment:
if isinstance(content, str):
content_bytes = content.encode(str_encoding)
content_b64 = b64encode(content_bytes)
else:
raise
return {
'type': mimetype,
'name': filename or "",
'content': content_b64.decode('ascii'),
}

4
djrill/tests/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from djrill.tests.test_admin import *
from djrill.tests.test_legacy import *
from djrill.tests.test_mandrill_send import *
from djrill.tests.test_mandrill_send_template import *

View File

@@ -0,0 +1,64 @@
from mock import patch
from django.conf import settings
from django.test import TestCase
from django.utils import simplejson as json
class DjrillBackendMockAPITestCase(TestCase):
"""TestCase that uses Djrill EmailBackend with a mocked Mandrill API"""
class MockResponse:
"""requests.post return value mock sufficient for DjrillBackend"""
def __init__(self, status_code=200, content="{}"):
self.status_code = status_code
self.content = content
def setUp(self):
self.patch = patch('requests.post', autospec=True)
self.mock_post = self.patch.start()
self.mock_post.return_value = self.MockResponse()
settings.MANDRILL_API_KEY = "FAKE_API_KEY_FOR_TESTING"
# Django TestCase sets up locmem EmailBackend; override it here
self.original_email_backend = settings.EMAIL_BACKEND
settings.EMAIL_BACKEND = "djrill.mail.backends.djrill.DjrillBackend"
def tearDown(self):
self.patch.stop()
settings.EMAIL_BACKEND = self.original_email_backend
def assert_mandrill_called(self, endpoint):
"""Verifies the (mock) Mandrill API was called on endpoint.
endpoint is a Mandrill API, e.g., "/messages/send.json"
"""
# This assumes the last (or only) call to requests.post is the
# Mandrill API call of interest.
if self.mock_post.call_args is None:
raise AssertionError("Mandrill API was not called")
(args, kwargs) = self.mock_post.call_args
try:
post_url = kwargs.get('url', None) or args[0]
except IndexError:
raise AssertionError("requests.post was called without an url (?!)")
if not post_url.endswith(endpoint):
raise AssertionError(
"requests.post was not called on %s\n(It was called on %s)"
% (endpoint, post_url))
def get_api_call_data(self):
"""Returns the data posted to the Mandrill API.
Fails test if API wasn't called.
"""
if self.mock_post.call_args is None:
raise AssertionError("Mandrill API was not called")
(args, kwargs) = self.mock_post.call_args
try:
post_data = kwargs.get('data', None) or args[1]
except IndexError:
raise AssertionError("requests.post was called without data")
return json.loads(post_data)

View File

@@ -0,0 +1,72 @@
import sys
from django.test import TestCase
from django.contrib.auth.models import User
from django.contrib import admin
from djrill.tests.mock_backend import DjrillBackendMockAPITestCase
def reset_admin_site():
"""Return the Django admin globals to their original state"""
admin.site = admin.AdminSite() # restore default
if 'djrill.admin' in sys.modules:
del sys.modules['djrill.admin'] # force autodiscover to re-import
class DjrillAdminTests(DjrillBackendMockAPITestCase):
"""Test the Djrill admin site"""
# These tests currently just verify that the admin site pages load
# without error -- they don't test any Mandrill-supplied content.
# (Future improvements could mock the Mandrill responses.)
# These urls set up the DjrillAdminSite as suggested in the readme
urls = 'djrill.tests.admin_urls'
@classmethod
def setUpClass(cls):
# Other test cases may muck with the Django admin site globals,
# so return it to the default state before loading test_admin_urls
reset_admin_site()
def setUp(self):
super(DjrillAdminTests, self).setUp()
# Must be authenticated staff to access admin site...
admin = User.objects.create_user('admin', 'admin@example.com', 'secret')
admin.is_staff = True
admin.save()
self.client.login(username='admin', password='secret')
def test_admin_senders(self):
response = self.client.get('/admin/djrill/senders/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Senders")
def test_admin_status(self):
response = self.client.get('/admin/djrill/status/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Status")
def test_admin_tags(self):
response = self.client.get('/admin/djrill/tags/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Tags")
def test_admin_urls(self):
response = self.client.get('/admin/djrill/urls/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "URLs")
def test_admin_index(self):
"""Make sure Djrill section is included in the admin index page"""
response = self.client.get('/admin/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Djrill")
class DjrillNoAdminTests(TestCase):
def test_admin_autodiscover_without_djrill(self):
"""Make sure autodiscover doesn't die without DjrillAdminSite"""
reset_admin_site()
admin.autodiscover() # test: this shouldn't error

View File

@@ -0,0 +1,89 @@
# Tests deprecated Djrill features
from django.test import TestCase
from djrill.mail import DjrillMessage
from djrill import MandrillAPIError, NotSupportedByMandrillError
class DjrillMessageTests(TestCase):
"""Test the DjrillMessage class (deprecated as of Djrill v0.2.0)
Maintained for compatibility with older code.
"""
def setUp(self):
self.subject = "Djrill baby djrill."
self.from_name = "Tarzan"
self.from_email = "test@example"
self.to = ["King Kong <kingkong@example.com>",
"Cheetah <cheetah@example.com", "bubbles@example.com"]
self.text_content = "Wonderful fallback text content."
self.html_content = "<h1>That's a nice HTML email right there.</h1>"
self.headers = {"Reply-To": "tarzan@example.com"}
self.tags = ["track", "this"]
def test_djrill_message_success(self):
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=self.tags, headers=self.headers,
from_name=self.from_name)
self.assertIsInstance(msg, DjrillMessage)
self.assertEqual(msg.body, self.text_content)
self.assertEqual(msg.recipients(), self.to)
self.assertEqual(msg.tags, self.tags)
self.assertEqual(msg.extra_headers, self.headers)
self.assertEqual(msg.from_name, self.from_name)
def test_djrill_message_html_success(self):
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=self.tags)
msg.attach_alternative(self.html_content, "text/html")
self.assertEqual(msg.alternatives[0][0], self.html_content)
def test_djrill_message_tag_failure(self):
with self.assertRaises(ValueError):
DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=["_fail"])
def test_djrill_message_tag_skip(self):
"""
Test that tags over 50 chars are not included in the tags list.
"""
tags = ["works", "awesomesauce",
"iwilltestmycodeiwilltestmycodeiwilltestmycodeiwilltestmycode"]
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=tags)
self.assertIsInstance(msg, DjrillMessage)
self.assertIn(tags[0], msg.tags)
self.assertIn(tags[1], msg.tags)
self.assertNotIn(tags[2], msg.tags)
def test_djrill_message_no_options(self):
"""DjrillMessage with only basic EmailMessage options should work"""
msg = DjrillMessage(self.subject, self.text_content,
self.from_email, self.to) # no Mandrill-specific options
self.assertIsInstance(msg, DjrillMessage)
self.assertEqual(msg.body, self.text_content)
self.assertEqual(msg.recipients(), self.to)
self.assertFalse(hasattr(msg, 'tags'))
self.assertFalse(hasattr(msg, 'from_name'))
self.assertFalse(hasattr(msg, 'preserve_recipients'))
class DjrillLegacyExceptionTests(TestCase):
def test_DjrillBackendHTTPError(self):
"""MandrillApiError was DjrillBackendHTTPError in 0.2.0"""
# ... and had to be imported from deep in the package:
from djrill.mail.backends.djrill import DjrillBackendHTTPError
ex = MandrillAPIError("testing")
self.assertIsInstance(ex, DjrillBackendHTTPError)
def test_NotSupportedByMandrillError(self):
"""Unsupported features used to just raise ValueError in 0.2.0"""
ex = NotSupportedByMandrillError("testing")
self.assertIsInstance(ex, ValueError)

View File

@@ -1,62 +1,21 @@
from mock import patch
import sys
from base64 import b64decode
from email.mime.base import MIMEBase
from django.conf import settings
from django.contrib import admin
from django.contrib.auth.models import User
from django.core import mail
from django.core.exceptions import ImproperlyConfigured
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"""
class MockResponse:
"""requests.post return value mock sufficient for DjrillBackend"""
def __init__(self, status_code=200, content="{}"):
self.status_code = status_code
self.content = content
def setUp(self):
self.patch = patch('requests.post')
self.mock_post = self.patch.start()
self.mock_post.return_value = self.MockResponse()
settings.MANDRILL_API_KEY = "FAKE_API_KEY_FOR_TESTING"
# Django TestCase sets up locmem EmailBackend; override it here
self.original_email_backend = settings.EMAIL_BACKEND
settings.EMAIL_BACKEND = "djrill.mail.backends.djrill.DjrillBackend"
def tearDown(self):
self.patch.stop()
settings.EMAIL_BACKEND = self.original_email_backend
def get_api_call_data(self):
"""Returns the data posted to the Mandrill API.
Fails test if API wasn't called.
"""
if self.mock_post.call_args is None:
raise AssertionError("Mandrill API was not called")
(args, kwargs) = self.mock_post.call_args
if 'data' not in kwargs:
raise AssertionError("requests.post was called without data kwarg "
"-- Maybe tests need to be updated for backend changes?")
return json.loads(kwargs['data'])
from djrill import MandrillAPIError, NotSupportedByMandrillError
from djrill.tests.mock_backend import DjrillBackendMockAPITestCase
class DjrillBackendTests(DjrillBackendMockAPITestCase):
"""Test Djrill's support for Django mail wrappers"""
"""Test Djrill backend support for Django mail wrappers"""
def test_send_mail(self):
mail.send_mail('Subject here', 'Here is the message.',
'from@example.com', ['to@example.com'], fail_silently=False)
self.assert_mandrill_called("/messages/send.json")
data = self.get_api_call_data()
self.assertEqual(data['message']['subject'], "Subject here")
self.assertEqual(data['message']['text'], "Here is the message.")
@@ -76,42 +35,52 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
(Test both sender and recipient addresses)
"""
mail.send_mail('Subject', 'Message', 'From Name <from@example.com>',
['Recipient #1 <to1@example.com>', 'to2@example.com'])
msg = mail.EmailMessage('Subject', 'Message',
'From Name <from@example.com>',
['Recipient #1 <to1@example.com>', 'to2@example.com'],
cc=['Carbon Copy <cc1@example.com>', 'cc2@example.com'],
bcc=['Blind Copy <bcc@example.com>'])
msg.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['from_name'], "From Name")
self.assertEqual(data['message']['from_email'], "from@example.com")
self.assertEqual(len(data['message']['to']), 2)
self.assertEqual(len(data['message']['to']), 4)
self.assertEqual(data['message']['to'][0]['name'], "Recipient #1")
self.assertEqual(data['message']['to'][0]['email'], "to1@example.com")
self.assertEqual(data['message']['to'][1]['name'], "")
self.assertEqual(data['message']['to'][1]['email'], "to2@example.com")
self.assertEqual(data['message']['to'][2]['name'], "Carbon Copy")
self.assertEqual(data['message']['to'][2]['email'], "cc1@example.com")
self.assertEqual(data['message']['to'][3]['name'], "")
self.assertEqual(data['message']['to'][3]['email'], "cc2@example.com")
# Mandrill only supports email, not name, for bcc:
self.assertEqual(data['message']['bcc_address'], "bcc@example.com")
def test_email_message(self):
email = mail.EmailMessage('Subject', 'Body goes here',
'from@example.com',
['to1@example.com', 'Also To <to2@example.com>'],
bcc=['bcc1@example.com', 'Also BCC <bcc2@example.com>'],
bcc=['bcc@example.com'],
cc=['cc1@example.com', 'Also CC <cc2@example.com>'],
headers={'Reply-To': 'another@example.com',
'X-MyHeader': 'my value'})
email.send()
self.assert_mandrill_called("/messages/send.json")
data = self.get_api_call_data()
self.assertEqual(data['message']['subject'], "Subject")
self.assertEqual(data['message']['text'], "Body goes here")
self.assertEqual(data['message']['from_email'], "from@example.com")
self.assertEqual(data['message']['headers'],
{ 'Reply-To': 'another@example.com', 'X-MyHeader': 'my value' })
# Mandrill doesn't have a notion of cc, and only allows a single bcc.
# Djrill just treats cc and bcc as though they were "to" addresses,
# Mandrill doesn't have a notion of cc.
# Djrill just treats cc as additional "to" addresses,
# which may or may not be what you want.
self.assertEqual(len(data['message']['to']), 6)
self.assertEqual(len(data['message']['to']), 4)
self.assertEqual(data['message']['to'][0]['email'], "to1@example.com")
self.assertEqual(data['message']['to'][1]['email'], "to2@example.com")
self.assertEqual(data['message']['to'][2]['email'], "cc1@example.com")
self.assertEqual(data['message']['to'][3]['email'], "cc2@example.com")
self.assertEqual(data['message']['to'][4]['email'], "bcc1@example.com")
self.assertEqual(data['message']['to'][5]['email'], "bcc2@example.com")
self.assertEqual(data['message']['bcc_address'], "bcc@example.com")
def test_html_message(self):
text_content = 'This is an important message.'
@@ -120,15 +89,55 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
'from@example.com', ['to@example.com'])
email.attach_alternative(html_content, "text/html")
email.send()
self.assert_mandrill_called("/messages/send.json")
data = self.get_api_call_data()
self.assertEqual(data['message']['text'], text_content)
self.assertEqual(data['message']['html'], html_content)
# Don't accidentally send the html part as an attachment:
self.assertFalse('attachments' in data['message'])
def test_attachments(self):
email = mail.EmailMessage('Subject', 'Body goes here',
'from@example.com', ['to1@example.com'])
text_content = "* Item one\n* Item two\n* Item three"
email.attach(filename="test.txt", content=text_content,
mimetype="text/plain")
# Should guess mimetype if not provided...
png_content = b"PNG\xb4 pretend this is the contents of a png file"
email.attach(filename="test.png", content=png_content)
# Should work with a MIMEBase object (also tests no filename)...
pdf_content = b"PDF\xb4 pretend this is valid pdf data"
mimeattachment = MIMEBase('application', 'pdf')
mimeattachment.set_payload(pdf_content)
email.attach(mimeattachment)
email.send()
def decode_att(att):
return b64decode(att.encode('ascii'))
data = self.get_api_call_data()
attachments = data['message']['attachments']
self.assertEqual(len(attachments), 3)
self.assertEqual(attachments[0]["type"], "text/plain")
self.assertEqual(attachments[0]["name"], "test.txt")
self.assertEqual(decode_att(attachments[0]["content"]).decode('ascii'),
text_content)
self.assertEqual(attachments[1]["type"], "image/png") # inferred
self.assertEqual(attachments[1]["name"], "test.png")
self.assertEqual(decode_att(attachments[1]["content"]), png_content)
self.assertEqual(attachments[2]["type"], "application/pdf")
self.assertEqual(attachments[2]["name"], "") # none
self.assertEqual(decode_att(attachments[2]["content"]), pdf_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):
with self.assertRaises(NotSupportedByMandrillError):
email.send()
# Make sure fail_silently is respected
@@ -146,14 +155,14 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
'from@example.com', ['to@example.com'])
email.attach_alternative("<p>First html is OK</p>", "text/html")
email.attach_alternative("<p>But not second html</p>", "text/html")
with self.assertRaises(ValueError):
with self.assertRaises(NotSupportedByMandrillError):
email.send()
# Only html alternatives allowed
email = mail.EmailMultiAlternatives('Subject', 'Body',
'from@example.com', ['to@example.com'])
email.attach_alternative("{'not': 'allowed'}", "application/json")
with self.assertRaises(ValueError):
with self.assertRaises(NotSupportedByMandrillError):
email.send()
# Make sure fail_silently is respected
@@ -165,9 +174,35 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
msg="Mandrill API should not be called when send fails silently")
self.assertEqual(sent, 0)
def test_attachment_errors(self):
# Mandrill silently strips attachments that aren't text/*, image/*,
# or application/pdf. We want to alert the Djrill user:
with self.assertRaises(NotSupportedByMandrillError):
msg = mail.EmailMessage('Subject', 'Body',
'from@example.com', ['to@example.com'])
# This is the default mimetype, but won't work with Mandrill:
msg.attach(content="test", mimetype="application/octet-stream")
msg.send()
with self.assertRaises(NotSupportedByMandrillError):
msg = mail.EmailMessage('Subject', 'Body',
'from@example.com', ['to@example.com'])
# Can't send Office docs, either:
msg.attach(filename="presentation.ppt", content="test",
mimetype="application/vnd.ms-powerpoint")
msg.send()
def test_bcc_errors(self):
# Mandrill only allows a single bcc address
with self.assertRaises(NotSupportedByMandrillError):
msg = mail.EmailMessage('Subject', 'Body',
'from@example.com', ['to@example.com'],
bcc=['bcc1@example.com>', 'bcc2@example.com'])
msg.send()
def test_mandrill_api_failure(self):
self.mock_post.return_value = self.MockResponse(status_code=400)
with self.assertRaises(DjrillBackendHTTPError):
with self.assertRaises(MandrillAPIError):
sent = mail.send_mail('Subject', 'Body', 'from@example.com',
['to@example.com'])
self.assertEqual(sent, 0)
@@ -279,8 +314,10 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
that your Mandrill account settings apply by default.
"""
self.message.send()
self.assert_mandrill_called("/messages/send.json")
data = self.get_api_call_data()
self.assertFalse('from_name' in data['message'])
self.assertFalse('bcc_address' in data['message'])
self.assertFalse('track_opens' in data['message'])
self.assertFalse('track_clicks' in data['message'])
self.assertFalse('auto_text' in data['message'])
@@ -295,129 +332,3 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
self.assertFalse('recipient_metadata' in data['message'])
def reset_admin_site():
"""Return the Django admin globals to their original state"""
admin.site = admin.AdminSite() # restore default
if 'djrill.admin' in sys.modules:
del sys.modules['djrill.admin'] # force autodiscover to re-import
class DjrillAdminTests(DjrillBackendMockAPITestCase):
"""Test the Djrill admin site"""
# These tests currently just verify that the admin site pages load
# without error -- they don't test any Mandrill-supplied content.
# (Future improvements could mock the Mandrill responses.)
# These urls set up the DjrillAdminSite as suggested in the readme
urls = 'djrill.test_admin_urls'
@classmethod
def setUpClass(cls):
# Other test cases may muck with the Django admin site globals,
# so return it to the default state before loading test_admin_urls
reset_admin_site()
def setUp(self):
super(DjrillAdminTests, self).setUp()
# Must be authenticated staff to access admin site...
admin = User.objects.create_user('admin', 'admin@example.com', 'secret')
admin.is_staff = True
admin.save()
self.client.login(username='admin', password='secret')
def test_admin_senders(self):
response = self.client.get('/admin/djrill/senders/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Senders")
def test_admin_status(self):
response = self.client.get('/admin/djrill/status/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Status")
def test_admin_tags(self):
response = self.client.get('/admin/djrill/tags/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Tags")
def test_admin_urls(self):
response = self.client.get('/admin/djrill/urls/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "URLs")
def test_admin_index(self):
"""Make sure Djrill section is included in the admin index page"""
response = self.client.get('/admin/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Djrill")
class DjrillNoAdminTests(TestCase):
def test_admin_autodiscover_without_djrill(self):
"""Make sure autodiscover doesn't die without DjrillAdminSite"""
reset_admin_site()
admin.autodiscover() # test: this shouldn't error
class DjrillMessageTests(TestCase):
def setUp(self):
self.subject = "Djrill baby djrill."
self.from_name = "Tarzan"
self.from_email = "test@example"
self.to = ["King Kong <kingkong@example.com>",
"Cheetah <cheetah@example.com", "bubbles@example.com"]
self.text_content = "Wonderful fallback text content."
self.html_content = "<h1>That's a nice HTML email right there.</h1>"
self.headers = {"Reply-To": "tarzan@example.com"}
self.tags = ["track", "this"]
def test_djrill_message_success(self):
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=self.tags, headers=self.headers,
from_name=self.from_name)
self.assertIsInstance(msg, DjrillMessage)
self.assertEqual(msg.body, self.text_content)
self.assertEqual(msg.recipients(), self.to)
self.assertEqual(msg.tags, self.tags)
self.assertEqual(msg.extra_headers, self.headers)
self.assertEqual(msg.from_name, self.from_name)
def test_djrill_message_html_success(self):
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=self.tags)
msg.attach_alternative(self.html_content, "text/html")
self.assertEqual(msg.alternatives[0][0], self.html_content)
def test_djrill_message_tag_failure(self):
with self.assertRaises(ValueError):
DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=["_fail"])
def test_djrill_message_tag_skip(self):
"""
Test that tags over 50 chars are not included in the tags list.
"""
tags = ["works", "awesomesauce",
"iwilltestmycodeiwilltestmycodeiwilltestmycodeiwilltestmycode"]
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=tags)
self.assertIsInstance(msg, DjrillMessage)
self.assertIn(tags[0], msg.tags)
self.assertIn(tags[1], msg.tags)
self.assertNotIn(tags[2], msg.tags)
def test_djrill_message_no_options(self):
"""DjrillMessage with only basic EmailMessage options should work"""
msg = DjrillMessage(self.subject, self.text_content,
self.from_email, self.to) # no Mandrill-specific options
self.assertIsInstance(msg, DjrillMessage)
self.assertEqual(msg.body, self.text_content)
self.assertEqual(msg.recipients(), self.to)
self.assertFalse(hasattr(msg, 'tags'))
self.assertFalse(hasattr(msg, 'from_name'))
self.assertFalse(hasattr(msg, 'preserve_recipients'))

View File

@@ -0,0 +1,50 @@
from django.core import mail
from djrill.tests.mock_backend import DjrillBackendMockAPITestCase
class DjrillMandrillSendTemplateTests(DjrillBackendMockAPITestCase):
"""Test Djrill backend support for Mandrill send-template features"""
def test_send_template(self):
msg = mail.EmailMessage('Subject', 'Text Body',
'from@example.com', ['to@example.com'])
msg.template_name = "PERSONALIZED_SPECIALS"
msg.template_content = {
'HEADLINE': "<h1>Specials Just For *|FNAME|*</h1>",
'OFFER_BLOCK': "<p><em>Half off</em> all fruit</p>"
}
msg.send()
self.assert_mandrill_called("/messages/send-template.json")
data = self.get_api_call_data()
self.assertEqual(data['template_name'], "PERSONALIZED_SPECIALS")
# Djrill expands simple python dicts into the more-verbose name/value
# structures the Mandrill API uses
self.assertEqual(data['template_content'],
[ {'name': "HEADLINE",
'value': "<h1>Specials Just For *|FNAME|*</h1>"},
{'name': "OFFER_BLOCK",
'value': "<p><em>Half off</em> all fruit</p>"} ]
)
def test_no_template_content(self):
# Just a template, without any template_content to be merged
msg = mail.EmailMessage('Subject', 'Text Body',
'from@example.com', ['to@example.com'])
msg.template_name = "WELCOME_MESSAGE"
msg.send()
self.assert_mandrill_called("/messages/send-template.json")
data = self.get_api_call_data()
self.assertEqual(data['template_name'], "WELCOME_MESSAGE")
self.assertFalse('template_content' in data)
def test_non_template_send(self):
# Make sure the non-template case still uses /messages/send.json
msg = mail.EmailMessage('Subject', 'Text Body',
'from@example.com', ['to@example.com'])
msg.send()
self.assert_mandrill_called("/messages/send.json")
data = self.get_api_call_data()
self.assertFalse('template_name' in data)
self.assertFalse('template_content' in data)
self.assertFalse('async' in data)

View File

@@ -5,7 +5,7 @@ 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
from djrill import MANDRILL_API_URL
import requests
@@ -25,7 +25,7 @@ class DjrillApiMixin(object):
"""
def __init__(self):
self.api_key = getattr(settings, "MANDRILL_API_KEY", None)
self.api_url = getattr(settings, "MANDRILL_API_URL", MANDRILL_API_URL)
self.api_url = MANDRILL_API_URL
if not self.api_key:
raise ImproperlyConfigured("You have not set your mandrill api key "
@@ -52,8 +52,8 @@ class DjrillApiJsonObjectsMixin(object):
def get_api_uri(self):
if self.api_uri is None:
raise NotImplementedError(u"%(cls)s is missing an api_uri. Define "
u"%(cls)s.api_uri or override %(cls)s.get_api_uri()." % {
raise NotImplementedError("%(cls)s is missing an api_uri. Define "
"%(cls)s.api_uri or override %(cls)s.get_api_uri()." % {
"cls": self.__class__.__name__
})

View File

@@ -1,14 +1,19 @@
from setuptools import setup
with open('LICENSE') as file:
license_text = file.read()
with open('README.rst') as file:
long_description = file.read()
setup(
name="djrill",
version="0.2.0",
version="0.3.0",
description='Django email backend for Mandrill.',
keywords="django, mailchimp, mandrill, email, email backend",
author="Kenneth Love <kenneth@brack3t.com>, Chris Jones <chris@brack3t.com>",
author_email="kenneth@brack3t.com",
url="https://github.com/brack3t/Djrill/",
license="BSD",
license=license_text,
packages=["djrill"],
zip_safe=False,
install_requires=["requests", "django"],
@@ -22,14 +27,5 @@ setup(
"Framework :: Django",
"Environment :: Web Environment",
],
long_description="""\
Djrill is an email backend for Django users who want to take advantage of the
`Mandrill <http://mandrill.com>`_ transactional email service from MailChimp.
In general, Djrill "just works" with Django's built-in ``django.core.mail``
package. You can also take advantage of Mandrill-specific features like tags,
metadata, and tracking. An optional Django admin interface is included.
Full details are on the `project page <https://github.com/brack3t/Djrill>`_.
""",
long_description=long_description,
)