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): def __init__(self, *args, **kwargs):
""" """
Optional kwargs: Optional kwargs:
email_message: the original EmailMessage being sent
payload: data arg (*not* json-stringified) for the Mandrill send call payload: data arg (*not* json-stringified) for the Mandrill 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.payload = kwargs.pop('payload', None) self.payload = kwargs.pop('payload', None)
if isinstance(self, HTTPError): if isinstance(self, HTTPError):
# must leave response in kwargs for HTTPError # must leave response in kwargs for HTTPError
@@ -57,7 +59,7 @@ class DjrillError(Exception):
description += "\n" + json.dumps(json_response, indent=2) description += "\n" + json.dumps(json_response, indent=2)
except (AttributeError, KeyError, ValueError): # not JSON = ValueError except (AttributeError, KeyError, ValueError): # not JSON = ValueError
try: try:
description += self.response.text description += " " + self.response.text
except AttributeError: except AttributeError:
pass pass
return description 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 django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE
from ..._version import __version__ from ..._version import __version__
from ...exceptions import (MandrillAPIError, MandrillRecipientsRefused, from ...exceptions import (DjrillError, MandrillAPIError, MandrillRecipientsRefused,
NotSerializableForMandrillError, NotSupportedByMandrillError) 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): class DjrillBackend(BaseEmailBackend):
""" """
Mandrill API Email Backend Mandrill API Email Backend
@@ -63,9 +43,6 @@ class DjrillBackend(BaseEmailBackend):
raise ImproperlyConfigured("You have not set your mandrill api key " raise ImproperlyConfigured("You have not set your mandrill api key "
"in the settings.py file.") "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): def open(self):
""" """
Ensure we have a requests Session to connect to the Mandrill API. Ensure we have a requests Session to connect to the Mandrill API.
@@ -127,72 +104,133 @@ class DjrillBackend(BaseEmailBackend):
def _send(self, message): def _send(self, message):
message.mandrill_response = None # until we have a response message.mandrill_response = None # until we have a response
if not message.recipients(): if not message.recipients():
return False return False
api_url = self.api_send
api_params = {
"key": self.api_key,
}
try: try:
msg_dict = self._build_standard_message_dict(message) payload = self.get_base_payload()
self._add_mandrill_options(message, msg_dict) self.build_send_payload(payload, message)
if getattr(message, 'alternatives', None): response = self.post_to_mandrill(payload, message)
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 # add the response from mandrill to the EmailMessage so callers can inspect it
# api url: /messages/send-template.json message.mandrill_response = self.parse_response(response, payload, message)
if hasattr(message, 'template_name'): self.validate_response(message.mandrill_response, response, payload, message)
api_url = self.api_send_template
api_params['template_name'] = message.template_name
api_params['template_content'] = \
self._expand_merge_vars(getattr(message, 'template_content', {}))
self._add_mandrill_toplevel_options(message, api_params) except DjrillError:
# every *expected* error is derived from DjrillError;
except NotSupportedByMandrillError: # we deliberately don't silence unexpected errors
if not self.fail_silently: if not self.fail_silently:
raise raise
return False 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
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)
payload.setdefault('message', {}).update(msg_dict)
if hasattr(message, 'template_name'):
payload['template_name'] = message.template_name
payload['template_content'] = \
self._expand_merge_vars(getattr(message, 'template_content', {}))
self._add_mandrill_toplevel_options(message, payload)
def get_api_url(self, payload, message):
"""Return the correct Mandrill API url for sending payload
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: try:
api_data = json.dumps(api_params) json_payload = self.serialize_payload(payload, message)
except TypeError as err: except TypeError as err:
if self.fail_silently:
return False
# Add some context to the "not JSON serializable" message # Add some context to the "not JSON serializable" message
raise NotSerializableForMandrillError(orig_err=err) raise NotSerializableForMandrillError(
orig_err=err, email_message=message, payload=payload)
response = self.session.post(api_url, data=api_data)
response = self.session.post(api_url, data=json_payload)
if response.status_code != 200: if response.status_code != 200:
if not self.fail_silently: raise MandrillAPIError(email_message=message, payload=payload, response=response)
raise MandrillAPIError(payload=api_params, response=response) return response
return False
# 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: try:
message.mandrill_response = response.json() return response.json()
recipient_status = [item["status"] for item in message.mandrill_response] except ValueError:
except (ValueError, KeyError): raise MandrillAPIError("Invalid JSON in Mandrill API response",
if not self.fail_silently: email_message=message, payload=payload, response=response)
raise MandrillAPIError("Error parsing Mandrill API response",
payload=api_params, response=response)
return False
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 # 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 (not self.ignore_recipient_status and if all([status in ('invalid', 'rejected') for status in recipient_status]):
all([status in ('invalid', 'rejected') for status in recipient_status])): raise MandrillRecipientsRefused(email_message=message, payload=payload, response=response)
if not self.fail_silently:
raise MandrillRecipientsRefused(payload=api_params, response=response)
return False
return True #
# Payload construction
#
def _build_standard_message_dict(self, message): def _build_standard_message_dict(self, message):
"""Create a Mandrill send message struct from a Django EmailMessage. """Create a Mandrill send message struct from a Django EmailMessage.
@@ -251,8 +289,7 @@ class DjrillBackend(BaseEmailBackend):
# Mandrill attributes that require conversion: # Mandrill attributes that require conversion:
if hasattr(message, 'send_at'): 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"): def _make_mandrill_to_list(self, message, recipients, recipient_type="to"):
"""Create a Mandrill 'to' field from a list of emails. """Create a Mandrill 'to' field from a list of emails.
@@ -335,14 +372,16 @@ class DjrillBackend(BaseEmailBackend):
if len(message.alternatives) > 1: if len(message.alternatives) > 1:
raise NotSupportedByMandrillError( raise NotSupportedByMandrillError(
"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)
(content, mimetype) = message.alternatives[0] (content, mimetype) = message.alternatives[0]
if mimetype != 'text/html': if mimetype != 'text/html':
raise NotSupportedByMandrillError( raise NotSupportedByMandrillError(
"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,
email_message=message)
msg_dict['html'] = content msg_dict['html'] = content
@@ -413,3 +452,23 @@ class DjrillBackend(BaseEmailBackend):
'content': content_b64.decode('ascii'), 'content': content_b64.decode('ascii'),
} }
return mandrill_attachment, is_embedded_image 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 This exception was deprecated in Djrill 0.3. Replace uses of it
with :exc:`djrill.MandrillAPIError`. 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 Other Djrill 2.0 Changes
~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~