mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-21 20:31:06 -05:00
Expose most Mandrill send features on EmailMessage objects.
* Supports additional Mandrill send-API attributes on any ``EmailMessage``-derived object -- see details in readme * Removes need for MANDRILL_API_URL in settings (since this is tightly tied to the code) * Removes ``DjrillMessage`` from the readme (but not the code or tests) -- its functionality is now duplicated or exceeded by standard EmailMessage with additional attributes * Ensures send(fail_silently=True) works as expected
This commit is contained in:
@@ -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
|
||||
|
||||
128
djrill/tests.py
128
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 <bcc2@example.com>'],
|
||||
cc=['cc1@example.com', 'Also CC <cc2@example.com>'],
|
||||
headers={'Reply-To': 'another@example.com',
|
||||
'X-MyHeader': 'my value',
|
||||
'Errors-To': 'silently stripped'})
|
||||
'X-MyHeader': 'my value'})
|
||||
email.send()
|
||||
data = self.get_api_call_data()
|
||||
self.assertEqual(data['message']['subject'], "Subject")
|
||||
@@ -124,6 +124,22 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
|
||||
self.assertEqual(data['message']['text'], text_content)
|
||||
self.assertEqual(data['message']['html'], html_content)
|
||||
|
||||
def test_extra_header_errors(self):
|
||||
email = mail.EmailMessage('Subject', 'Body', 'from@example.com',
|
||||
['to@example.com'],
|
||||
headers={'Non-X-Non-Reply-To-Header': 'not permitted'})
|
||||
with self.assertRaises(ValueError):
|
||||
email.send()
|
||||
|
||||
# Make sure fail_silently is respected
|
||||
email = mail.EmailMessage('Subject', 'Body', 'from@example.com',
|
||||
['to@example.com'],
|
||||
headers={'Non-X-Non-Reply-To-Header': 'not permitted'})
|
||||
sent = email.send(fail_silently=True)
|
||||
self.assertFalse(self.mock_post.called,
|
||||
msg="Mandrill API should not be called when send fails silently")
|
||||
self.assertEqual(sent, 0)
|
||||
|
||||
def test_alternative_errors(self):
|
||||
# Multiple alternatives not allowed
|
||||
email = mail.EmailMultiAlternatives('Subject', 'Body',
|
||||
@@ -149,6 +165,112 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
|
||||
msg="Mandrill API should not be called when send fails silently")
|
||||
self.assertEqual(sent, 0)
|
||||
|
||||
def test_mandrill_api_failure(self):
|
||||
self.mock_post.return_value = self.MockResponse(status_code=400)
|
||||
with self.assertRaises(DjrillBackendHTTPError):
|
||||
sent = mail.send_mail('Subject', 'Body', 'from@example.com',
|
||||
['to@example.com'])
|
||||
self.assertEqual(sent, 0)
|
||||
|
||||
# Make sure fail_silently is respected
|
||||
self.mock_post.return_value = self.MockResponse(status_code=400)
|
||||
sent = mail.send_mail('Subject', 'Body', 'from@example.com',
|
||||
['to@example.com'], fail_silently=True)
|
||||
self.assertEqual(sent, 0)
|
||||
|
||||
|
||||
class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
|
||||
"""Test Djrill backend support for Mandrill-specific features"""
|
||||
|
||||
def setUp(self):
|
||||
super(DjrillMandrillFeatureTests, self).setUp()
|
||||
self.message = mail.EmailMessage('Subject', 'Text Body',
|
||||
'from@example.com', ['to@example.com'])
|
||||
|
||||
def test_tracking(self):
|
||||
# First make sure we're not setting the API param if the track_click
|
||||
# attr isn't there. (The Mandrill account option of True for html,
|
||||
# False for plaintext can't be communicated through the API, other than
|
||||
# by omitting the track_clicks API param to use your account default.)
|
||||
self.message.send()
|
||||
data = self.get_api_call_data()
|
||||
self.assertFalse('track_clicks' in data['message'])
|
||||
# Now re-send with the params set
|
||||
self.message.track_opens = True
|
||||
self.message.track_clicks = True
|
||||
self.message.url_strip_qs = True
|
||||
self.message.send()
|
||||
data = self.get_api_call_data()
|
||||
self.assertEqual(data['message']['track_opens'], True)
|
||||
self.assertEqual(data['message']['track_clicks'], True)
|
||||
self.assertEqual(data['message']['url_strip_qs'], True)
|
||||
|
||||
def test_message_options(self):
|
||||
self.message.auto_text = True
|
||||
self.message.preserve_recipients = True
|
||||
self.message.send()
|
||||
data = self.get_api_call_data()
|
||||
self.assertEqual(data['message']['auto_text'], True)
|
||||
self.assertEqual(data['message']['preserve_recipients'], True)
|
||||
|
||||
def test_merge(self):
|
||||
# Djrill expands simple python dicts into the more-verbose name/value
|
||||
# structures the Mandrill API uses
|
||||
self.message.global_merge_vars = { 'GREETING': "Hello",
|
||||
'ACCOUNT_TYPE': "Basic" }
|
||||
self.message.merge_vars = {
|
||||
"customer@example.com": { 'GREETING': "Dear Customer",
|
||||
'ACCOUNT_TYPE': "Premium" },
|
||||
"guest@example.com": { 'GREETING': "Dear Guest" },
|
||||
}
|
||||
self.message.send()
|
||||
data = self.get_api_call_data()
|
||||
self.assertEqual(data['message']['global_merge_vars'],
|
||||
[ {'name': 'ACCOUNT_TYPE', 'value': "Basic"},
|
||||
{'name': "GREETING", 'value': "Hello"} ])
|
||||
self.assertEqual(data['message']['merge_vars'],
|
||||
[ { 'rcpt': "customer@example.com",
|
||||
'vars': [{ 'name': 'ACCOUNT_TYPE', 'value': "Premium" },
|
||||
{ 'name': "GREETING", 'value': "Dear Customer"}] },
|
||||
{ 'rcpt': "guest@example.com",
|
||||
'vars': [{ 'name': "GREETING", 'value': "Dear Guest"}] }
|
||||
])
|
||||
|
||||
def test_tags(self):
|
||||
self.message.tags = ["receipt", "repeat-user"]
|
||||
self.message.send()
|
||||
data = self.get_api_call_data()
|
||||
self.assertEqual(data['message']['tags'], ["receipt", "repeat-user"])
|
||||
|
||||
def test_google_analytics(self):
|
||||
self.message.google_analytics_domains = ["example.com"]
|
||||
self.message.google_analytics_campaign = "Email Receipts"
|
||||
self.message.send()
|
||||
data = self.get_api_call_data()
|
||||
self.assertEqual(data['message']['google_analytics_domains'],
|
||||
["example.com"])
|
||||
self.assertEqual(data['message']['google_analytics_campaign'],
|
||||
"Email Receipts")
|
||||
|
||||
def test_metadata(self):
|
||||
self.message.metadata = { 'batch_num': "12345", 'type': "Receipts" }
|
||||
self.message.recipient_metadata = {
|
||||
# Djrill expands simple python dicts into the more-verbose
|
||||
# name/value structures the Mandrill API uses
|
||||
"customer@example.com": { 'cust_id': "67890", 'order_id': "54321" },
|
||||
"guest@example.com": { 'cust_id': "94107", 'order_id': "43215" }
|
||||
}
|
||||
self.message.send()
|
||||
data = self.get_api_call_data()
|
||||
self.assertEqual(data['message']['metadata'], { 'batch_num': "12345",
|
||||
'type': "Receipts" })
|
||||
self.assertEqual(data['message']['recipient_metadata'],
|
||||
[ { 'rcpt': "customer@example.com",
|
||||
'values': { 'cust_id': "67890", 'order_id': "54321" } },
|
||||
{ 'rcpt': "guest@example.com",
|
||||
'values': { 'cust_id': "94107", 'order_id': "43215" } }
|
||||
])
|
||||
|
||||
|
||||
def reset_admin_site():
|
||||
"""Return the Django admin globals to their original state"""
|
||||
|
||||
@@ -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__
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user