Raise error for invalid/rejected recipients

Raise new MandrillRecipientsRefused exception
when Mandrill returns 'reject' or 'invalid' status
for *all* recipients of a message.

(Similar to Django's SMTP email backend raising
SMTPRecipientsRefused.)

Add setting MANDRILL_IGNORE_RECIPIENT_STATUS
to override the new exception.

Trap JSON parsing errors in Mandrill API response,
and raise MandrillAPIError for them. (Helps with #93.)

Closes #80.
Closes #81.
This commit is contained in:
medmunds
2015-12-01 13:26:21 -08:00
parent 8433e6d660
commit d14b87c910
9 changed files with 204 additions and 37 deletions

View File

@@ -1,3 +1,2 @@
from ._version import __version__, VERSION
from .exceptions import MandrillAPIError, NotSupportedByMandrillError
from .exceptions import MandrillAPIError, MandrillRecipientsRefused, NotSupportedByMandrillError

View File

@@ -2,6 +2,23 @@ import json
from requests import HTTPError
def format_response(response):
"""Return a string-formatted version of response
Format json if available, else just return text.
Returns "" if neither json nor text available.
"""
try:
json_response = response.json()
return "\n" + json.dumps(json_response, indent=2)
except (AttributeError, KeyError, ValueError): # not JSON = ValueError
try:
return response.text
except AttributeError:
pass
return ""
class MandrillAPIError(HTTPError):
"""Exception for unsuccessful response from Mandrill API."""
def __init__(self, status_code, response=None, log_message=None, *args, **kwargs):
@@ -15,14 +32,21 @@ class MandrillAPIError(HTTPError):
if self.log_message:
message += "\n" + self.log_message
# Include the Mandrill response, nicely formatted, if possible
try:
json_response = self.response.json()
message += "\nMandrill response:\n" + json.dumps(json_response, indent=2)
except (AttributeError, KeyError, ValueError): # not JSON = ValueError
try:
message += "\nMandrill response: " + self.response.text
except AttributeError:
pass
if self.response is not None:
message += "\nMandrill response: " + format_response(self.response)
return message
class MandrillRecipientsRefused(IOError):
"""Exception for send where all recipients are invalid or rejected."""
def __init__(self, message, response=None, *args, **kwargs):
super(MandrillRecipientsRefused, self).__init__(message, *args, **kwargs)
self.response = response
def __str__(self):
message = self.args[0]
if self.response is not None:
message += "\nMandrill response: " + format_response(self.response)
return message

View File

@@ -12,7 +12,7 @@ from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE
from ..._version import __version__
from ...exceptions import MandrillAPIError, NotSupportedByMandrillError
from ...exceptions import MandrillAPIError, MandrillRecipientsRefused, NotSupportedByMandrillError
def encode_date_for_mandrill(dt):
@@ -56,6 +56,7 @@ class DjrillBackend(BaseEmailBackend):
self.global_settings[setting_key] = settings.MANDRILL_SETTINGS[setting_key]
self.subaccount = getattr(settings, "MANDRILL_SUBACCOUNT", None)
self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False)
if not self.api_key:
raise ImproperlyConfigured("You have not set your mandrill api key "
@@ -124,6 +125,8 @@ class DjrillBackend(BaseEmailBackend):
return num_sent
def _send(self, message):
message.mandrill_response = None # until we have a response
if not message.recipients():
return False
@@ -172,10 +175,6 @@ class DjrillBackend(BaseEmailBackend):
response = self.session.post(api_url, data=api_data)
if response.status_code != 200:
# add a mandrill_response for the sake of being explicit
message.mandrill_response = None
if not self.fail_silently:
log_message = "Failed to send a message"
if 'to' in msg_dict:
@@ -190,7 +189,27 @@ class DjrillBackend(BaseEmailBackend):
return False
# add the response from mandrill to the EmailMessage so callers can inspect it
message.mandrill_response = response.json()
try:
message.mandrill_response = response.json()
recipient_status = [item["status"] for item in message.mandrill_response]
except (ValueError, KeyError):
if not self.fail_silently:
raise MandrillAPIError(
status_code=response.status_code,
response=response,
log_message="Error parsing Mandrill API response")
return False
# Error if *all* recipients are invalid or refused
# (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend)
if (not self.ignore_recipient_status and
all([status in ('invalid', 'rejected') for status in recipient_status])):
if not self.fail_silently:
raise MandrillRecipientsRefused(
"All message recipients were rejected or invalid",
response=response
)
return False
return True

View File

@@ -7,6 +7,14 @@ from django.test import TestCase
from django.test.utils import override_settings
MANDRILL_SUCCESS_RESPONSE = six.b("""[{
"email": "to@example.com",
"status": "sent",
"_id": "abc123",
"reject_reason": null
}]""")
@override_settings(MANDRILL_API_KEY="FAKE_API_KEY_FOR_TESTING",
EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
class DjrillBackendMockAPITestCase(TestCase):
@@ -14,7 +22,7 @@ class DjrillBackendMockAPITestCase(TestCase):
class MockResponse(requests.Response):
"""requests.post return value mock sufficient for DjrillBackend"""
def __init__(self, status_code=200, raw=six.b("{}"), encoding='utf-8'):
def __init__(self, status_code=200, raw=six.b(MANDRILL_SUCCESS_RESPONSE), encoding='utf-8'):
super(DjrillBackendMockAPITestCase.MockResponse, self).__init__()
self.status_code = status_code
self.encoding = encoding

View File

@@ -7,7 +7,7 @@ from django.core import mail
from django.test import TestCase
from django.test.utils import override_settings
from djrill import MandrillAPIError
from djrill import MandrillAPIError, MandrillRecipientsRefused
MANDRILL_TEST_API_KEY = os.getenv('MANDRILL_TEST_API_KEY')
@@ -58,25 +58,41 @@ class DjrillIntegrationTests(TestCase):
def test_invalid_to(self):
# Example of detecting when a recipient is not a valid email address
self.message.to = ['invalid@localhost']
sent_count = self.message.send()
self.assertEqual(sent_count, 1) # The send call is "successful"...
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
if response[0]['status'] == 'queued':
self.skipTest("Mandrill queued the send -- can't complete this test")
self.assertEqual(response[0]['status'], 'invalid') # ... but the mail is not delivered
try:
self.message.send()
except MandrillRecipientsRefused:
# Mandrill refused to deliver the mail -- message.mandrill_response will tell you why:
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
self.assertEqual(response[0]['status'], 'invalid')
else:
# Sometimes Mandrill queues these test sends
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
if response[0]['status'] == 'queued':
self.skipTest("Mandrill queued the send -- can't complete this test")
else:
self.fail("Djrill did not raise MandrillRecipientsRefused for invalid recipient")
def test_rejected_to(self):
# Example of detecting when a recipient is on Mandrill's rejection blacklist
self.message.to = ['reject@test.mandrillapp.com']
sent_count = self.message.send()
self.assertEqual(sent_count, 1) # The send call is "successful"...
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
if response[0]['status'] == 'queued':
self.skipTest("Mandrill queued the send -- can't complete this test")
self.assertEqual(response[0]['status'], 'rejected') # ... but the mail is not delivered
self.assertEqual(response[0]['reject_reason'], 'test') # ... and here's why
try:
self.message.send()
except MandrillRecipientsRefused:
# Mandrill refused to deliver the mail -- message.mandrill_response will tell you why:
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
self.assertEqual(response[0]['status'], 'rejected')
self.assertEqual(response[0]['reject_reason'], 'test')
else:
# Sometimes Mandrill queues these test sends
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
if response[0]['status'] == 'queued':
self.skipTest("Mandrill queued the send -- can't complete this test")
else:
self.fail("Djrill did not raise MandrillRecipientsRefused for blacklist recipient")
@override_settings(MANDRILL_API_KEY="Hey, that's not an API key!")
def test_invalid_api_key(self):

View File

@@ -18,7 +18,7 @@ from django.core.mail import make_msgid
from django.test import TestCase
from django.test.utils import override_settings
from djrill import MandrillAPIError, NotSupportedByMandrillError
from djrill import MandrillAPIError, MandrillRecipientsRefused, NotSupportedByMandrillError
from .mock_backend import DjrillBackendMockAPITestCase
@@ -515,7 +515,7 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
def test_send_attaches_mandrill_response(self):
""" The mandrill_response should be attached to the message when it is sent """
response = [{'mandrill_response': 'would_be_here'}]
response = [{'email': 'to1@example.com', 'status': 'sent'}]
self.mock_post.return_value = self.MockResponse(raw=six.b(json.dumps(response)))
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
sent = msg.send()
@@ -530,6 +530,14 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
self.assertEqual(sent, 0)
self.assertIsNone(msg.mandrill_response)
def test_send_unparsable_mandrill_response(self):
"""If the send succeeds, but a non-JSON API response, should raise an API exception"""
self.mock_post.return_value = self.MockResponse(status_code=500, raw=b"this isn't json")
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
with self.assertRaises(MandrillAPIError):
msg.send()
self.assertIsNone(msg.mandrill_response)
def test_json_serialization_errors(self):
"""Try to provide more information about non-json-serializable data"""
self.message.global_merge_vars = {'PRICE': Decimal('19.99')}
@@ -547,6 +555,51 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
self.message.send()
class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase):
"""Djrill raises MandrillRecipientsRefused when *all* recipients are rejected or invalid"""
def test_recipients_refused(self):
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'reject@test.mandrillapp.com'])
self.mock_post.return_value = self.MockResponse(status_code=200, raw=b"""
[{ "email": "invalid@localhost", "status": "invalid" },
{ "email": "reject@test.mandrillapp.com", "status": "rejected" }]""")
with self.assertRaises(MandrillRecipientsRefused):
msg.send()
def test_fail_silently(self):
self.mock_post.return_value = self.MockResponse(status_code=200, raw=b"""
[{ "email": "invalid@localhost", "status": "invalid" },
{ "email": "reject@test.mandrillapp.com", "status": "rejected" }]""")
sent = mail.send_mail('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'reject@test.mandrillapp.com'],
fail_silently=True)
self.assertEqual(sent, 0)
def test_mixed_response(self):
"""If *any* recipients are valid or queued, no exception is raised"""
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'valid@example.com',
'reject@test.mandrillapp.com', 'also.valid@example.com'])
self.mock_post.return_value = self.MockResponse(status_code=200, raw=b"""
[{ "email": "invalid@localhost", "status": "invalid" },
{ "email": "valid@example.com", "status": "sent" },
{ "email": "reject@test.mandrillapp.com", "status": "rejected" },
{ "email": "also.valid@example.com", "status": "queued" }]""")
sent = msg.send()
self.assertEqual(sent, 1) # one message sent, successfully, to 2 of 4 recipients
@override_settings(MANDRILL_IGNORE_RECIPIENT_STATUS=True)
def test_settings_override(self):
"""Setting restores Djrill 1.x behavior"""
self.mock_post.return_value = self.MockResponse(status_code=200, raw=b"""
[{ "email": "invalid@localhost", "status": "invalid" },
{ "email": "reject@test.mandrillapp.com", "status": "rejected" }]""")
sent = mail.send_mail('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'reject@test.mandrillapp.com'])
self.assertEqual(sent, 1) # refused message is included in sent count
@override_settings(MANDRILL_SETTINGS={
'from_name': 'Djrill Test',
'important': True,