From 046987a93465ae1804d43145833f7840b5d0d73d Mon Sep 17 00:00:00 2001 From: peillis Date: Mon, 24 Dec 2012 13:36:37 +0100 Subject: [PATCH 01/23] adding the send-template call --- djrill/mail/backends/djrill.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index fb63822..840455e 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -45,6 +45,7 @@ class DjrillBackend(BaseEmailBackend): "in the settings.py file.") self.api_action = self.api_url + "/messages/send.json" + self.template_api_action = self.api_url + "/messages/send-template.json" def send_messages(self, email_messages): if not email_messages: @@ -73,10 +74,23 @@ class DjrillBackend(BaseEmailBackend): raise return False - djrill_it = requests.post(self.api_action, data=json.dumps({ - "key": self.api_key, - "message": msg_dict - })) + # check if template is set in message to send it via + # api url: /messages/send-template.json + if hasattr(message, 'template_name'): + template_content = getattr(message, 'template_content', + None) + djrill_it = requests.post(self.template_api_action, + data=json.dumps({ + "key": self.api_key, + "template_name": message.template_name, + "template_content": template_content, + "message": msg_dict + })) + else: + djrill_it = requests.post(self.api_action, data=json.dumps({ + "key": self.api_key, + "message": msg_dict + })) if djrill_it.status_code != 200: if not self.fail_silently: From 8a0fccdf53e4598c27589db672514c17bcfde1da Mon Sep 17 00:00:00 2001 From: medmunds Date: Thu, 3 Jan 2013 10:16:19 -0800 Subject: [PATCH 02/23] setup: pull long_description and license from README and LICENSE files. Ensures info on PyPI matches version being distributed there. (Avoids problem where docs on github are ahead of published version on PyPI.) --- setup.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/setup.py b/setup.py index da91696..1ff3eb7 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( author="Kenneth Love , Chris Jones ", author_email="kenneth@brack3t.com", url="https://github.com/brack3t/Djrill/", - license="BSD", + license=open('LICENSE').read(), packages=["djrill"], zip_safe=False, install_requires=["requests", "django"], @@ -22,14 +22,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=open('README.rst').read(), ) From d1b0e0a574177ea5aca6b10e904797554f505fea Mon Sep 17 00:00:00 2001 From: medmunds Date: Thu, 3 Jan 2013 10:51:07 -0800 Subject: [PATCH 03/23] Tests: break apart tests.py into tests directory --- README.rst | 6 +- djrill/tests/__init__.py | 3 + .../admin_urls.py} | 0 djrill/tests/mock_backend.py | 44 +++++ djrill/tests/test_admin.py | 72 +++++++ djrill/tests/test_legacy.py | 74 ++++++++ .../{tests.py => tests/test_mandrill_send.py} | 177 +----------------- 7 files changed, 199 insertions(+), 177 deletions(-) create mode 100644 djrill/tests/__init__.py rename djrill/{test_admin_urls.py => tests/admin_urls.py} (100%) create mode 100644 djrill/tests/mock_backend.py create mode 100644 djrill/tests/test_admin.py create mode 100644 djrill/tests/test_legacy.py rename djrill/{tests.py => tests/test_mandrill_send.py} (63%) diff --git a/README.rst b/README.rst index 1a2553c..c21a44d 100644 --- a/README.rst +++ b/README.rst @@ -161,7 +161,7 @@ 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. Testing @@ -180,11 +180,11 @@ 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 Release Notes diff --git a/djrill/tests/__init__.py b/djrill/tests/__init__.py new file mode 100644 index 0000000..82248c1 --- /dev/null +++ b/djrill/tests/__init__.py @@ -0,0 +1,3 @@ +from test_admin import * +from test_legacy import * +from test_mandrill_send 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..3e4546b --- /dev/null +++ b/djrill/tests/mock_backend.py @@ -0,0 +1,44 @@ +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') + 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']) + + 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..c8b39c6 --- /dev/null +++ b/djrill/tests/test_legacy.py @@ -0,0 +1,74 @@ +# Tests deprecated Djrill features + +from django.test import TestCase + +from djrill.mail import DjrillMessage + + +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 ", - "Cheetah Date: Thu, 3 Jan 2013 10:51:57 -0800 Subject: [PATCH 04/23] readme: add info on contributing --- README.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.rst b/README.rst index c21a44d..0e0c8da 100644 --- a/README.rst +++ b/README.rst @@ -187,6 +187,18 @@ or:: 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 ------------- From 5b4f4c12cba1cfd9b1a9da085fc71c1790cd71f5 Mon Sep 17 00:00:00 2001 From: medmunds Date: Thu, 3 Jan 2013 13:52:41 -0800 Subject: [PATCH 05/23] Support sending attachments --- README.rst | 3 ++- djrill/mail/backends/djrill.py | 42 +++++++++++++++++++++++++++++- djrill/tests/test_mandrill_send.py | 37 ++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 0e0c8da..e76dcfd 100644 --- a/README.rst +++ b/README.rst @@ -122,7 +122,8 @@ Djrill supports most of the functionality of Django's `EmailMessage`_ and 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 attempts to include a message's attachments, though Mandrill may place + some restrictions on allowable attachment types. (See the Mandrill docs.) * 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`` diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index fb63822..e82bd69 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -1,10 +1,13 @@ 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 +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. @@ -68,6 +71,7 @@ class DjrillBackend(BaseEmailBackend): self._add_mandrill_options(message, msg_dict) if getattr(message, 'alternatives', None): self._add_alternatives(message, msg_dict) + self._add_attachments(message, msg_dict) except ValueError: if not self.fail_silently: raise @@ -186,3 +190,39 @@ class DjrillBackend(BaseEmailBackend): % mimetype) msg_dict['html'] = content + + def _add_attachments(self, message, msg_dict): + """Extend msg_dict to include any attachments in message""" + if message.attachments: + attachments = [ + self._make_mandrill_attachment(attachment) + for attachment in message.attachments + ] + if len(attachments) > 0: + msg_dict['attachments'] = attachments + + def _make_mandrill_attachment(self, attachment): + """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 + + return { + 'type': mimetype, + 'name': filename or "", + 'content': b64encode(content), + } + diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 4b0da6e..9bce8be 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -1,3 +1,6 @@ +from base64 import b64encode +from email.mime.base import MIMEBase + from django.conf import settings from django.core import mail from django.core.exceptions import ImproperlyConfigured @@ -78,6 +81,40 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): 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']) + + content1 = "* Item one\n* Item two\n* Item three" + email.attach(filename="test.txt", content=content1, + mimetype="text/plain") + + # Should guess mimetype if not provided... + content2 = "PNG* pretend this is the contents of a png file" + email.attach(filename="test.png", content=content2) + + # Should work with a MIMEBase object (also tests no filename)... + content3 = "PDF* pretend this is valid pdf data" + mimeattachment = MIMEBase('application', 'pdf') + mimeattachment.set_payload(content3) + email.attach(mimeattachment) + + email.send() + 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(attachments[0]["content"], b64encode(content1)) + self.assertEqual(attachments[1]["type"], "image/png") # inferred + self.assertEqual(attachments[1]["name"], "test.png") + self.assertEqual(attachments[1]["content"], b64encode(content2)) + self.assertEqual(attachments[2]["type"], "application/pdf") + self.assertEqual(attachments[2]["name"], "") # none + self.assertEqual(attachments[2]["content"], b64encode(content3)) def test_extra_header_errors(self): email = mail.EmailMessage('Subject', 'Body', 'from@example.com', From 7eef68067d5070d14656d1ee88ea006e04d64b80 Mon Sep 17 00:00:00 2001 From: medmunds Date: Thu, 3 Jan 2013 15:10:28 -0800 Subject: [PATCH 06/23] Readme: Note Mandrill silently filters unsupported attachment types --- README.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index e76dcfd..23234f5 100644 --- a/README.rst +++ b/README.rst @@ -122,8 +122,10 @@ Djrill supports most of the functionality of Django's `EmailMessage`_ and 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 attempts to include a message's attachments, though Mandrill may place - some restrictions on allowable attachment types. (See the Mandrill docs.) +* Djrill attempts to include a message's attachments, but Mandrill will + (silently) ignore any attachment types it doesn't allow. According to + Mandrill's docs, attachments are only allowed with the mimetypes "text/*", + "image/*", or "application/pdf". * 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`` From d0236dd5aa8cc74b43c97f12bc36ef58479d4871 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Thu, 3 Jan 2013 15:16:16 -0800 Subject: [PATCH 07/23] (Readme rst * escapes) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 23234f5..29c2c92 100644 --- a/README.rst +++ b/README.rst @@ -124,8 +124,8 @@ Djrill supports most of the functionality of Django's `EmailMessage`_ and non-html alternatives.) * Djrill attempts to include a message's attachments, but Mandrill will (silently) ignore any attachment types it doesn't allow. According to - Mandrill's docs, attachments are only allowed with the mimetypes "text/*", - "image/*", or "application/pdf". + Mandrill's docs, attachments are only allowed with the mimetypes "text/\*", + "image/\*", or "application/pdf". * 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`` From 207e94e6d0cd1ee93567819902ff4fd5acdc4f99 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 11 Jan 2013 12:44:06 -0800 Subject: [PATCH 08/23] Tests: add ability to check which Mandrill API endpoint was used. Add DjrillBackendMockAPITestCase.assert_mandrill_called; use it in representative backend test cases. (Also make get_api_call_data work with various ways of calling requests.post.) --- djrill/tests/mock_backend.py | 30 +++++++++++++++++++++++++----- djrill/tests/test_mandrill_send.py | 4 ++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/djrill/tests/mock_backend.py b/djrill/tests/mock_backend.py index 3e4546b..5003250 100644 --- a/djrill/tests/mock_backend.py +++ b/djrill/tests/mock_backend.py @@ -14,7 +14,7 @@ class DjrillBackendMockAPITestCase(TestCase): self.content = content def setUp(self): - self.patch = patch('requests.post') + self.patch = patch('requests.post', autospec=True) self.mock_post = self.patch.start() self.mock_post.return_value = self.MockResponse() @@ -28,6 +28,25 @@ class DjrillBackendMockAPITestCase(TestCase): 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. @@ -36,9 +55,10 @@ class DjrillBackendMockAPITestCase(TestCase): 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']) + 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_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 9bce8be..96c4231 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -15,6 +15,7 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): 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.") @@ -54,6 +55,7 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): 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") @@ -78,6 +80,7 @@ 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) @@ -271,6 +274,7 @@ 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('track_opens' in data['message']) From 4be12952a315ca8afb79e5deb6c5dbade3c887e9 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 11 Jan 2013 13:28:49 -0800 Subject: [PATCH 09/23] Add send-template tests (and fixes). Add test cases for send-template. Expand template_content dict into Mandrill's name/value array. Don't send template_content as "None" if missing. Clean up some variable names in the backend. --- djrill/mail/backends/djrill.py | 37 ++++++++------- djrill/tests/__init__.py | 1 + djrill/tests/test_mandrill_send_template.py | 50 +++++++++++++++++++++ 3 files changed, 69 insertions(+), 19 deletions(-) create mode 100644 djrill/tests/test_mandrill_send_template.py diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index de12329..ac210ba 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -47,8 +47,8 @@ class DjrillBackend(BaseEmailBackend): 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.template_api_action = self.api_url + "/messages/send-template.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: @@ -78,29 +78,28 @@ class DjrillBackend(BaseEmailBackend): raise return False + api_url = self.api_send + api_params = { + "key": self.api_key, + "message": msg_dict + } + # check if template is set in message to send it via # api url: /messages/send-template.json if hasattr(message, 'template_name'): - template_content = getattr(message, 'template_content', - None) - djrill_it = requests.post(self.template_api_action, - data=json.dumps({ - "key": self.api_key, - "template_name": message.template_name, - "template_content": template_content, - "message": msg_dict - })) - else: - djrill_it = requests.post(self.api_action, data=json.dumps({ - "key": self.api_key, - "message": msg_dict - })) + 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) - if djrill_it.status_code != 200: + 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, + 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 diff --git a/djrill/tests/__init__.py b/djrill/tests/__init__.py index 82248c1..767ad30 100644 --- a/djrill/tests/__init__.py +++ b/djrill/tests/__init__.py @@ -1,3 +1,4 @@ from test_admin import * from test_legacy import * from test_mandrill_send import * +from test_mandrill_send_template import * diff --git a/djrill/tests/test_mandrill_send_template.py b/djrill/tests/test_mandrill_send_template.py new file mode 100644 index 0000000..4278907 --- /dev/null +++ b/djrill/tests/test_mandrill_send_template.py @@ -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': "

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) From 0826e2b7b0f38abdd1e78517b05dc98dcdeeb201 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 11 Jan 2013 14:51:37 -0800 Subject: [PATCH 10/23] Readme: docs for send-template (and break up the lengthy 'usage' section) --- README.rst | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 29c2c92..47f3381 100644 --- a/README.rst +++ b/README.rst @@ -83,7 +83,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: @@ -111,6 +112,9 @@ 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). +Django EmailMessage Support +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + Djrill supports most of the functionality of Django's `EmailMessage`_ and `EmailMultiAlternatives`_ classes. Some limitations: @@ -136,7 +140,10 @@ Djrill supports most of the functionality of Django's `EmailMessage`_ and * The ``from_email`` must be in one of the approved sending domains in your Mandrill account. -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 @@ -166,6 +173,33 @@ 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/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.) Testing ------- @@ -230,5 +264,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 From fac078ee18ea1b11f55265dd02af18f39dd5bcb4 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 11 Jan 2013 15:23:08 -0800 Subject: [PATCH 11/23] Readme: Explain using multiple email backends --- README.rst | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 47f3381..c84f8dd 100644 --- a/README.rst +++ b/README.rst @@ -74,7 +74,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 @@ -201,6 +203,32 @@ 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 ------- From 18d27fdb21a2b664d75559e132f0e5d2dbe21ed2 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 11 Jan 2013 16:59:42 -0800 Subject: [PATCH 12/23] Exception cleanup Introduce djrill.NotSupportedByMandrillError for unsupported functionality (previously used generic ValueError). Change to djrill.MandrillAPIError, derived from requests.HTTPError, for API error responses (previously used djrill.mail.backends.djrill.DjrillBackendHTTPError -- retained as equivalent for backwards compatibility). --- README.rst | 20 +++++++++----- djrill/__init__.py | 2 ++ djrill/exceptions.py | 33 +++++++++++++++++++++++ djrill/mail/backends/djrill.py | 43 +++++++++++++----------------- djrill/tests/test_legacy.py | 15 +++++++++++ djrill/tests/test_mandrill_send.py | 10 +++---- 6 files changed, 86 insertions(+), 37 deletions(-) create mode 100644 djrill/exceptions.py diff --git a/README.rst b/README.rst index c84f8dd..c9113b6 100644 --- a/README.rst +++ b/README.rst @@ -110,9 +110,14 @@ 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -122,12 +127,13 @@ Djrill supports most of the functionality of Django's `EmailMessage`_ and * 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.NotSupportedByMandrillError`` 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.) + raise a ``djrill.NotSupportedByMandrillError`` exception when you attempt to + send the message. (Mandrill doesn't support sending multiple html alternative + parts, or any non-html alternatives.) * Djrill attempts to include a message's attachments, but Mandrill will (silently) ignore any attachment types it doesn't allow. According to Mandrill's docs, attachments are only allowed with the mimetypes "text/\*", diff --git a/djrill/__init__.py b/djrill/__init__.py index 1e21173..f74604c 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -4,6 +4,7 @@ from django.utils.text import capfirst VERSION = (0, 2, 0) __version__ = '.'.join([str(x) for x in VERSION]) +from exceptions import MandrillAPIError, NotSupportedByMandrillError class DjrillAdminSite(AdminSite): index_template = "djrill/index.html" @@ -31,6 +32,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 ac210ba..73b8a42 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -4,6 +4,10 @@ from django.core.mail.backends.base import BaseEmailBackend 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 MandrillAPIError, NotSupportedByMandrillError +from ... import MandrillAPIError, NotSupportedByMandrillError + from base64 import b64encode from email.mime.base import MIMEBase from email.utils import parseaddr @@ -14,21 +18,7 @@ import requests # 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): """ @@ -73,7 +63,7 @@ class DjrillBackend(BaseEmailBackend): if getattr(message, 'alternatives', None): self._add_alternatives(message, msg_dict) self._add_attachments(message, msg_dict) - except ValueError: + except NotSupportedByMandrillError: if not self.fail_silently: raise return False @@ -97,7 +87,7 @@ class DjrillBackend(BaseEmailBackend): if response.status_code != 200: if not self.fail_silently: - raise DjrillBackendHTTPError( + raise MandrillAPIError( status_code=response.status_code, response=response, log_message="Failed to send a message to %s, from %s" % @@ -112,8 +102,9 @@ 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) @@ -135,8 +126,9 @@ class DjrillBackend(BaseEmailBackend): 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 @@ -192,15 +184,16 @@ 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 diff --git a/djrill/tests/test_legacy.py b/djrill/tests/test_legacy.py index c8b39c6..64a12f7 100644 --- a/djrill/tests/test_legacy.py +++ b/djrill/tests/test_legacy.py @@ -3,6 +3,7 @@ from django.test import TestCase from djrill.mail import DjrillMessage +from djrill import MandrillAPIError, NotSupportedByMandrillError class DjrillMessageTests(TestCase): @@ -72,3 +73,17 @@ class DjrillMessageTests(TestCase): 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) diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 96c4231..2565ac1 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -5,7 +5,7 @@ from django.conf import settings from django.core import mail from django.core.exceptions import ImproperlyConfigured -from djrill.mail.backends.djrill import DjrillBackendHTTPError +from djrill import MandrillAPIError, NotSupportedByMandrillError from djrill.tests.mock_backend import DjrillBackendMockAPITestCase @@ -123,7 +123,7 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): 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 @@ -141,14 +141,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 @@ -162,7 +162,7 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): 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) From ad4b9f38ff25588c6b2f337be608ef14dca471e3 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 11 Jan 2013 17:26:09 -0800 Subject: [PATCH 13/23] Raise NotSupportedByMandrillError for unsupported attachment mimetypes. --- README.rst | 8 ++++---- djrill/mail/backends/djrill.py | 12 ++++++++++++ djrill/tests/test_mandrill_send.py | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index c9113b6..99c5c6a 100644 --- a/README.rst +++ b/README.rst @@ -134,10 +134,10 @@ Djrill supports most of the functionality of Django's `EmailMessage`_ and raise a ``djrill.NotSupportedByMandrillError`` exception when you attempt to send the message. (Mandrill doesn't support sending multiple html alternative parts, or any non-html alternatives.) -* Djrill attempts to include a message's attachments, but Mandrill will - (silently) ignore any attachment types it doesn't allow. According to - Mandrill's docs, attachments are only allowed with the mimetypes "text/\*", - "image/\*", or "application/pdf". +* 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 a ``djrill.NotSupportedByMandrillError`` + exception when you attempt to send the 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`` diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index 73b8a42..98466a3 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -226,6 +226,18 @@ class DjrillBackend(BaseEmailBackend): 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) + return { 'type': mimetype, 'name': filename or "", diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 2565ac1..2a86147 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -160,6 +160,24 @@ 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_mandrill_api_failure(self): self.mock_post.return_value = self.MockResponse(status_code=400) with self.assertRaises(MandrillAPIError): From 8f9afdff7ea5c7beb1e6e04f5d877320585c8151 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 11 Jan 2013 17:34:17 -0800 Subject: [PATCH 14/23] Move MANDRILL_API_URL to package root (out of backend) --- djrill/__init__.py | 6 ++++++ djrill/mail/backends/djrill.py | 10 +++------- djrill/views.py | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/djrill/__init__.py b/djrill/__init__.py index f74604c..0b67a0d 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -1,9 +1,15 @@ +from django.conf import settings from django.contrib.admin.sites import AdminSite from django.utils.text import capfirst VERSION = (0, 2, 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") + from exceptions import MandrillAPIError, NotSupportedByMandrillError class DjrillAdminSite(AdminSite): diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index 98466a3..b6436de 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -5,8 +5,8 @@ from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_T from django.utils import simplejson as json # Oops: this file has the same name as our app, and cannot be renamed. -#from djrill import MandrillAPIError, NotSupportedByMandrillError -from ... import MandrillAPIError, NotSupportedByMandrillError +#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 @@ -14,10 +14,6 @@ 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" - DjrillBackendHTTPError = MandrillAPIError # Backwards-compat Djrill<=0.2.0 class DjrillBackend(BaseEmailBackend): @@ -31,7 +27,7 @@ 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 " diff --git a/djrill/views.py b/djrill/views.py index 4f4c5da..23fee44 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 " From 8de6b218b96bb6784b104f7995445462515672af Mon Sep 17 00:00:00 2001 From: medmunds Date: Sat, 12 Jan 2013 10:26:42 -0800 Subject: [PATCH 15/23] Handle bcc as Mandrill bcc_address, rather than additional to address --- README.rst | 15 ++++++++----- djrill/mail/backends/djrill.py | 16 ++++++++++--- djrill/tests/test_mandrill_send.py | 36 ++++++++++++++++++++++-------- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/README.rst b/README.rst index 99c5c6a..700cf80 100644 --- a/README.rst +++ b/README.rst @@ -138,11 +138,16 @@ Djrill supports most of the functionality of Django's `EmailMessage`_ and "image/\*", or "application/pdf" (since that is all Mandrill allows). Any other attachment types will raise a ``djrill.NotSupportedByMandrillError`` exception when you attempt to send the 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.) +* 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. +* 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.) * 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 diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index b6436de..c14807d 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -105,10 +105,11 @@ class DjrillBackend(BaseEmailBackend): 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, @@ -119,6 +120,15 @@ 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-"): diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 2a86147..165dd53 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -35,22 +35,32 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): (Test both sender and recipient addresses) """ - mail.send_mail('Subject', 'Message', 'From Name ', - ['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'}) @@ -62,16 +72,15 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): 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.' @@ -178,6 +187,14 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): 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(MandrillAPIError): @@ -295,6 +312,7 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): 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']) From 6dc1eea74de86301eaea0901aa61e73eb13ef1b3 Mon Sep 17 00:00:00 2001 From: medmunds Date: Sat, 12 Jan 2013 10:37:23 -0800 Subject: [PATCH 16/23] Readme: clean up "Django EmailMessage Support" section --- README.rst | 55 +++++++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/README.rst b/README.rst index 700cf80..b8e970f 100644 --- a/README.rst +++ b/README.rst @@ -123,35 +123,36 @@ 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 - ``djrill.NotSupportedByMandrillError`` exception when you attempt to send the +* **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. -* 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 ``djrill.NotSupportedByMandrillError`` exception when you attempt to - send the message. (Mandrill doesn't support sending multiple html alternative - parts, or any non-html alternatives.) -* 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 a ``djrill.NotSupportedByMandrillError`` - exception when you attempt to send the message. -* 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. -* 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.) -* 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. +* **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.) Mandrill Message Options ~~~~~~~~~~~~~~~~~~~~~~~~ From 241f9eeb2ccaf97daa7f898ddb26bf602a464028 Mon Sep 17 00:00:00 2001 From: medmunds Date: Sat, 12 Jan 2013 10:48:37 -0800 Subject: [PATCH 17/23] Readme: restore summary (from old long_description) --- README.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.rst b/README.rst index b8e970f..97adead 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. From c550aacbb8881edc2936ee6720a97442e0d22633 Mon Sep 17 00:00:00 2001 From: medmunds Date: Sat, 12 Jan 2013 10:50:44 -0800 Subject: [PATCH 18/23] Travis: re-enable Python 3.2 testing (Recent requests updates may have fixed Python 3 setup issues) --- .travis.yml | 2 +- README.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/README.rst b/README.rst index 97adead..068e7fa 100644 --- a/README.rst +++ b/README.rst @@ -249,7 +249,7 @@ 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.) From b4f2866f0f3c53b2824c91cdba41b1f9bf6a20f8 Mon Sep 17 00:00:00 2001 From: medmunds Date: Sat, 12 Jan 2013 11:45:44 -0800 Subject: [PATCH 19/23] Python 3.2 fixes - Absolute imports - Unicode strings --- djrill/__init__.py | 4 ++-- djrill/tests/__init__.py | 8 ++++---- djrill/views.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/djrill/__init__.py b/djrill/__init__.py index 0b67a0d..c842d77 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -2,6 +2,8 @@ from django.conf import settings from django.contrib.admin.sites import AdminSite from django.utils.text import capfirst +from djrill.exceptions import MandrillAPIError, NotSupportedByMandrillError + VERSION = (0, 2, 0) __version__ = '.'.join([str(x) for x in VERSION]) @@ -10,8 +12,6 @@ __version__ = '.'.join([str(x) for x in VERSION]) MANDRILL_API_URL = getattr(settings, "MANDRILL_API_URL", "http://mandrillapp.com/api/1.0") -from exceptions import MandrillAPIError, NotSupportedByMandrillError - class DjrillAdminSite(AdminSite): index_template = "djrill/index.html" custom_views = [] diff --git a/djrill/tests/__init__.py b/djrill/tests/__init__.py index 767ad30..1dd0c92 100644 --- a/djrill/tests/__init__.py +++ b/djrill/tests/__init__.py @@ -1,4 +1,4 @@ -from test_admin import * -from test_legacy import * -from test_mandrill_send import * -from test_mandrill_send_template import * +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/views.py b/djrill/views.py index 23fee44..5264119 100644 --- a/djrill/views.py +++ b/djrill/views.py @@ -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__ }) From ac0614a63310c3a585d47327ac517e42e9c72bd7 Mon Sep 17 00:00:00 2001 From: medmunds Date: Sat, 12 Jan 2013 13:32:57 -0800 Subject: [PATCH 20/23] More Python 3.2 fixes - attachment encoding --- djrill/mail/backends/djrill.py | 19 +++++++++++++++---- djrill/tests/test_mandrill_send.py | 21 +++++++++++---------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index c14807d..9114ca9 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -206,14 +206,15 @@ class DjrillBackend(BaseEmailBackend): 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) - for attachment in message.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): + 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 @@ -244,9 +245,19 @@ class DjrillBackend(BaseEmailBackend): "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': b64encode(content), + 'content': content_b64.decode('ascii'), } diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 165dd53..2b2710d 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -1,4 +1,4 @@ -from base64 import b64encode +from base64 import b64decode from email.mime.base import MIMEBase from django.conf import settings @@ -100,18 +100,18 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com']) - content1 = "* Item one\n* Item two\n* Item three" - email.attach(filename="test.txt", content=content1, + 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... - content2 = "PNG* pretend this is the contents of a png file" - email.attach(filename="test.png", content=content2) + 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)... - content3 = "PDF* pretend this is valid pdf data" + pdf_content = b"PDF\xb4 pretend this is valid pdf data" mimeattachment = MIMEBase('application', 'pdf') - mimeattachment.set_payload(content3) + mimeattachment.set_payload(pdf_content) email.attach(mimeattachment) email.send() @@ -120,13 +120,14 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): self.assertEqual(len(attachments), 3) self.assertEqual(attachments[0]["type"], "text/plain") self.assertEqual(attachments[0]["name"], "test.txt") - self.assertEqual(attachments[0]["content"], b64encode(content1)) + self.assertEqual(b64decode(attachments[0]["content"]).decode('ascii'), + text_content) self.assertEqual(attachments[1]["type"], "image/png") # inferred self.assertEqual(attachments[1]["name"], "test.png") - self.assertEqual(attachments[1]["content"], b64encode(content2)) + self.assertEqual(b64decode(attachments[1]["content"]), png_content) self.assertEqual(attachments[2]["type"], "application/pdf") self.assertEqual(attachments[2]["name"], "") # none - self.assertEqual(attachments[2]["content"], b64encode(content3)) + self.assertEqual(b64decode(attachments[2]["content"]), pdf_content) def test_extra_header_errors(self): email = mail.EmailMessage('Subject', 'Body', 'from@example.com', From 86b9711f2c96c14b782565d4b5a7b86af4c711c2 Mon Sep 17 00:00:00 2001 From: medmunds Date: Sat, 12 Jan 2013 13:49:35 -0800 Subject: [PATCH 21/23] setup: close license, readme files --- setup.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 1ff3eb7..d17f699 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,10 @@ 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", @@ -8,7 +13,7 @@ setup( author="Kenneth Love , Chris Jones ", author_email="kenneth@brack3t.com", url="https://github.com/brack3t/Djrill/", - license=open('LICENSE').read(), + license=license_text, packages=["djrill"], zip_safe=False, install_requires=["requests", "django"], @@ -22,5 +27,5 @@ setup( "Framework :: Django", "Environment :: Web Environment", ], - long_description=open('README.rst').read(), + long_description=long_description, ) From 860ebcdc4459427c973f41a6cd4fc546f86bbcaa Mon Sep 17 00:00:00 2001 From: medmunds Date: Sat, 12 Jan 2013 14:00:34 -0800 Subject: [PATCH 22/23] Python 3.2 (but not 3.3) b64decode requires bytes not str --- djrill/tests/test_mandrill_send.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 2b2710d..ad89306 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -115,19 +115,23 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): 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(b64decode(attachments[0]["content"]).decode('ascii'), + 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(b64decode(attachments[1]["content"]), png_content) + self.assertEqual(decode_att(attachments[1]["content"]), png_content) self.assertEqual(attachments[2]["type"], "application/pdf") self.assertEqual(attachments[2]["name"], "") # none - self.assertEqual(b64decode(attachments[2]["content"]), pdf_content) + self.assertEqual(decode_att(attachments[2]["content"]), pdf_content) def test_extra_header_errors(self): email = mail.EmailMessage('Subject', 'Body', 'from@example.com', From 9380b1d8c961e9fde41f49a6335869ca7826cd09 Mon Sep 17 00:00:00 2001 From: medmunds Date: Sat, 12 Jan 2013 14:20:40 -0800 Subject: [PATCH 23/23] Prep for 0.3.0 release - Update version numbers - Release notes - Update authors --- AUTHORS.txt | 1 + README.rst | 15 ++++++++++++++- djrill/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) 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 068e7fa..c60108f 100644 --- a/README.rst +++ b/README.rst @@ -170,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 @@ -283,6 +283,19 @@ 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 diff --git a/djrill/__init__.py b/djrill/__init__.py index c842d77..e19f8f7 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -4,7 +4,7 @@ from django.utils.text import capfirst from djrill.exceptions import MandrillAPIError, NotSupportedByMandrillError -VERSION = (0, 2, 0) +VERSION = (0, 3, 0) __version__ = '.'.join([str(x) for x in VERSION]) # This backend was developed against this API endpoint. diff --git a/setup.py b/setup.py index d17f699..9a3fb5a 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open('README.rst') as file: 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 ",