Start factoring out common backend functionality

This commit is contained in:
medmunds
2016-02-29 11:52:35 -08:00
parent 1c7fe8a759
commit ef971489cd
4 changed files with 297 additions and 164 deletions

248
anymail/backends/base.py Normal file
View File

@@ -0,0 +1,248 @@
import json
import requests
from django.core.mail.backends.base import BaseEmailBackend
from .._version import __version__
from ..exceptions import AnymailError, AnymailRequestsAPIError, AnymailSerializationError, AnymailImproperlyConfigured
class AnymailBaseBackend(BaseEmailBackend):
"""
Base Anymail email backend
"""
def open(self):
"""
Open and persist a connection to the ESP's API, and whether
a new connection was created.
Callers must ensure they later call close, if (and only if) open
returns True.
"""
# Subclasses should use an instance property to maintain a cached
# connection, and return True iff they initialize that instance
# property in _this_ open call. (If the cached connection already
# exists, just do nothing and return False.)
#
# Subclasses should swallow operational errors if self.fail_silently
# (e.g., network errors), but otherwise can raise any errors.
#
# (Returning a bool to indicate whether connection was created is
# borrowed from django.core.email.backends.SMTPBackend)
return False
def close(self):
"""
Close the cached connection created by open.
You must only call close if your code called open and it returned True.
"""
# Subclasses should tear down the cached connection and clear
# the instance property.
#
# Subclasses should swallow operational errors if self.fail_silently
# (e.g., network errors), but otherwise can raise any errors.
pass
def send_messages(self, email_messages):
"""
Sends one or more EmailMessage objects and returns the number of email
messages sent.
"""
# This API is specified by Django's core BaseEmailBackend
# (so you can't change it to, e.g., return detailed status).
# Subclasses shouldn't need to override.
num_sent = 0
if not email_messages:
return num_sent
created_session = self.open()
try:
for message in email_messages:
try:
sent = self._send(message)
except AnymailError:
if self.fail_silently:
sent = False
else:
raise
if sent:
num_sent += 1
finally:
if created_session:
self.close()
return num_sent
def _send(self, message):
"""Sends the EmailMessage message, and returns True if the message was sent.
This should only be called by the base send_messages loop.
Implementations must raise exceptions derived from AnymailError for
anticipated failures that should be suppressed in fail_silently mode.
"""
message.anymail_status = None
esp_response_attr = "%s_response" % self.esp_name.lower() # e.g., message.mandrill_response
setattr(message, esp_response_attr, None) # until we have a response
if not message.recipients():
return False
payload = self.build_message_payload(message)
# FUTURE: if pre-send-signal OK...
response = self.post_to_esp(payload, message)
parsed_response = self.deserialize_response(response, payload, message)
setattr(message, esp_response_attr, parsed_response)
message.anymail_status = self.validate_response(parsed_response, response, payload, message)
# FUTURE: post-send signal
return True
def build_message_payload(self, message):
"""Return a payload with all message-specific options for ESP send call.
message is an EmailMessage, possibly with additional Anymail-specific attrs
Can raise AnymailUnsupportedFeature for unsupported options in message.
"""
raise NotImplementedError("%s.%s must implement build_message_payload" %
(self.__class__.__module__, self.__class__.__name__))
def post_to_esp(self, payload, message):
"""Post payload to ESP send API endpoint, and return the raw response.
payload is the result of build_message_payload
message is the original EmailMessage
return should be a raw response
Can raise AnymailAPIError (or derived exception) for problems posting to the ESP
"""
raise NotImplementedError("%s.%s must implement build_message_payload" %
(self.__class__.__module__, self.__class__.__name__))
def deserialize_response(self, response, payload, message):
"""Deserialize a raw ESP response
Can raise AnymailAPIError (or derived exception) if response is unparsable
"""
raise NotImplementedError("%s.%s must implement build_message_payload" %
(self.__class__.__module__, self.__class__.__name__))
def validate_response(self, parsed_response, response, payload, message):
"""Validate parsed_response, raising exceptions for any problems, and return normalized status.
Extend this to provide your own validation checks.
Validation exceptions should inherit from anymail.exceptions.AnymailError
for proper fail_silently behavior.
If *all* recipients are refused or invalid, should raise AnymailRecipientsRefused
Returns one of "sent", "queued", "refused", "error" or "multi"
"""
raise NotImplementedError("%s.%s must implement validate_response" %
(self.__class__.__module__, self.__class__.__name__))
@property
def esp_name(self):
"""
Read-only name of the ESP for this backend.
(E.g., MailgunBackend will return "Mailgun")
"""
return self.__class__.__name__.replace("Backend", "")
class AnymailRequestsBackend(AnymailBaseBackend):
"""
Base Anymail email backend for ESPs that use an HTTP API via requests
"""
def __init__(self, api_url, **kwargs):
"""Init options from Django settings"""
self.api_url = api_url
super(AnymailRequestsBackend, self).__init__(**kwargs)
self.session = None
def open(self):
if self.session:
return False # already exists
try:
self.session = requests.Session()
except requests.RequestException:
if not self.fail_silently:
raise
else:
self.session.headers["User-Agent"] = "Anymail/%s %s" % (
__version__, self.session.headers.get("User-Agent", ""))
return True
def close(self):
if self.session is None:
return
try:
self.session.close()
except requests.RequestException:
if not self.fail_silently:
raise
finally:
self.session = None
def _send(self, message):
if self.session is None:
class_name = self.__class__.__name__
raise RuntimeError(
"Session has not been opened in {class_name}._send. "
"(This is either an implementation error in {class_name}, "
"or you are incorrectly calling _send directly.)".format(class_name=class_name))
return super(AnymailRequestsBackend, self)._send(message)
def get_api_url(self, payload, message):
"""Return the correct ESP url for sending payload
Override this to substitute your own logic for determining API endpoint.
"""
return self.api_url
def serialize_payload(self, payload, message):
"""Return payload serialized to post data.
Should raise AnymailSerializationError if payload is not serializable
"""
try:
return json.dumps(payload)
except TypeError as err:
# Add some context to the "not JSON serializable" message
raise AnymailSerializationError(orig_err=err, email_message=message, payload=payload)
def post_to_esp(self, payload, message):
"""Post payload to ESP send API endpoint, and return the raw response.
payload is the result of build_message_payload
message is the original EmailMessage
return should be a requests.Response
Can raise AnymailRequestsAPIError for HTTP errors in the post
"""
api_url = self.get_api_url(payload, message)
post_data = self.serialize_payload(payload, message)
response = self.session.post(api_url, data=post_data)
if response.status_code != 200:
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response)
return response
def deserialize_response(self, response, payload, message):
"""Return parsed ESP API response
Can raise AnymailRequestsAPIError if response is unparsable
"""
try:
return response.json()
except ValueError:
raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
email_message=message, payload=payload, response=response)

View File

@@ -1,6 +1,4 @@
import json
import mimetypes import mimetypes
import requests
from base64 import b64encode from base64 import b64encode
from datetime import date, datetime from datetime import date, datetime
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
@@ -11,32 +9,32 @@ except ImportError:
from urllib.parse import urljoin # python 3 from urllib.parse import urljoin # python 3
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
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 ..exceptions import (AnymailImproperlyConfigured,
from ..exceptions import (AnymailError, AnymailRequestsAPIError, AnymailRecipientsRefused, AnymailRequestsAPIError, AnymailRecipientsRefused,
AnymailSerializationError, AnymailUnsupportedFeature) AnymailUnsupportedFeature)
from .base import AnymailRequestsBackend
class MandrillBackend(BaseEmailBackend): class MandrillBackend(AnymailRequestsBackend):
""" """
Mandrill API Email Backend Mandrill API Email Backend
""" """
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Init options from Django settings""" """Init options from Django settings"""
super(MandrillBackend, self).__init__(**kwargs) api_url = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0")
if not api_url.endswith("/"):
api_url += "/"
super(MandrillBackend, self).__init__(api_url, **kwargs)
try: try:
self.api_key = settings.MANDRILL_API_KEY self.api_key = settings.MANDRILL_API_KEY
except AttributeError: except AttributeError:
raise ImproperlyConfigured("Set MANDRILL_API_KEY in settings.py to use Djrill") raise AnymailImproperlyConfigured("Set MANDRILL_API_KEY in settings.py to use Anymail Mandrill backend")
self.api_url = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0")
if not self.api_url.endswith("/"):
self.api_url += "/"
self.global_settings = {} self.global_settings = {}
try: try:
@@ -44,7 +42,7 @@ class MandrillBackend(BaseEmailBackend):
except AttributeError: except AttributeError:
pass # no MANDRILL_SETTINGS setting pass # no MANDRILL_SETTINGS setting
except (TypeError, ValueError): # e.g., not enumerable except (TypeError, ValueError): # e.g., not enumerable
raise ImproperlyConfigured("MANDRILL_SETTINGS must be a dict or mapping") raise AnymailImproperlyConfigured("MANDRILL_SETTINGS must be a dict or mapping")
try: try:
self.global_settings["subaccount"] = settings.MANDRILL_SUBACCOUNT self.global_settings["subaccount"] = settings.MANDRILL_SUBACCOUNT
@@ -54,100 +52,7 @@ class MandrillBackend(BaseEmailBackend):
self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False) self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False)
self.session = None self.session = None
def open(self): def build_message_payload(self, message):
"""
Ensure we have a requests Session to connect to the Mandrill API.
Returns True if a new session was created (and the caller must close it).
"""
if self.session:
return False # already exists
try:
self.session = requests.Session()
except requests.RequestException:
if not self.fail_silently:
raise
else:
self.session.headers["User-Agent"] = "Djrill/%s %s" % (
__version__, self.session.headers.get("User-Agent", ""))
return True
def close(self):
"""
Close the Mandrill API Session unconditionally.
(You should call this only if you called open and it returned True;
else someone else created the session and will clean it up themselves.)
"""
if self.session is None:
return
try:
self.session.close()
except requests.RequestException:
if not self.fail_silently:
raise
finally:
self.session = None
def send_messages(self, email_messages):
"""
Sends one or more EmailMessage objects and returns the number of email
messages sent.
"""
if not email_messages:
return 0
created_session = self.open()
if not self.session:
return 0 # exception in self.open with fail_silently
num_sent = 0
try:
for message in email_messages:
sent = self._send(message)
if sent:
num_sent += 1
finally:
if created_session:
self.close()
return num_sent
def _send(self, message):
message.mandrill_response = None # until we have a response
if not message.recipients():
return False
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 AnymailError:
# every *expected* error is derived from AnymailError;
# 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. """Modify payload to add all message-specific options for Mandrill send call.
payload is a dict that will become the Mandrill send data payload is a dict that will become the Mandrill send data
@@ -160,12 +65,17 @@ class MandrillBackend(BaseEmailBackend):
if getattr(message, 'alternatives', None): if getattr(message, 'alternatives', None):
self._add_alternatives(message, msg_dict) self._add_alternatives(message, msg_dict)
self._add_attachments(message, msg_dict) self._add_attachments(message, msg_dict)
payload.setdefault('message', {}).update(msg_dict)
payload = {
"key": self.api_key,
"message": msg_dict,
}
if hasattr(message, 'template_name'): if hasattr(message, 'template_name'):
payload['template_name'] = message.template_name payload['template_name'] = message.template_name
payload['template_content'] = \ payload['template_content'] = \
self._expand_merge_vars(getattr(message, 'template_content', {})) self._expand_merge_vars(getattr(message, 'template_content', {}))
self._add_mandrill_toplevel_options(message, payload) self._add_mandrill_toplevel_options(message, payload)
return payload
def get_api_url(self, payload, message): def get_api_url(self, payload, message):
"""Return the correct Mandrill API url for sending payload """Return the correct Mandrill API url for sending payload
@@ -178,67 +88,28 @@ class MandrillBackend(BaseEmailBackend):
api_method = "messages/send.json" api_method = "messages/send.json"
return urljoin(self.api_url, api_method) return urljoin(self.api_url, api_method)
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 AnymailSerializationError if payload is not serializable
Can raise AnymailRequestsAPIError for HTTP errors in the post
"""
api_url = self.get_api_url(payload, message)
try:
json_payload = self.serialize_payload(payload, message)
except TypeError as err:
# Add some context to the "not JSON serializable" message
raise AnymailSerializationError(
orig_err=err, email_message=message, payload=payload)
response = self.session.post(api_url, data=json_payload)
if response.status_code != 200:
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response)
return response
def parse_response(self, response, payload, message):
"""Return parsed json from Mandrill API response
Can raise AnymailRequestsAPIError if response is not valid JSON
"""
try:
return response.json()
except ValueError:
raise AnymailRequestsAPIError("Invalid JSON in Mandrill API 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):
"""Validate parsed_response, raising exceptions for any problems. """Validate parsed_response, raising exceptions for any problems.
Extend this to provide your own validation checks.
Validation exceptions should inherit from anymail.exceptions.DjrillException
for proper fail_silently behavior.
The base version here checks for invalid or refused recipients.
""" """
if self.ignore_recipient_status:
return
try: try:
recipient_status = [item["status"] for item in parsed_response] unique_statuses = set([item["status"] for item in parsed_response])
except (KeyError, TypeError): except (KeyError, TypeError):
raise AnymailRequestsAPIError("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)
if unique_statuses == {"sent"}:
return "sent"
elif unique_statuses == {"queued"}:
return "queued"
elif unique_statuses.issubset({"invalid", "rejected"}):
if self.ignore_recipient_status:
return "refused"
else:
# 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]):
raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response) raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response)
else:
return "multi"
# #
# Payload construction # Payload construction

View File

@@ -1,3 +1,4 @@
from django.core.exceptions import ImproperlyConfigured
import json import json
from requests import HTTPError from requests import HTTPError
@@ -67,6 +68,10 @@ class AnymailError(Exception):
return description return description
class AnymailImproperlyConfigured(AnymailError, ImproperlyConfigured):
"""Exception for configuration problems"""
class AnymailAPIError(AnymailError): class AnymailAPIError(AnymailError):
"""Exception for unsuccessful response from ESP's API.""" """Exception for unsuccessful response from ESP's API."""

View File

@@ -525,7 +525,10 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
sent = msg.send() sent = msg.send()
self.assertEqual(sent, 1) self.assertEqual(sent, 1)
# noinspection PyUnresolvedReferences
self.assertEqual(msg.mandrill_response, response) self.assertEqual(msg.mandrill_response, response)
# noinspection PyUnresolvedReferences
self.assertEqual(msg.anymail_status, "sent")
def test_send_failed_mandrill_response(self): def test_send_failed_mandrill_response(self):
""" If the send fails, mandrill_response should be set to None """ """ If the send fails, mandrill_response should be set to None """
@@ -533,7 +536,10 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
sent = msg.send(fail_silently=True) sent = msg.send(fail_silently=True)
self.assertEqual(sent, 0) self.assertEqual(sent, 0)
# noinspection PyUnresolvedReferences
self.assertIsNone(msg.mandrill_response) self.assertIsNone(msg.mandrill_response)
# noinspection PyUnresolvedReferences
self.assertIsNone(msg.anymail_status)
def test_send_unparsable_mandrill_response(self): def test_send_unparsable_mandrill_response(self):
"""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"""
@@ -541,7 +547,10 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
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(AnymailAPIError): with self.assertRaises(AnymailAPIError):
msg.send() msg.send()
# noinspection PyUnresolvedReferences
self.assertIsNone(msg.mandrill_response) self.assertIsNone(msg.mandrill_response)
# noinspection PyUnresolvedReferences
self.assertIsNone(msg.anymail_status)
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"""