Files
django-anymail/anymail/backends/base_requests.py
medmunds bd9d92f5a0 Cleanup: centralize Reply-To header handling; case-insensitive headers
Django allows setting the reply address with either message.reply_to
or message.extra_headers["Reply-To"]. If both are supplied, the extra
headers version takes precedence. (See EmailMessage.message().)

Several Anymail backends had duplicate logic to handle conflicting
properties. Move that logic into the base Payload.

(Also prepares for common handling of extra_headers['From'], later.)

Related changes:

* Use CaseInsensitiveDict for processing extra_headers.
  This is potentially a breaking change, but any code that was trying
  to send multiple headers differing only in case was likely already
  broken. (Email header field names are case-insensitive, per RFC-822.)

* Handle CaseInsensitiveDict in RequestsPayload.serialize_json().
  (Several backends had duplicate code for handling this, too.)

* Fixes SparkPost backend, which had been incorrectly treating
  message.reply_to and message.extra_headers['Reply-To'] differently.
2018-02-26 12:25:57 -08:00

172 lines
6.4 KiB
Python

import json
import requests
from requests.structures import CaseInsensitiveDict
from six.moves.urllib.parse import urljoin
from anymail.utils import get_anymail_setting
from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailRequestsAPIError, AnymailSerializationError
from .._version import __version__
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
self.timeout = get_anymail_setting('requests_timeout', kwargs=kwargs, default=30)
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"] = "django-anymail/{version}-{esp} {orig}".format(
esp=self.esp_name.lower(), version=__version__,
orig=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 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
"""
params = payload.get_request_params(self.api_url)
params.setdefault('timeout', self.timeout)
try:
response = self.session.request(**params)
except requests.RequestException as err:
# raise an exception that is both AnymailRequestsAPIError
# and the original requests exception type
exc_class = type('AnymailRequestsAPIError', (AnymailRequestsAPIError, type(err)), {})
raise exc_class(
"Error posting to %s:" % params.get('url', '<missing url>'),
raised_from=err, email_message=message, payload=payload)
self.raise_for_status(response, payload, message)
return response
def raise_for_status(self, response, payload, message):
"""Raise AnymailRequestsAPIError if response is an HTTP error
Subclasses can override for custom error checking
(though should defer parsing/deserialization of the body to
parse_recipient_status)
"""
if response.status_code != 200:
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
backend=self)
def deserialize_json_response(self, response, payload, message):
"""Deserialize an ESP API response that's in json.
Useful for implementing deserialize_response
"""
try:
return response.json()
except ValueError:
raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
email_message=message, payload=payload, response=response,
backend=self)
class RequestsPayload(BasePayload):
"""Abstract Payload for AnymailRequestsBackend"""
def __init__(self, message, defaults, backend,
method="POST", params=None, data=None,
headers=None, files=None, auth=None):
self.method = method
self.params = params
self.data = data
self.headers = headers
self.files = files
self.auth = auth
super(RequestsPayload, self).__init__(message, defaults, backend)
def get_request_params(self, api_url):
"""Returns a dict of requests.request params that will send payload to the ESP.
:param api_url: the base api_url for the backend
:return: dict
"""
api_endpoint = self.get_api_endpoint()
if api_endpoint is not None:
url = urljoin(api_url, api_endpoint)
else:
url = api_url
return dict(
method=self.method,
url=url,
params=self.params,
data=self.serialize_data(),
headers=self.headers,
files=self.files,
auth=self.auth,
# json= is not here, because we prefer to do our own serialization
# to provide extra context in error messages
)
def get_api_endpoint(self):
"""Returns a str that should be joined to the backend's api_url for sending this payload."""
return None
def serialize_data(self):
"""Performs any necessary serialization on self.data, and returns the result."""
return self.data
def serialize_json(self, data):
"""Returns data serialized to json, raising appropriate errors.
Useful for implementing serialize_data in a subclass,
"""
try:
return json.dumps(data, default=self._json_default)
except TypeError as err:
# Add some context to the "not JSON serializable" message
raise AnymailSerializationError(orig_err=err, email_message=self.message,
backend=self.backend, payload=self)
@staticmethod
def _json_default(o):
"""json.dump default function that handles some common Payload data types"""
if isinstance(o, CaseInsensitiveDict): # used for headers
return dict(o)
raise TypeError("Object of type '%s' is not JSON serializable" %
o.__class__.__name__)