Refactor backend

* Break apart massive _send call
* Try to facilitate subclassing
* Centralize fail_silently handling during _send
* Include original EmailMessage as exception attr
This commit is contained in:
medmunds
2015-12-02 12:33:27 -08:00
parent b8cdc6ce82
commit fe1e2d1ae5
3 changed files with 143 additions and 74 deletions

View File

@@ -12,9 +12,11 @@ class DjrillError(Exception):
def __init__(self, *args, **kwargs):
"""
Optional kwargs:
email_message: the original EmailMessage being sent
payload: data arg (*not* json-stringified) for the Mandrill send call
response: requests.Response from the send call
"""
self.email_message = kwargs.pop('email_message', None)
self.payload = kwargs.pop('payload', None)
if isinstance(self, HTTPError):
# must leave response in kwargs for HTTPError
@@ -57,7 +59,7 @@ class DjrillError(Exception):
description += "\n" + json.dumps(json_response, indent=2)
except (AttributeError, KeyError, ValueError): # not JSON = ValueError
try:
description += self.response.text
description += " " + self.response.text
except AttributeError:
pass
return description

View File

@@ -12,30 +12,10 @@ 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, MandrillRecipientsRefused,
from ...exceptions import (DjrillError, MandrillAPIError, MandrillRecipientsRefused,
NotSerializableForMandrillError, NotSupportedByMandrillError)
def encode_date_for_mandrill(dt):
"""Format a date or datetime for use as a Mandrill API date field
datetime becomes "YYYY-MM-DD HH:MM:SS"
converted to UTC, if timezone-aware
microseconds removed
date becomes "YYYY-MM-DD 00:00:00"
anything else gets returned intact
"""
if isinstance(dt, datetime):
dt = dt.replace(microsecond=0)
if dt.utcoffset() is not None:
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
return dt.isoformat(' ')
elif isinstance(dt, date):
return dt.isoformat() + ' 00:00:00'
else:
return dt
class DjrillBackend(BaseEmailBackend):
"""
Mandrill API Email Backend
@@ -63,9 +43,6 @@ class DjrillBackend(BaseEmailBackend):
raise ImproperlyConfigured("You have not set your mandrill api key "
"in the settings.py file.")
self.api_send = self.api_url + "/messages/send.json"
self.api_send_template = self.api_url + "/messages/send-template.json"
def open(self):
"""
Ensure we have a requests Session to connect to the Mandrill API.
@@ -127,72 +104,133 @@ class DjrillBackend(BaseEmailBackend):
def _send(self, message):
message.mandrill_response = None # until we have a response
if not message.recipients():
return False
api_url = self.api_send
api_params = {
try:
payload = self.get_base_payload()
self.build_send_payload(payload, message)
response = self.post_to_mandrill(payload, message)
# add the response from mandrill to the EmailMessage so callers can inspect it
message.mandrill_response = self.parse_response(response, payload, message)
self.validate_response(message.mandrill_response, response, payload, message)
except DjrillError:
# every *expected* error is derived from DjrillError;
# we deliberately don't silence unexpected errors
if not self.fail_silently:
raise
return False
return True
def get_base_payload(self):
"""Return non-message-dependent payload for Mandrill send call
(The return value will be modified for the send, so must be a copy
of any shared state.)
"""
payload = {
"key": self.api_key,
}
return payload
try:
def build_send_payload(self, payload, message):
"""Modify payload to add all message-specific options for Mandrill send call.
payload is a dict that will become the Mandrill send data
message is an EmailMessage, possibly with additional Mandrill-specific attrs
Can raise NotSupportedByMandrillError for unsupported options in message.
"""
msg_dict = self._build_standard_message_dict(message)
self._add_mandrill_options(message, msg_dict)
if getattr(message, 'alternatives', None):
self._add_alternatives(message, msg_dict)
self._add_attachments(message, msg_dict)
api_params['message'] = msg_dict
# check if template is set in message to send it via
# api url: /messages/send-template.json
payload.setdefault('message', {}).update(msg_dict)
if hasattr(message, 'template_name'):
api_url = self.api_send_template
api_params['template_name'] = message.template_name
api_params['template_content'] = \
payload['template_name'] = message.template_name
payload['template_content'] = \
self._expand_merge_vars(getattr(message, 'template_content', {}))
self._add_mandrill_toplevel_options(message, payload)
self._add_mandrill_toplevel_options(message, api_params)
def get_api_url(self, payload, message):
"""Return the correct Mandrill API url for sending payload
except NotSupportedByMandrillError:
if not self.fail_silently:
raise
return False
Override this to substitute your own logic for determining API endpoint.
"""
if 'template_name' in payload:
return self.api_url + "/messages/send-template.json"
else:
return self.api_url + "/messages/send.json"
def serialize_payload(self, payload, message):
"""Return payload serialized to a json str.
Override this to substitute your own JSON serializer (e.g., to handle dates)
"""
return json.dumps(payload)
def post_to_mandrill(self, payload, message):
"""Post payload to correct Mandrill send API endpoint, and return the response.
payload is a dict to use as Mandrill send data
message is the original EmailMessage
return should be a requests.Response
Can raise NotSerializableForMandrillError if payload is not serializable
Can raise MandrillAPIError for HTTP errors in the post
"""
api_url = self.get_api_url(payload, message)
try:
api_data = json.dumps(api_params)
json_payload = self.serialize_payload(payload, message)
except TypeError as err:
if self.fail_silently:
return False
# Add some context to the "not JSON serializable" message
raise NotSerializableForMandrillError(orig_err=err)
response = self.session.post(api_url, data=api_data)
raise NotSerializableForMandrillError(
orig_err=err, email_message=message, payload=payload)
response = self.session.post(api_url, data=json_payload)
if response.status_code != 200:
if not self.fail_silently:
raise MandrillAPIError(payload=api_params, response=response)
return False
raise MandrillAPIError(email_message=message, payload=payload, response=response)
return response
# add the response from mandrill to the EmailMessage so callers can inspect it
def parse_response(self, response, payload, message):
"""Return parsed json from Mandrill API response
Can raise MandrillAPIError if response is not valid 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("Error parsing Mandrill API response",
payload=api_params, response=response)
return False
return response.json()
except ValueError:
raise MandrillAPIError("Invalid JSON in Mandrill API response",
email_message=message, payload=payload, response=response)
def validate_response(self, parsed_response, response, payload, message):
"""Validate parsed_response, raising exceptions for any problems.
Extend this to provide your own validation checks.
Validation exceptions should inherit from djrill.exceptions.DjrillException
for proper fail_silently behavior.
The base version here checks for invalid or refused recipients.
"""
if self.ignore_recipient_status:
return
try:
recipient_status = [item["status"] for item in parsed_response]
except (KeyError, TypeError):
raise MandrillAPIError("Invalid Mandrill API response format",
email_message=message, payload=payload, response=response)
# 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(payload=api_params, response=response)
return False
if all([status in ('invalid', 'rejected') for status in recipient_status]):
raise MandrillRecipientsRefused(email_message=message, payload=payload, response=response)
return True
#
# Payload construction
#
def _build_standard_message_dict(self, message):
"""Create a Mandrill send message struct from a Django EmailMessage.
@@ -251,8 +289,7 @@ class DjrillBackend(BaseEmailBackend):
# Mandrill attributes that require conversion:
if hasattr(message, 'send_at'):
api_params['send_at'] = encode_date_for_mandrill(message.send_at)
api_params['send_at'] = self.encode_date_for_mandrill(message.send_at)
def _make_mandrill_to_list(self, message, recipients, recipient_type="to"):
"""Create a Mandrill 'to' field from a list of emails.
@@ -335,14 +372,16 @@ class DjrillBackend(BaseEmailBackend):
if len(message.alternatives) > 1:
raise NotSupportedByMandrillError(
"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)
(content, mimetype) = message.alternatives[0]
if mimetype != 'text/html':
raise NotSupportedByMandrillError(
"Invalid alternative mimetype '%s'. "
"Mandrill only accepts plain text and html emails."
% mimetype)
% mimetype,
email_message=message)
msg_dict['html'] = content
@@ -413,3 +452,23 @@ class DjrillBackend(BaseEmailBackend):
'content': content_b64.decode('ascii'),
}
return mandrill_attachment, is_embedded_image
@classmethod
def encode_date_for_mandrill(cls, dt):
"""Format a date or datetime for use as a Mandrill API date field
datetime becomes "YYYY-MM-DD HH:MM:SS"
converted to UTC, if timezone-aware
microseconds removed
date becomes "YYYY-MM-DD 00:00:00"
anything else gets returned intact
"""
if isinstance(dt, datetime):
dt = dt.replace(microsecond=0)
if dt.utcoffset() is not None:
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
return dt.isoformat(' ')
elif isinstance(dt, date):
return dt.isoformat() + ' 00:00:00'
else:
return dt

View File

@@ -79,6 +79,14 @@ Removed DjrillBackendHTTPError
This exception was deprecated in Djrill 0.3. Replace uses of it
with :exc:`djrill.MandrillAPIError`.
Refactored Djrill backend and exceptions
Several internal details of ``djrill.mail.backends.DjrillBackend``
and Djrill's exception classes have been significantly updated for 2.0.
The intent is to make it easier to maintain and extend the backend
(including creating your own subclasses to override Djrill's default
behavior). As a result, though, any existing code that depended on
undocumented Djrill internals may need to be updated.
Other Djrill 2.0 Changes
~~~~~~~~~~~~~~~~~~~~~~~~