Rename exceptions

* Change Djrill -> Mandrill in exception names
* Don't re-export at package level
  (import from anymail.exceptions, not from anymail)
This commit is contained in:
medmunds
2016-02-27 11:57:53 -08:00
parent 921dd5d0d6
commit 1c7fe8a759
6 changed files with 74 additions and 71 deletions

View File

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

View File

@@ -16,8 +16,8 @@ from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE
from .._version import __version__ from .._version import __version__
from ..exceptions import (DjrillError, MandrillAPIError, MandrillRecipientsRefused, from ..exceptions import (AnymailError, AnymailRequestsAPIError, AnymailRecipientsRefused,
NotSerializableForMandrillError, NotSupportedByMandrillError) AnymailSerializationError, AnymailUnsupportedFeature)
class MandrillBackend(BaseEmailBackend): class MandrillBackend(BaseEmailBackend):
@@ -127,8 +127,8 @@ class MandrillBackend(BaseEmailBackend):
message.mandrill_response = self.parse_response(response, payload, message) message.mandrill_response = self.parse_response(response, payload, message)
self.validate_response(message.mandrill_response, response, payload, message) self.validate_response(message.mandrill_response, response, payload, message)
except DjrillError: except AnymailError:
# every *expected* error is derived from DjrillError; # every *expected* error is derived from AnymailError;
# we deliberately don't silence unexpected errors # we deliberately don't silence unexpected errors
if not self.fail_silently: if not self.fail_silently:
raise raise
@@ -153,7 +153,7 @@ class MandrillBackend(BaseEmailBackend):
payload is a dict that will become the Mandrill send data payload is a dict that will become the Mandrill send data
message is an EmailMessage, possibly with additional Mandrill-specific attrs message is an EmailMessage, possibly with additional Mandrill-specific attrs
Can raise NotSupportedByMandrillError for unsupported options in message. Can raise AnymailUnsupportedFeature for unsupported options in message.
""" """
msg_dict = self._build_standard_message_dict(message) msg_dict = self._build_standard_message_dict(message)
self._add_mandrill_options(message, msg_dict) self._add_mandrill_options(message, msg_dict)
@@ -192,31 +192,31 @@ class MandrillBackend(BaseEmailBackend):
message is the original EmailMessage message is the original EmailMessage
return should be a requests.Response return should be a requests.Response
Can raise NotSerializableForMandrillError if payload is not serializable Can raise AnymailSerializationError if payload is not serializable
Can raise MandrillAPIError for HTTP errors in the post Can raise AnymailRequestsAPIError for HTTP errors in the post
""" """
api_url = self.get_api_url(payload, message) api_url = self.get_api_url(payload, message)
try: try:
json_payload = self.serialize_payload(payload, message) json_payload = self.serialize_payload(payload, message)
except TypeError as err: except TypeError as err:
# Add some context to the "not JSON serializable" message # Add some context to the "not JSON serializable" message
raise NotSerializableForMandrillError( raise AnymailSerializationError(
orig_err=err, email_message=message, payload=payload) orig_err=err, email_message=message, payload=payload)
response = self.session.post(api_url, data=json_payload) response = self.session.post(api_url, data=json_payload)
if response.status_code != 200: if response.status_code != 200:
raise MandrillAPIError(email_message=message, payload=payload, response=response) raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response)
return response return response
def parse_response(self, response, payload, message): def parse_response(self, response, payload, message):
"""Return parsed json from Mandrill API response """Return parsed json from Mandrill API response
Can raise MandrillAPIError if response is not valid JSON Can raise AnymailRequestsAPIError if response is not valid JSON
""" """
try: try:
return response.json() return response.json()
except ValueError: except ValueError:
raise MandrillAPIError("Invalid JSON in Mandrill API response", raise AnymailRequestsAPIError("Invalid JSON in Mandrill API response",
email_message=message, payload=payload, response=response) email_message=message, payload=payload, response=response)
def validate_response(self, parsed_response, response, payload, message): def validate_response(self, parsed_response, response, payload, message):
@@ -233,12 +233,12 @@ class MandrillBackend(BaseEmailBackend):
try: try:
recipient_status = [item["status"] for item in parsed_response] recipient_status = [item["status"] for item in parsed_response]
except (KeyError, TypeError): except (KeyError, TypeError):
raise MandrillAPIError("Invalid Mandrill API response format", raise AnymailRequestsAPIError("Invalid Mandrill API response format",
email_message=message, payload=payload, response=response) email_message=message, payload=payload, response=response)
# Error if *all* recipients are invalid or refused # Error if *all* recipients are invalid or refused
# (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend) # (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend)
if all([status in ('invalid', 'rejected') for status in recipient_status]): if all([status in ('invalid', 'rejected') for status in recipient_status]):
raise MandrillRecipientsRefused(email_message=message, payload=payload, response=response) raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response)
# #
# Payload construction # Payload construction
@@ -251,7 +251,7 @@ class MandrillBackend(BaseEmailBackend):
use by default. Standard text email messages sent through Django will use by default. Standard text email messages sent through Django will
still work through Mandrill. still work through Mandrill.
Raises NotSupportedByMandrillError for any standard EmailMessage Raises AnymailUnsupportedFeature for any standard EmailMessage
features that cannot be accurately communicated to Mandrill. features that cannot be accurately communicated to Mandrill.
""" """
sender = sanitize_address(message.from_email, message.encoding) sender = sanitize_address(message.from_email, message.encoding)
@@ -381,14 +381,14 @@ class MandrillBackend(BaseEmailBackend):
the HTML output for your email. the HTML output for your email.
""" """
if len(message.alternatives) > 1: if len(message.alternatives) > 1:
raise NotSupportedByMandrillError( raise AnymailUnsupportedFeature(
"Too many alternatives attached to the message. " "Too many alternatives attached to the message. "
"Mandrill only accepts plain text and html emails.", "Mandrill only accepts plain text and html emails.",
email_message=message) email_message=message)
(content, mimetype) = message.alternatives[0] (content, mimetype) = message.alternatives[0]
if mimetype != 'text/html': if mimetype != 'text/html':
raise NotSupportedByMandrillError( raise AnymailUnsupportedFeature(
"Invalid alternative mimetype '%s'. " "Invalid alternative mimetype '%s'. "
"Mandrill only accepts plain text and html emails." "Mandrill only accepts plain text and html emails."
% mimetype, % mimetype,

View File

@@ -2,28 +2,30 @@ import json
from requests import HTTPError from requests import HTTPError
class DjrillError(Exception): class AnymailError(Exception):
"""Base class for exceptions raised by Djrill """Base class for exceptions raised by Anymail
Overrides __str__ to provide additional information about Overrides __str__ to provide additional information about
Mandrill API call and response. the ESP API call and response.
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
""" """
Optional kwargs: Optional kwargs:
email_message: the original EmailMessage being sent email_message: the original EmailMessage being sent
payload: data arg (*not* json-stringified) for the Mandrill send call status_code: HTTP status code of response to ESP send call
payload: data arg (*not* json-stringified) for the ESP send call
response: requests.Response from the send call response: requests.Response from the send call
""" """
self.email_message = kwargs.pop('email_message', None) self.email_message = kwargs.pop('email_message', None)
self.payload = kwargs.pop('payload', None) self.payload = kwargs.pop('payload', None)
self.status_code = kwargs.pop('status_code', None)
if isinstance(self, HTTPError): if isinstance(self, HTTPError):
# must leave response in kwargs for HTTPError # must leave response in kwargs for HTTPError
self.response = kwargs.get('response', None) self.response = kwargs.get('response', None)
else: else:
self.response = kwargs.pop('response', None) self.response = kwargs.pop('response', None)
super(DjrillError, self).__init__(*args, **kwargs) super(AnymailError, self).__init__(*args, **kwargs)
def __str__(self): def __str__(self):
parts = [ parts = [
@@ -34,7 +36,7 @@ class DjrillError(Exception):
return "\n".join(filter(None, parts)) return "\n".join(filter(None, parts))
def describe_send(self): def describe_send(self):
"""Return a string describing the Mandrill send in self.payload, or None""" """Return a string describing the ESP send in self.payload, or None"""
if self.payload is None: if self.payload is None:
return None return None
description = "Sending a message" description = "Sending a message"
@@ -50,10 +52,10 @@ class DjrillError(Exception):
return description return description
def describe_response(self): def describe_response(self):
"""Return a formatted string of self.response, or None""" """Return a formatted string of self.status_code and response, or None"""
if self.response is None: if self.status_code is None:
return None return None
description = "Mandrill API response %d:" % self.response.status_code description = "ESP API response %d:" % self.status_code
try: try:
json_response = self.response.json() json_response = self.response.json()
description += "\n" + json.dumps(json_response, indent=2) description += "\n" + json.dumps(json_response, indent=2)
@@ -65,53 +67,56 @@ class DjrillError(Exception):
return description return description
class MandrillAPIError(DjrillError, HTTPError): class AnymailAPIError(AnymailError):
"""Exception for unsuccessful response from Mandrill API.""" """Exception for unsuccessful response from ESP's API."""
class AnymailRequestsAPIError(AnymailAPIError, HTTPError):
"""Exception for unsuccessful response from a requests API."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(MandrillAPIError, self).__init__(*args, **kwargs) super(AnymailRequestsAPIError, self).__init__(*args, **kwargs)
if self.response is not None: if self.response is not None:
self.status_code = self.response.status_code self.status_code = self.response.status_code
class MandrillRecipientsRefused(DjrillError): class AnymailRecipientsRefused(AnymailError):
"""Exception for send where all recipients are invalid or rejected.""" """Exception for send where all recipients are invalid or rejected."""
def __init__(self, message=None, *args, **kwargs): def __init__(self, message=None, *args, **kwargs):
if message is None: if message is None:
message = "All message recipients were rejected or invalid" message = "All message recipients were rejected or invalid"
super(MandrillRecipientsRefused, self).__init__(message, *args, **kwargs) super(AnymailRecipientsRefused, self).__init__(message, *args, **kwargs)
class NotSupportedByMandrillError(DjrillError, ValueError): class AnymailUnsupportedFeature(AnymailError, ValueError):
"""Exception for email features that Mandrill doesn't support. """Exception for Anymail features that the ESP doesn't support.
This is typically raised when attempting to send a Django EmailMessage that 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 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., non-HTML ignored by or can't be communicated to the ESP's API.
alternative parts.)
It's generally *not* raised for Mandrill-specific features, like limitations It's generally *not* raised for ESP-specific limitations, like the number
on Mandrill tag names or restrictions on from emails. (Djrill expects of tags allowed on a message. (Anymail expects
Mandrill to return an API error for these where appropriate, and tries to the ESP to return an API error for these where appropriate, and tries to
avoid duplicating Mandrill's validation logic locally.) avoid duplicating each ESP's validation logic locally.)
""" """
class NotSerializableForMandrillError(DjrillError, TypeError): class AnymailSerializationError(AnymailError, TypeError):
"""Exception for data that Djrill doesn't know how to convert to JSON. """Exception for data that Anymail can't serialize for the ESP's API.
This typically results from including something like a date or Decimal This typically results from including something like a date or Decimal
in your merge_vars (or other Mandrill-specific EmailMessage option). in your merge_vars.
""" """
# inherits from TypeError for backwards compatibility with Djrill 1.x # inherits from TypeError for compatibility with JSON serialization error
def __init__(self, message=None, orig_err=None, *args, **kwargs): def __init__(self, message=None, orig_err=None, *args, **kwargs):
if message is None: if message is None:
message = "Don't know how to send this data to Mandrill. " \ message = "Don't know how to send this data to your ESP. " \
"Try converting it to a string or number first." "Try converting it to a string or number first."
if orig_err is not None: if orig_err is not None:
message += "\n%s" % str(orig_err) message += "\n%s" % str(orig_err)
super(NotSerializableForMandrillError, self).__init__(message, *args, **kwargs) super(AnymailSerializationError, self).__init__(message, *args, **kwargs)

View File

@@ -7,7 +7,7 @@ from django.core import mail
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from anymail import MandrillAPIError, MandrillRecipientsRefused from anymail.exceptions import AnymailAPIError, AnymailRecipientsRefused
MANDRILL_TEST_API_KEY = os.getenv('MANDRILL_TEST_API_KEY') MANDRILL_TEST_API_KEY = os.getenv('MANDRILL_TEST_API_KEY')
@@ -51,7 +51,7 @@ class DjrillIntegrationTests(TestCase):
try: try:
self.message.send() self.message.send()
self.fail("This line will not be reached, because send() raised an exception") self.fail("This line will not be reached, because send() raised an exception")
except MandrillAPIError as err: except AnymailAPIError as err:
self.assertEqual(err.status_code, 500) self.assertEqual(err.status_code, 500)
self.assertIn("email address is invalid", str(err)) self.assertIn("email address is invalid", str(err))
@@ -60,7 +60,7 @@ class DjrillIntegrationTests(TestCase):
self.message.to = ['invalid@localhost'] self.message.to = ['invalid@localhost']
try: try:
self.message.send() self.message.send()
except MandrillRecipientsRefused: except AnymailRecipientsRefused:
# Mandrill refused to deliver the mail -- message.mandrill_response will tell you why: # Mandrill refused to deliver the mail -- message.mandrill_response will tell you why:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
response = self.message.mandrill_response response = self.message.mandrill_response
@@ -72,14 +72,14 @@ class DjrillIntegrationTests(TestCase):
if response[0]['status'] == 'queued': if response[0]['status'] == 'queued':
self.skipTest("Mandrill queued the send -- can't complete this test") self.skipTest("Mandrill queued the send -- can't complete this test")
else: else:
self.fail("Djrill did not raise MandrillRecipientsRefused for invalid recipient") self.fail("Djrill did not raise AnymailRecipientsRefused for invalid recipient")
def test_rejected_to(self): def test_rejected_to(self):
# Example of detecting when a recipient is on Mandrill's rejection blacklist # Example of detecting when a recipient is on Mandrill's rejection blacklist
self.message.to = ['reject@test.mandrillapp.com'] self.message.to = ['reject@test.mandrillapp.com']
try: try:
self.message.send() self.message.send()
except MandrillRecipientsRefused: except AnymailRecipientsRefused:
# Mandrill refused to deliver the mail -- message.mandrill_response will tell you why: # Mandrill refused to deliver the mail -- message.mandrill_response will tell you why:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
response = self.message.mandrill_response response = self.message.mandrill_response
@@ -92,7 +92,7 @@ class DjrillIntegrationTests(TestCase):
if response[0]['status'] == 'queued': if response[0]['status'] == 'queued':
self.skipTest("Mandrill queued the send -- can't complete this test") self.skipTest("Mandrill queued the send -- can't complete this test")
else: else:
self.fail("Djrill did not raise MandrillRecipientsRefused for blacklist recipient") self.fail("Djrill did not raise AnymailRecipientsRefused for blacklist recipient")
@override_settings(MANDRILL_API_KEY="Hey, that's not an API key!") @override_settings(MANDRILL_API_KEY="Hey, that's not an API key!")
def test_invalid_api_key(self): def test_invalid_api_key(self):
@@ -100,6 +100,6 @@ class DjrillIntegrationTests(TestCase):
try: try:
self.message.send() self.message.send()
self.fail("This line will not be reached, because send() raised an exception") self.fail("This line will not be reached, because send() raised an exception")
except MandrillAPIError as err: except AnymailAPIError as err:
self.assertEqual(err.status_code, 500) self.assertEqual(err.status_code, 500)
self.assertIn("Invalid API key", str(err)) self.assertIn("Invalid API key", str(err))

View File

@@ -19,8 +19,8 @@ from django.core.mail import make_msgid
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from anymail import (MandrillAPIError, MandrillRecipientsRefused, from anymail.exceptions import (AnymailAPIError, AnymailRecipientsRefused,
NotSerializableForMandrillError, NotSupportedByMandrillError) AnymailSerializationError, AnymailUnsupportedFeature)
from .mock_backend import DjrillBackendMockAPITestCase from .mock_backend import DjrillBackendMockAPITestCase
@@ -275,14 +275,14 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
'from@example.com', ['to@example.com']) 'from@example.com', ['to@example.com'])
email.attach_alternative("<p>First html is OK</p>", "text/html") email.attach_alternative("<p>First html is OK</p>", "text/html")
email.attach_alternative("<p>But not second html</p>", "text/html") email.attach_alternative("<p>But not second html</p>", "text/html")
with self.assertRaises(NotSupportedByMandrillError): with self.assertRaises(AnymailUnsupportedFeature):
email.send() email.send()
# Only html alternatives allowed # Only html alternatives allowed
email = mail.EmailMultiAlternatives('Subject', 'Body', email = mail.EmailMultiAlternatives('Subject', 'Body',
'from@example.com', ['to@example.com']) 'from@example.com', ['to@example.com'])
email.attach_alternative("{'not': 'allowed'}", "application/json") email.attach_alternative("{'not': 'allowed'}", "application/json")
with self.assertRaises(NotSupportedByMandrillError): with self.assertRaises(AnymailUnsupportedFeature):
email.send() email.send()
# Make sure fail_silently is respected # Make sure fail_silently is respected
@@ -296,7 +296,7 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
def test_mandrill_api_failure(self): def test_mandrill_api_failure(self):
self.mock_post.return_value = self.MockResponse(status_code=400) self.mock_post.return_value = self.MockResponse(status_code=400)
with self.assertRaises(MandrillAPIError): with self.assertRaises(AnymailAPIError):
sent = mail.send_mail('Subject', 'Body', 'from@example.com', sent = mail.send_mail('Subject', 'Body', 'from@example.com',
['to@example.com']) ['to@example.com'])
self.assertEqual(sent, 0) self.assertEqual(sent, 0)
@@ -319,17 +319,17 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
"message": "Helpful explanation from Mandrill" "message": "Helpful explanation from Mandrill"
}""" }"""
self.mock_post.return_value = self.MockResponse(status_code=400, raw=error_response) self.mock_post.return_value = self.MockResponse(status_code=400, raw=error_response)
with self.assertRaisesMessage(MandrillAPIError, "Helpful explanation from Mandrill"): with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from Mandrill"):
msg.send() msg.send()
# Non-JSON error response: # Non-JSON error response:
self.mock_post.return_value = self.MockResponse(status_code=500, raw=b"Invalid API key") self.mock_post.return_value = self.MockResponse(status_code=500, raw=b"Invalid API key")
with self.assertRaisesMessage(MandrillAPIError, "Invalid API key"): with self.assertRaisesMessage(AnymailAPIError, "Invalid API key"):
msg.send() msg.send()
# No content in the error response: # No content in the error response:
self.mock_post.return_value = self.MockResponse(status_code=502, raw=None) self.mock_post.return_value = self.MockResponse(status_code=502, raw=None)
with self.assertRaises(MandrillAPIError): with self.assertRaises(AnymailAPIError):
msg.send() msg.send()
@@ -539,29 +539,29 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
"""If the send succeeds, but a non-JSON API response, should raise an API exception""" """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") 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'],) msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
with self.assertRaises(MandrillAPIError): with self.assertRaises(AnymailAPIError):
msg.send() msg.send()
self.assertIsNone(msg.mandrill_response) self.assertIsNone(msg.mandrill_response)
def test_json_serialization_errors(self): def test_json_serialization_errors(self):
"""Try to provide more information about non-json-serializable data""" """Try to provide more information about non-json-serializable data"""
self.message.global_merge_vars = {'PRICE': Decimal('19.99')} self.message.global_merge_vars = {'PRICE': Decimal('19.99')}
with self.assertRaises(NotSerializableForMandrillError) as cm: with self.assertRaises(AnymailSerializationError) as cm:
self.message.send() self.message.send()
err = cm.exception err = cm.exception
self.assertTrue(isinstance(err, TypeError)) # Djrill 1.x re-raised TypeError from json.dumps self.assertTrue(isinstance(err, TypeError)) # Djrill 1.x re-raised TypeError from json.dumps
self.assertStrContains(str(err), "Don't know how to send this data to Mandrill") # our added context self.assertStrContains(str(err), "Don't know how to send this data to your ESP") # our added context
self.assertStrContains(str(err), "Decimal('19.99') is not JSON serializable") # original message self.assertStrContains(str(err), "Decimal('19.99') is not JSON serializable") # original message
def test_dates_not_serialized(self): def test_dates_not_serialized(self):
"""Pre-2.0 Djrill accidentally serialized dates to ISO""" """Pre-2.0 Djrill accidentally serialized dates to ISO"""
self.message.global_merge_vars = {'SHIP_DATE': date(2015, 12, 2)} self.message.global_merge_vars = {'SHIP_DATE': date(2015, 12, 2)}
with self.assertRaises(NotSerializableForMandrillError): with self.assertRaises(AnymailSerializationError):
self.message.send() self.message.send()
class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase): class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase):
"""Djrill raises MandrillRecipientsRefused when *all* recipients are rejected or invalid""" """Djrill raises AnymailRecipientsRefused when *all* recipients are rejected or invalid"""
def test_recipients_refused(self): def test_recipients_refused(self):
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', msg = mail.EmailMessage('Subject', 'Body', 'from@example.com',
@@ -569,7 +569,7 @@ class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase):
self.mock_post.return_value = self.MockResponse(status_code=200, raw=b""" self.mock_post.return_value = self.MockResponse(status_code=200, raw=b"""
[{ "email": "invalid@localhost", "status": "invalid" }, [{ "email": "invalid@localhost", "status": "invalid" },
{ "email": "reject@test.mandrillapp.com", "status": "rejected" }]""") { "email": "reject@test.mandrillapp.com", "status": "rejected" }]""")
with self.assertRaises(MandrillRecipientsRefused): with self.assertRaises(AnymailRecipientsRefused):
msg.send() msg.send()
def test_fail_silently(self): def test_fail_silently(self):

View File

@@ -1,6 +1,6 @@
from django.core import mail from django.core import mail
from anymail import MandrillAPIError from anymail.exceptions import AnymailAPIError
from .mock_backend import DjrillBackendMockAPITestCase from .mock_backend import DjrillBackendMockAPITestCase
@@ -47,7 +47,7 @@ class DjrillMandrillSendTemplateTests(DjrillBackendMockAPITestCase):
'from@example.com', ['to@example.com']) 'from@example.com', ['to@example.com'])
msg.template_name = "PERSONALIZED_SPECIALS" msg.template_name = "PERSONALIZED_SPECIALS"
msg.use_template_from = True msg.use_template_from = True
with self.assertRaises(MandrillAPIError): with self.assertRaises(AnymailAPIError):
msg.send() msg.send()
def test_send_template_without_subject_field(self): def test_send_template_without_subject_field(self):