diff --git a/README.rst b/README.rst index 6925ecb..364d2dc 100644 --- a/README.rst +++ b/README.rst @@ -17,38 +17,45 @@ Djrill is made available under the BSD license. Installation ------------ -:: +Install from PyPI:: pip install djrill -The only dependency other than Django is the requests_ library from Kenneth Reitz. If you do not install through PyPI you will -need to do :: +The only dependency other than Django is the requests_ library from Kenneth +Reitz. (If you do not install Djrill using pip or setuptools, you will also +need to ``pip install requests``.) - pip install requests Configuration ------------- In ``settings.py``: -1. Add ``djrill`` to your ``INSTALLED_APPS``. :: +1. Add ``djrill`` to your ``INSTALLED_APPS``: + +.. code:: python INSTALLED_APPS = ( ... "djrill" ) -2. Add the following two lines, substituting your own ``MANDRILL_API_KEY``:: +2. Add the following line, substituting your own ``MANDRILL_API_KEY``: + +.. code:: python MANDRILL_API_KEY = "brack3t-is-awesome" - MANDRILL_API_URL = "http://mandrillapp.com/api/1.0" -3. Override your existing email backend with the following line:: +3. Override your existing email backend with the following line: + +.. code:: python EMAIL_BACKEND = "djrill.mail.backends.djrill.DjrillBackend" -4. (optional) If you want to be able to add senders through Django's admin or view stats about your - messages, do the following in your base ``urls.py`` :: +4. (optional) If you want to be able to add senders through Django's admin or + view stats about your messages, do the following in your base ``urls.py``: + +.. code:: python ... from django.contrib import admin @@ -69,46 +76,86 @@ Usage Since you are replacing the global ``EMAIL_BACKEND``, **all** emails are sent through Mandrill's service. -If you just want to use Mandrill for sending emails through Django's built-in ``send_mail`` and ``send_mass_mail`` methods, all -you need to do is follow steps 1 through 3 of the above Configuration. +In general, Djrill "just works" with Django's built-in `django.core.mail`_ +package, including ``send_mail``, ``send_mass_mail``, ``EmailMessage`` and +``EmailMultiAlternatives``. -If, however, you want more control over the messages, to include an HTML version, or to attach tags or tracked URLs to an email, -usage of our ``DjrillMessage`` class, which is a thin wrapper around Django's ``EmailMultiAlternatives`` is required. +You can also take advantage of Mandrill-specific features like tags, metadata, +and tracking by creating a ``django.mail.EmailMessage`` (or for HTML, +``django.mail.EmailMultiAlternatives``) object and setting Mandrill-specific +properties on it before calling its ``send`` method. -Example, in a view: :: +Example: - from django.views.generic import View +.. code:: python - from djrill.mail import DjrillMessage + from django.core.mail import EmailMultiAlternatives # or just EmailMessage if you don't need HTML - class SendEmailView(View): + subject = "Djrill Message" + from_email = "Djrill Sender " # this has to be in your Mandrill account's sending domains + to = ["Djrill Receiver ", "djrill.two@example.com"] + reply_email = "Customer Service " # optional + text_content = "This is the text version of your email" + html_content = "

This is the HTML version of your email

" # optional, use with ``attach_alternative`` below - def get(self, request): - subject = "Djrill Message" - from_email = "djrill@example.com" # this has to be one of your approved senders - from_name = "Djrill" # optional - to = ["Djrill Receiver ", "djrill.two@example.com"] - text_content = "This is the text version of your email" - html_content = "

This is the HTML version of your email

" # optional, requires the ``attach_alternative`` line below - tags = ["one tag", "two tag", "red tag", "blue tag"] # optional, can't be over 50 chars or start with an underscore + msg = EmailMultiAlternatives(subject, text_content, from_email, to, headers={'Reply-To': reply_email}) + msg.tags = ["one tag", "two tag", "red tag", "blue tag"] # optional, Mandrill-specific message extension + msg.metadata = {'user_id': "8675309"} # optional, Mandrill-specific message extension + msg.attach_alternative(html_content, "text/html") + msg.send() - msg = DjrillMessage(subject, text_content, from_email, to, tags=tags, from_name=from_name) - msg.attach_alternative(html_content, "text/html") - msg.send() - ... # you'll want to return some sort of HttpResponse +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). -Any tags over 50 characters in length are silently ignored since Mandrill doesn't support them. Any tags starting with an underscore will raise an ``ImproperlyConfigured`` -exception. Tags with an underscore are reserved by Mandrill. +Djrill supports most of the functionality of Django's ``EmailMessage`` and +``EmailMultiAlternatives``. Some limitations: -If you attach more than one alternative type, an ``ImproperlyConfigured`` exception will be raised. Mandrill does not support attaching -files to an email, so attachments will be silently ignored. +* Djrill 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.) -Not shown above, but settable, are the two options, ``track_clicks`` and ``track_opens``. They are both set to ``True`` by default, but can be set to ``False`` and passed in when you instantiate your ``DjrillMessage`` -object. +Many of the options from the Mandrill `messages/send.json API`_ ``message`` +struct can be set directly on an ``EmailMessage`` (or subclass) object: + +* ``track_opens`` - Boolean +* ``track_clicks`` - Boolean (If you want to track clicks in HTML only, not + plaintext mail, you must *not* set this property, and instead just set the + default in your Mandrill account sending options.) +* ``auto_text`` - Boolean +* ``url_strip_qs`` - Boolean +* ``preserve_recipients`` - Boolean -- see the caution about bcc addresses above +* ``global_merge_vars`` - a dict -- e.g., + ``{ 'company': "ACME", 'offer': "10% off" }`` +* ``recipient_merge_vars`` - a dict whose keys are the recipient email addresses + and whose values are dicts of merge vars for each recipient -- e.g., + ``{ 'wiley@example.com': { 'offer': "15% off anvils" } }`` +* ``tags`` - a list of strings +* ``google_analytics_domains`` - a list of string domain names +* ``google_analytics_campaign`` - a string or list of strings +* ``metadata`` - a dict +* ``recipient_metadata`` - a dict whose keys are the recipient email addresses, + and whose values are dicts of metadata for each recipient (similar to + ``recipient_merge_vars``) + +These Mandrill-specific properties work with *any* ``EmailMessage``-derived +object, so you can use them with many other apps that add Django mail +functionality (such as Django template-based messages). + +If you have any questions about the python syntax for any of these properties, +see ``DjrillMandrillFeatureTests`` in tests.py for examples. -Just like Django's ``EmailMessage`` and ``EmailMultiAlternatives``, ``DjrillMessage`` accepts extra headers through the -``headers`` argument. Currently it only accepts ``Reply-To`` and ``X-*`` headers since that is all that Mandrill accepts. Any -extra headers are silently discarded. Testing ------- @@ -145,3 +192,6 @@ the awesome ``requests`` library. .. _requests: http://docs.python-requests.org .. _django-adminplus: https://github.com/jsocol/django-adminplus .. _mock: http://www.voidspace.org.uk/python/mock/index.html +.. _django.core.mail: https://docs.djangoproject.com/en/dev/topics/email/ +.. _messages/send.json API: https://mandrillapp.com/api/docs/messages.html#method=send + diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index 47a8136..fb63822 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -7,11 +7,16 @@ from django.utils import simplejson as json from email.utils import parseaddr import requests +# This backend was developed against this API endpoint. +# You can override in settings.py, if desired. +MANDRILL_API_URL = "http://mandrillapp.com/api/1.0" + class DjrillBackendHTTPError(Exception): """An exception that will turn into an HTTP error response.""" - def __init__(self, status_code, log_message=None): + def __init__(self, status_code, response=None, log_message=None): super(DjrillBackendHTTPError, self).__init__() self.status_code = status_code + self.response = response # often contains helpful Mandrill info self.log_message = log_message def __str__(self): @@ -33,14 +38,11 @@ class DjrillBackend(BaseEmailBackend): """ super(DjrillBackend, self).__init__(**kwargs) self.api_key = getattr(settings, "MANDRILL_API_KEY", None) - self.api_url = getattr(settings, "MANDRILL_API_URL", None) + self.api_url = getattr(settings, "MANDRILL_API_URL", MANDRILL_API_URL) if not self.api_key: raise ImproperlyConfigured("You have not set your mandrill api key " "in the settings.py file.") - if not self.api_url: - raise ImproperlyConfigured("You have not added the Mandrill api " - "url to your settings.py") self.api_action = self.api_url + "/messages/send.json" @@ -51,6 +53,7 @@ class DjrillBackend(BaseEmailBackend): num_sent = 0 for message in email_messages: sent = self._send(message) + if sent: num_sent += 1 @@ -60,20 +63,11 @@ class DjrillBackend(BaseEmailBackend): if not message.recipients(): return False - self.sender = sanitize_address(message.from_email, message.encoding) - recipients_list = [sanitize_address(addr, message.encoding) - for addr in message.recipients()] - self.recipients = [{"email": e, "name": n} for n,e in [ - parseaddr(r) for r in recipients_list]] - - self.msg_dict = self._build_standard_message_dict(message) - - if getattr(message, "alternative_subtype", None): - if message.alternative_subtype == "mandrill": - self._build_advanced_message_dict(message) try: + msg_dict = self._build_standard_message_dict(message) + self._add_mandrill_options(message, msg_dict) if getattr(message, 'alternatives', None): - self._add_alternatives(message) + self._add_alternatives(message, msg_dict) except ValueError: if not self.fail_silently: raise @@ -81,61 +75,98 @@ class DjrillBackend(BaseEmailBackend): djrill_it = requests.post(self.api_action, data=json.dumps({ "key": self.api_key, - "message": self.msg_dict + "message": msg_dict })) if djrill_it.status_code != 200: if not self.fail_silently: raise DjrillBackendHTTPError( status_code=djrill_it.status_code, + response = djrill_it, log_message="Failed to send a message to %s, from %s" % - (self.recipients, self.sender)) + (msg_dict['to'], msg_dict['from_email'])) return False return True def _build_standard_message_dict(self, message): - """ - Build standard message dict. + """Create a Mandrill send message struct from a Django EmailMessage. Builds the standard dict that Django's send_mail and send_mass_mail use by default. Standard text email messages sent through Django will still work through Mandrill. + + Raises ValueError for any standard EmailMessage features that cannot be + accurately communicated to Mandrill (e.g., prohibited headers). """ - from_name, from_email = parseaddr(self.sender) + sender = sanitize_address(message.from_email, message.encoding) + from_name, from_email = parseaddr(sender) + + recipients = [parseaddr(sanitize_address(addr, message.encoding)) + for addr in message.recipients()] + to_list = [{"email": to_email, "name": to_name} + for (to_name, to_email) in recipients] + msg_dict = { "text": message.body, "subject": message.subject, "from_email": from_email, - "to": self.recipients + "to": to_list } if from_name: msg_dict["from_name"] = from_name if message.extra_headers: - accepted_headers = {} for k in message.extra_headers.keys(): - if k.startswith("X-") or k == "Reply-To": - accepted_headers.update( - {"%s" % k: message.extra_headers[k]}) - msg_dict.update({"headers": accepted_headers}) + if k != "Reply-To" and not k.startswith("X-"): + raise ValueError("Invalid message header '%s' - Mandrill " + "only allows Reply-To and X-* headers" % k) + msg_dict["headers"] = message.extra_headers return msg_dict - def _build_advanced_message_dict(self, message): - """ - Builds advanced message dict - """ - self.msg_dict.update({ - "tags": message.tags, - "track_opens": message.track_opens, - "track_clicks": message.track_clicks, - "preserve_recipients": message.preserve_recipients, - }) - if message.from_name: - self.msg_dict["from_name"] = message.from_name + def _add_mandrill_options(self, message, msg_dict): + """Extend msg_dict to include Mandrill options set on message""" + # Mandrill attributes that can be copied directly: + mandrill_attrs = [ + 'from_name', # overrides display name parsed from from_email above + 'track_opens', 'track_clicks', 'auto_text', 'url_strip_qs', + 'tags', 'preserve_recipients', + 'google_analytics_domains', 'google_analytics_campaign', + 'metadata'] + for attr in mandrill_attrs: + if hasattr(message, attr): + msg_dict[attr] = getattr(message, attr) + + # Allow simple python dicts in place of Mandrill + # [{name:name, value:value},...] arrays... + if hasattr(message, 'global_merge_vars'): + msg_dict['global_merge_vars'] = \ + self._expand_merge_vars(message.global_merge_vars) + if hasattr(message, 'merge_vars'): + # For testing reproducibility, we sort the recipients + msg_dict['merge_vars'] = [ + { 'rcpt': rcpt, + 'vars': self._expand_merge_vars(message.merge_vars[rcpt]) } + for rcpt in sorted(message.merge_vars.keys()) + ] + if hasattr(message, 'recipient_metadata'): + # For testing reproducibility, we sort the recipients + msg_dict['recipient_metadata'] = [ + { 'rcpt': rcpt, 'values': message.recipient_metadata[rcpt] } + for rcpt in sorted(message.recipient_metadata.keys()) + ] - def _add_alternatives(self, message): + def _expand_merge_vars(self, vars): + """Convert a Python dict to an array of name-value used by Mandrill. + + { name: value, ... } --> [ {'name': name, 'value': value }, ... ] + """ + # For testing reproducibility, we sort the keys + return [ { 'name': name, 'value': vars[name] } + for name in sorted(vars.keys()) ] + + def _add_alternatives(self, message, msg_dict): """ There can be only one! ... alternative attachment, and it must be text/html. @@ -154,6 +185,4 @@ class DjrillBackend(BaseEmailBackend): "Mandrill only accepts plain text and html emails." % mimetype) - self.msg_dict.update({ - "html": content - }) + msg_dict['html'] = content diff --git a/djrill/tests.py b/djrill/tests.py index e5c006f..7c9e175 100644 --- a/djrill/tests.py +++ b/djrill/tests.py @@ -10,6 +10,8 @@ from django.test import TestCase from django.utils import simplejson as json from djrill.mail import DjrillMessage +from djrill.mail.backends.djrill import DjrillBackendHTTPError + class DjrillBackendMockAPITestCase(TestCase): """TestCase that uses Djrill EmailBackend with a mocked Mandrill API""" @@ -26,7 +28,6 @@ class DjrillBackendMockAPITestCase(TestCase): self.mock_post.return_value = self.MockResponse() settings.MANDRILL_API_KEY = "FAKE_API_KEY_FOR_TESTING" - settings.MANDRILL_API_URL = "http://mandrillapp.com/api/1.0" # Django TestCase sets up locmem EmailBackend; override it here self.original_email_backend = settings.EMAIL_BACKEND @@ -93,8 +94,7 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): bcc=['bcc1@example.com', 'Also BCC '], cc=['cc1@example.com', 'Also CC '], headers={'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value', - 'Errors-To': 'silently stripped'}) + 'X-MyHeader': 'my value'}) email.send() data = self.get_api_call_data() self.assertEqual(data['message']['subject'], "Subject") @@ -124,6 +124,22 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): self.assertEqual(data['message']['text'], text_content) self.assertEqual(data['message']['html'], html_content) + def test_extra_header_errors(self): + email = mail.EmailMessage('Subject', 'Body', 'from@example.com', + ['to@example.com'], + headers={'Non-X-Non-Reply-To-Header': 'not permitted'}) + with self.assertRaises(ValueError): + email.send() + + # Make sure fail_silently is respected + email = mail.EmailMessage('Subject', 'Body', 'from@example.com', + ['to@example.com'], + headers={'Non-X-Non-Reply-To-Header': 'not permitted'}) + sent = email.send(fail_silently=True) + self.assertFalse(self.mock_post.called, + msg="Mandrill API should not be called when send fails silently") + self.assertEqual(sent, 0) + def test_alternative_errors(self): # Multiple alternatives not allowed email = mail.EmailMultiAlternatives('Subject', 'Body', @@ -149,6 +165,135 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): msg="Mandrill API should not be called when send fails silently") self.assertEqual(sent, 0) + def test_mandrill_api_failure(self): + self.mock_post.return_value = self.MockResponse(status_code=400) + with self.assertRaises(DjrillBackendHTTPError): + sent = mail.send_mail('Subject', 'Body', 'from@example.com', + ['to@example.com']) + self.assertEqual(sent, 0) + + # Make sure fail_silently is respected + self.mock_post.return_value = self.MockResponse(status_code=400) + sent = mail.send_mail('Subject', 'Body', 'from@example.com', + ['to@example.com'], fail_silently=True) + self.assertEqual(sent, 0) + + +class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): + """Test Djrill backend support for Mandrill-specific features""" + + def setUp(self): + super(DjrillMandrillFeatureTests, self).setUp() + self.message = mail.EmailMessage('Subject', 'Text Body', + 'from@example.com', ['to@example.com']) + + def test_tracking(self): + # First make sure we're not setting the API param if the track_click + # attr isn't there. (The Mandrill account option of True for html, + # False for plaintext can't be communicated through the API, other than + # by omitting the track_clicks API param to use your account default.) + self.message.send() + data = self.get_api_call_data() + self.assertFalse('track_clicks' in data['message']) + # Now re-send with the params set + self.message.track_opens = True + self.message.track_clicks = True + self.message.url_strip_qs = True + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['message']['track_opens'], True) + self.assertEqual(data['message']['track_clicks'], True) + self.assertEqual(data['message']['url_strip_qs'], True) + + def test_message_options(self): + self.message.auto_text = True + self.message.preserve_recipients = True + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['message']['auto_text'], True) + self.assertEqual(data['message']['preserve_recipients'], True) + + def test_merge(self): + # Djrill expands simple python dicts into the more-verbose name/value + # structures the Mandrill API uses + self.message.global_merge_vars = { 'GREETING': "Hello", + 'ACCOUNT_TYPE': "Basic" } + self.message.merge_vars = { + "customer@example.com": { 'GREETING': "Dear Customer", + 'ACCOUNT_TYPE': "Premium" }, + "guest@example.com": { 'GREETING': "Dear Guest" }, + } + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['message']['global_merge_vars'], + [ {'name': 'ACCOUNT_TYPE', 'value': "Basic"}, + {'name': "GREETING", 'value': "Hello"} ]) + self.assertEqual(data['message']['merge_vars'], + [ { 'rcpt': "customer@example.com", + 'vars': [{ 'name': 'ACCOUNT_TYPE', 'value': "Premium" }, + { 'name': "GREETING", 'value': "Dear Customer"}] }, + { 'rcpt': "guest@example.com", + 'vars': [{ 'name': "GREETING", 'value': "Dear Guest"}] } + ]) + + def test_tags(self): + self.message.tags = ["receipt", "repeat-user"] + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['message']['tags'], ["receipt", "repeat-user"]) + + def test_google_analytics(self): + self.message.google_analytics_domains = ["example.com"] + self.message.google_analytics_campaign = "Email Receipts" + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['message']['google_analytics_domains'], + ["example.com"]) + self.assertEqual(data['message']['google_analytics_campaign'], + "Email Receipts") + + def test_metadata(self): + self.message.metadata = { 'batch_num': "12345", 'type': "Receipts" } + self.message.recipient_metadata = { + # Djrill expands simple python dicts into the more-verbose + # name/value structures the Mandrill API uses + "customer@example.com": { 'cust_id': "67890", 'order_id': "54321" }, + "guest@example.com": { 'cust_id': "94107", 'order_id': "43215" } + } + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['message']['metadata'], { 'batch_num': "12345", + 'type': "Receipts" }) + self.assertEqual(data['message']['recipient_metadata'], + [ { 'rcpt': "customer@example.com", + 'values': { 'cust_id': "67890", 'order_id': "54321" } }, + { 'rcpt': "guest@example.com", + 'values': { 'cust_id': "94107", 'order_id': "43215" } } + ]) + + def test_default_omits_options(self): + """Make sure by default we don't send any Mandrill-specific options. + + Options not specified by the caller should be omitted entirely from + the Mandrill API call (*not* sent as False or empty). This ensures + that your Mandrill account settings apply by default. + """ + self.message.send() + data = self.get_api_call_data() + self.assertFalse('from_name' in data['message']) + self.assertFalse('track_opens' in data['message']) + self.assertFalse('track_clicks' in data['message']) + self.assertFalse('auto_text' in data['message']) + self.assertFalse('url_strip_qs' in data['message']) + self.assertFalse('tags' in data['message']) + self.assertFalse('preserve_recipients' in data['message']) + self.assertFalse('google_analytics_domains' in data['message']) + self.assertFalse('google_analytics_campaign' in data['message']) + self.assertFalse('metadata' in data['message']) + self.assertFalse('global_merge_vars' in data['message']) + self.assertFalse('merge_vars' in data['message']) + self.assertFalse('recipient_metadata' in data['message']) + def reset_admin_site(): """Return the Django admin globals to their original state""" diff --git a/djrill/views.py b/djrill/views.py index f2cf2fd..4f4c5da 100644 --- a/djrill/views.py +++ b/djrill/views.py @@ -5,6 +5,8 @@ from django.core.exceptions import ImproperlyConfigured from django.utils import simplejson as json from django.views.generic import TemplateView +from djrill.mail.backends.djrill import MANDRILL_API_URL + import requests @@ -23,14 +25,11 @@ class DjrillApiMixin(object): """ def __init__(self): self.api_key = getattr(settings, "MANDRILL_API_KEY", None) - self.api_url = getattr(settings, "MANDRILL_API_URL", None) + self.api_url = getattr(settings, "MANDRILL_API_URL", MANDRILL_API_URL) if not self.api_key: raise ImproperlyConfigured("You have not set your mandrill api key " "in the settings file.") - if not self.api_url: - raise ImproperlyConfigured("You have not added the Mandrill api " - "url to your settings.py") def get_context_data(self, **kwargs): kwargs = super(DjrillApiMixin, self).get_context_data(**kwargs) @@ -53,7 +52,7 @@ class DjrillApiJsonObjectsMixin(object): def get_api_uri(self): if self.api_uri is None: - raise ImproperlyConfigured(u"%(cls)s is missing an api_uri. Define " + raise NotImplementedError(u"%(cls)s is missing an api_uri. Define " u"%(cls)s.api_uri or override %(cls)s.get_api_uri()." % { "cls": self.__class__.__name__ })