mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
"key": self.api_key,
|
||||
}
|
||||
|
||||
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, msg_dict)
|
||||
self._add_attachments(message, msg_dict)
|
||||
api_params['message'] = msg_dict
|
||||
payload = self.get_base_payload()
|
||||
self.build_send_payload(payload, message)
|
||||
response = self.post_to_mandrill(payload, message)
|
||||
|
||||
# check if template is set in message to send it via
|
||||
# api url: /messages/send-template.json
|
||||
if hasattr(message, 'template_name'):
|
||||
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', {}))
|
||||
# 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)
|
||||
|
||||
self._add_mandrill_toplevel_options(message, api_params)
|
||||
|
||||
except NotSupportedByMandrillError:
|
||||
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
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Reference in New Issue
Block a user