diff --git a/.travis.yml b/.travis.yml index 1730f7b..d62303d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/AUTHORS.txt b/AUTHORS.txt index adf7072..f3734b6 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -8,3 +8,4 @@ ArnaudF Théo Crevon Rafael E. Belliard Jared Morse +peillis diff --git a/README.rst b/README.rst index 1a2553c..c60108f 100644 --- a/README.rst +++ b/README.rst @@ -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 "). -* 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 "). +* **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': "track it" } + 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 diff --git a/djrill/__init__.py b/djrill/__init__.py index 1e21173..e19f8f7 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -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('', diff --git a/djrill/exceptions.py b/djrill/exceptions.py new file mode 100644 index 0000000..4c986ff --- /dev/null +++ b/djrill/exceptions.py @@ -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.) + + """ diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index fb63822..9114ca9 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -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,11 +120,21 @@ 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 " - "only allows Reply-To and X-* headers" % k) + raise NotSupportedByMandrillError( + "Invalid message header '%s' - Mandrill " + "only allows Reply-To and X-* headers" % k) msg_dict["headers"] = message.extra_headers return msg_dict @@ -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'. " - "Mandrill only accepts plain text and html emails." - % mimetype) + 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'), + } + diff --git a/djrill/tests/__init__.py b/djrill/tests/__init__.py new file mode 100644 index 0000000..1dd0c92 --- /dev/null +++ b/djrill/tests/__init__.py @@ -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 * diff --git a/djrill/test_admin_urls.py b/djrill/tests/admin_urls.py similarity index 100% rename from djrill/test_admin_urls.py rename to djrill/tests/admin_urls.py diff --git a/djrill/tests/mock_backend.py b/djrill/tests/mock_backend.py new file mode 100644 index 0000000..5003250 --- /dev/null +++ b/djrill/tests/mock_backend.py @@ -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) + + diff --git a/djrill/tests/test_admin.py b/djrill/tests/test_admin.py new file mode 100644 index 0000000..d27f871 --- /dev/null +++ b/djrill/tests/test_admin.py @@ -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 diff --git a/djrill/tests/test_legacy.py b/djrill/tests/test_legacy.py new file mode 100644 index 0000000..64a12f7 --- /dev/null +++ b/djrill/tests/test_legacy.py @@ -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 ", + "Cheetah ', - ['Recipient #1 ', 'to2@example.com']) + msg = mail.EmailMessage('Subject', 'Message', + 'From Name ', + ['Recipient #1 ', 'to2@example.com'], + cc=['Carbon Copy ', 'cc2@example.com'], + bcc=['Blind Copy ']) + 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 '], - bcc=['bcc1@example.com', 'Also BCC '], + bcc=['bcc@example.com'], cc=['cc1@example.com', 'Also CC '], 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("

First html is OK

", "text/html") email.attach_alternative("

But not second html

", "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) @@ -222,7 +257,7 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): "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'], @@ -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 ", - "Cheetah Specials Just For *|FNAME|*", + 'OFFER_BLOCK': "

Half off all fruit

" + } + 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': "

Specials Just For *|FNAME|*

"}, + {'name': "OFFER_BLOCK", + 'value': "

Half off all fruit

"} ] + ) + + 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) diff --git a/djrill/views.py b/djrill/views.py index 4f4c5da..5264119 100644 --- a/djrill/views.py +++ b/djrill/views.py @@ -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__ }) diff --git a/setup.py b/setup.py index da91696..9a3fb5a 100644 --- a/setup.py +++ b/setup.py @@ -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 , Chris Jones ", 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 `_ 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 `_. -""", + long_description=long_description, )