Drop Python 2 and Django 1.11 support

Minimum supported versions are now Django 2.0, Python 3.5.

This touches a lot of code, to:
* Remove obsolete portability code and workarounds
  (six, backports of email parsers, test utils, etc.)
* Use Python 3 syntax (class defs, raise ... from, etc.)
* Correct inheritance for mixin classes
* Fix outdated docs content and links
* Suppress Python 3 "unclosed SSLSocket" ResourceWarnings
  that are beyond our control (in integration tests due to boto3, 
  python-sparkpost)
This commit is contained in:
Mike Edmunds
2020-08-01 14:53:10 -07:00
committed by GitHub
parent c803108481
commit 85cec5e9dc
87 changed files with 672 additions and 1278 deletions

View File

@@ -1,10 +1,6 @@
from email.charset import Charset, QP
from email.header import Header
from email.mime.base import MIMEBase
from email.mime.text import MIMEText
from django.core.mail import BadHeaderError
from .base import AnymailBaseBackend, BasePayload
from .._version import __version__
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
@@ -15,42 +11,14 @@ try:
import boto3
from botocore.client import Config
from botocore.exceptions import BotoCoreError, ClientError, ConnectionError
except ImportError:
raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses')
except ImportError as err:
raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses') from err
# boto3 has several root exception classes; this is meant to cover all of them
BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError)
# Work around Python 2 bug in email.message.Message.to_string, where long headers
# containing commas or semicolons get an extra space inserted after every ',' or ';'
# not already followed by a space. https://bugs.python.org/issue25257
if Header("test,Python2,header,comma,bug", maxlinelen=20).encode() == "test,Python2,header,comma,bug":
# no workaround needed
HeaderBugWorkaround = None
def add_header(message, name, val):
message[name] = val
else:
# workaround: custom Header subclass that won't consider ',' and ';' as folding candidates
class HeaderBugWorkaround(Header):
def encode(self, splitchars=' ', **kwargs): # only split on spaces, rather than splitchars=';, '
return Header.encode(self, splitchars, **kwargs)
def add_header(message, name, val):
# Must bypass Django's SafeMIMEMessage.__set_item__, because its call to
# forbid_multi_line_headers converts the val back to a str, undoing this
# workaround. That makes this code responsible for sanitizing val:
if '\n' in val or '\r' in val:
raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (val, name))
val = HeaderBugWorkaround(val, header_name=name)
assert isinstance(message, MIMEBase)
MIMEBase.__setitem__(message, name, val)
class EmailBackend(AnymailBaseBackend):
"""
Amazon SES Email Backend (using boto3)
@@ -60,7 +28,7 @@ class EmailBackend(AnymailBaseBackend):
def __init__(self, **kwargs):
"""Init options from Django settings"""
super(EmailBackend, self).__init__(**kwargs)
super().__init__(**kwargs)
# AMAZON_SES_CLIENT_PARAMS is optional - boto3 can find credentials several other ways
self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs)
self.configuration_set_name = get_anymail_setting("configuration_set_name", esp_name=self.esp_name,
@@ -77,6 +45,8 @@ class EmailBackend(AnymailBaseBackend):
except BOTO_BASE_ERRORS:
if not self.fail_silently:
raise
else:
return True # created client
def close(self):
if self.client is None:
@@ -98,7 +68,7 @@ class EmailBackend(AnymailBaseBackend):
except BOTO_BASE_ERRORS as err:
# ClientError has a response attr with parsed json error response (other errors don't)
raise AnymailAPIError(str(err), backend=self, email_message=message, payload=payload,
response=getattr(err, 'response', None), raised_from=err)
response=getattr(err, 'response', None)) from err
return response
def parse_recipient_status(self, response, payload, message):
@@ -125,12 +95,9 @@ class AmazonSESBasePayload(BasePayload):
class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
def init_payload(self):
super(AmazonSESSendRawEmailPayload, self).init_payload()
super().init_payload()
self.all_recipients = []
self.mime_message = self.message.message()
if HeaderBugWorkaround and "Subject" in self.mime_message:
# (message.message() will have already checked subject for BadHeaderError)
self.mime_message.replace_header("Subject", HeaderBugWorkaround(self.message.subject))
# Work around an Amazon SES bug where, if all of:
# - the message body (text or html) contains non-ASCII characters
@@ -165,7 +132,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
except (KeyError, TypeError) as err:
raise AnymailAPIError(
"%s parsing Amazon SES send result %r" % (str(err), response),
backend=self.backend, email_message=self.message, payload=self)
backend=self.backend, email_message=self.message, payload=self) from None
recipient_status = AnymailRecipientStatus(message_id=message_id, status="queued")
return {recipient.addr_spec: recipient_status for recipient in self.all_recipients}
@@ -248,14 +215,14 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
# (See "How do message tags work?" in https://aws.amazon.com/blogs/ses/introducing-sending-metrics/
# and https://forums.aws.amazon.com/thread.jspa?messageID=782922.)
# To support reliable retrieval in webhooks, just use custom headers for metadata.
add_header(self.mime_message, "X-Metadata", self.serialize_json(metadata))
self.mime_message["X-Metadata"] = self.serialize_json(metadata)
def set_tags(self, tags):
# See note about Amazon SES Message Tags and custom headers in set_metadata above.
# To support reliable retrieval in webhooks, use custom headers for tags.
# (There are no restrictions on number or content for custom header tags.)
for tag in tags:
add_header(self.mime_message, "X-Tag", tag) # creates multiple X-Tag headers, one per tag
self.mime_message.add_header("X-Tag", tag) # creates multiple X-Tag headers, one per tag
# Also *optionally* pass a single Message Tag if the AMAZON_SES_MESSAGE_TAG_NAME
# Anymail setting is set (default no). The AWS API restricts tag content in this case.
@@ -278,7 +245,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
def init_payload(self):
super(AmazonSESSendBulkTemplatedEmailPayload, self).init_payload()
super().init_payload()
# late-bind recipients and merge_data in call_send_api
self.recipients = {"to": [], "cc": [], "bcc": []}
self.merge_data = {}
@@ -311,7 +278,7 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
except (KeyError, TypeError) as err:
raise AnymailAPIError(
"%s parsing Amazon SES send result %r" % (str(err), response),
backend=self.backend, email_message=self.message, payload=self)
backend=self.backend, email_message=self.message, payload=self) from None
to_addrs = [to.addr_spec for to in self.recipients["to"]]
if len(anymail_statuses) != len(to_addrs):

View File

@@ -1,7 +1,6 @@
import json
from datetime import date, datetime
import six
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc
@@ -23,7 +22,7 @@ class AnymailBaseBackend(BaseEmailBackend):
"""
def __init__(self, *args, **kwargs):
super(AnymailBaseBackend, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.ignore_unsupported_features = get_anymail_setting('ignore_unsupported_features',
kwargs=kwargs, default=False)
@@ -207,7 +206,7 @@ class AnymailBaseBackend(BaseEmailBackend):
(self.__class__.__module__, self.__class__.__name__))
class BasePayload(object):
class BasePayload:
# Listing of EmailMessage/EmailMultiAlternatives attributes
# to process into Payload. Each item is in the form:
# (attr, combiner, converter)
@@ -365,7 +364,7 @@ class BasePayload(object):
# TypeError: must be str, not list
# TypeError: can only concatenate list (not "str") to list
# TypeError: Can't convert 'list' object to str implicitly
if isinstance(value, six.string_types) or is_lazy(value):
if isinstance(value, str) or is_lazy(value):
raise TypeError('"{attr}" attribute must be a list or other iterable'.format(attr=attr))
#
@@ -538,7 +537,7 @@ class BasePayload(object):
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)
backend=self.backend, payload=self) from None
@staticmethod
def _json_default(o):

View File

@@ -1,13 +1,11 @@
from __future__ import print_function
from urllib.parse import urljoin
import requests
import six
from six.moves.urllib.parse import urljoin
from anymail.utils import get_anymail_setting
from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailRequestsAPIError
from .._version import __version__
from ..exceptions import AnymailRequestsAPIError
class AnymailRequestsBackend(AnymailBaseBackend):
@@ -19,7 +17,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
"""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)
super().__init__(**kwargs)
self.session = None
def open(self):
@@ -57,7 +55,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
"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)
return super()._send(message)
def post_to_esp(self, payload, message):
"""Post payload to ESP send API endpoint, and return the raw response.
@@ -78,7 +76,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
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)
email_message=message, payload=payload) from err
self.raise_for_status(response, payload, message)
return response
@@ -100,10 +98,10 @@ class AnymailRequestsBackend(AnymailBaseBackend):
"""
try:
return response.json()
except ValueError:
except ValueError as err:
raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
@staticmethod
def _dump_api_request(response, **kwargs):
@@ -113,22 +111,22 @@ class AnymailRequestsBackend(AnymailBaseBackend):
# If you need the raw bytes, configure HTTPConnection logging as shown
# in http://docs.python-requests.org/en/v3.0.0/api/#api-changes)
request = response.request # a PreparedRequest
print(u"\n===== Anymail API request")
print(u"{method} {url}\n{headers}".format(
print("\n===== Anymail API request")
print("{method} {url}\n{headers}".format(
method=request.method, url=request.url,
headers=u"".join(u"{header}: {value}\n".format(header=header, value=value)
for (header, value) in request.headers.items()),
headers="".join("{header}: {value}\n".format(header=header, value=value)
for (header, value) in request.headers.items()),
))
if request.body is not None:
body_text = (request.body if isinstance(request.body, six.text_type)
body_text = (request.body if isinstance(request.body, str)
else request.body.decode("utf-8", errors="replace")
).replace("\r\n", "\n")
print(body_text)
print(u"\n----- Response")
print(u"HTTP {status} {reason}\n{headers}\n{body}".format(
print("\n----- Response")
print("HTTP {status} {reason}\n{headers}\n{body}".format(
status=response.status_code, reason=response.reason,
headers=u"".join(u"{header}: {value}\n".format(header=header, value=value)
for (header, value) in response.headers.items()),
headers="".join("{header}: {value}\n".format(header=header, value=value)
for (header, value) in response.headers.items()),
body=response.text, # Let Requests decode body content for us
))
@@ -145,7 +143,7 @@ class RequestsPayload(BasePayload):
self.headers = headers
self.files = files
self.auth = auth
super(RequestsPayload, self).__init__(message, defaults, backend)
super().__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.

View File

@@ -1,15 +1,14 @@
from datetime import datetime
from email.utils import encode_rfc2231
from six.moves.urllib.parse import quote
from urllib.parse import quote
from requests import Request
from ..exceptions import AnymailRequestsAPIError, AnymailError
from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailError, AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, rfc2822date
from .base_requests import AnymailRequestsBackend, RequestsPayload
# Feature-detect whether requests (urllib3) correctly uses RFC 7578 encoding for non-
# ASCII filenames in Content-Disposition headers. (This was fixed in urllib3 v1.25.)
@@ -17,7 +16,7 @@ from .base_requests import AnymailRequestsBackend, RequestsPayload
# (Note: when this workaround is removed, please also remove the "old_urllib3" tox envs.)
def is_requests_rfc_5758_compliant():
request = Request(method='POST', url='https://www.example.com',
files=[('attachment', (u'\N{NOT SIGN}.txt', 'test', 'text/plain'))])
files=[('attachment', ('\N{NOT SIGN}.txt', 'test', 'text/plain'))])
prepared = request.prepare()
form_data = prepared.body # bytes
return b'filename*=' not in form_data
@@ -43,7 +42,7 @@ class EmailBackend(AnymailRequestsBackend):
default="https://api.mailgun.net/v3")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return MailgunPayload(message, defaults, self)
@@ -62,10 +61,10 @@ class EmailBackend(AnymailRequestsBackend):
try:
message_id = parsed_response["id"]
mailgun_message = parsed_response["message"]
except (KeyError, TypeError):
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid Mailgun API response format",
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
if not mailgun_message.startswith("Queued"):
raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message,
email_message=message, payload=payload, response=response,
@@ -89,7 +88,7 @@ class MailgunPayload(RequestsPayload):
self.merge_metadata = {}
self.to_emails = []
super(MailgunPayload, self).__init__(message, defaults, backend, auth=auth, *args, **kwargs)
super().__init__(message, defaults, backend, auth=auth, *args, **kwargs)
def get_api_endpoint(self):
if self.sender_domain is None:
@@ -105,7 +104,7 @@ class MailgunPayload(RequestsPayload):
return "%s/messages" % quote(self.sender_domain, safe='')
def get_request_params(self, api_url):
params = super(MailgunPayload, self).get_request_params(api_url)
params = super().get_request_params(api_url)
non_ascii_filenames = [filename
for (field, (filename, content, mimetype)) in params["files"]
if filename is not None and not isascii(filename)]
@@ -122,9 +121,7 @@ class MailgunPayload(RequestsPayload):
prepared = Request(**params).prepare()
form_data = prepared.body # bytes
for filename in non_ascii_filenames: # text
rfc2231_filename = encode_rfc2231( # wants a str (text in PY3, bytes in PY2)
filename if isinstance(filename, str) else filename.encode("utf-8"),
charset="utf-8")
rfc2231_filename = encode_rfc2231(filename, charset="utf-8")
form_data = form_data.replace(
b'filename*=' + rfc2231_filename.encode("utf-8"),
b'filename="' + filename.encode("utf-8") + b'"')

View File

@@ -1,12 +1,10 @@
from email.header import Header
from six.moves.urllib.parse import quote
from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES
from ..utils import get_anymail_setting, EmailAddress, parse_address_list
from urllib.parse import quote
from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailRequestsAPIError
from ..message import ANYMAIL_STATUSES, AnymailRecipientStatus
from ..utils import EmailAddress, get_anymail_setting, parse_address_list
class EmailBackend(AnymailRequestsBackend):
@@ -25,7 +23,7 @@ class EmailBackend(AnymailRequestsBackend):
default="https://api.mailjet.com/v3")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return MailjetPayload(message, defaults, self)
@@ -36,7 +34,7 @@ class EmailBackend(AnymailRequestsBackend):
raise AnymailRequestsAPIError(
"Invalid Mailjet API key or secret",
email_message=message, payload=payload, response=response, backend=self)
super(EmailBackend, self).raise_for_status(response, payload, message)
super().raise_for_status(response, payload, message)
def parse_recipient_status(self, response, payload, message):
# Mailjet's (v3.0) transactional send API is not covered in their reference docs.
@@ -61,10 +59,10 @@ class EmailBackend(AnymailRequestsBackend):
message_id = str(item['MessageID'])
email = item['Email']
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
except (KeyError, TypeError):
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid Mailjet API response format",
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
# Make sure we ended up with a status for every original recipient
# (Mailjet only communicates "Sent")
for recipients in payload.recipients.values():
@@ -88,8 +86,7 @@ class MailjetPayload(RequestsPayload):
self.metadata = None
self.merge_data = {}
self.merge_metadata = {}
super(MailjetPayload, self).__init__(message, defaults, backend,
auth=auth, headers=http_headers, *args, **kwargs)
super().__init__(message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self):
return "send"
@@ -153,9 +150,10 @@ class MailjetPayload(RequestsPayload):
parsed.addr_spec)
else:
parsed = EmailAddress(headers["SenderName"], headers["SenderEmail"])
except KeyError:
except KeyError as err:
raise AnymailRequestsAPIError("Invalid Mailjet template API response",
email_message=self.message, response=response, backend=self.backend)
email_message=self.message, response=response,
backend=self.backend) from err
self.set_from_email(parsed)
def _format_email_for_mailjet(self, email):

View File

@@ -23,7 +23,7 @@ class EmailBackend(AnymailRequestsBackend):
default="https://mandrillapp.com/api/1.0")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return MandrillPayload(message, defaults, self)
@@ -40,10 +40,10 @@ class EmailBackend(AnymailRequestsBackend):
status = 'unknown'
message_id = item.get('_id', None) # can be missing for invalid/rejected recipients
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
except (KeyError, TypeError):
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid Mandrill API response format",
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
return recipient_status
@@ -69,7 +69,7 @@ class MandrillPayload(RequestsPayload):
def __init__(self, *args, **kwargs):
self.esp_extra = {} # late-bound in serialize_data
super(MandrillPayload, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
def get_api_endpoint(self):
if 'template_name' in self.data:

View File

@@ -22,7 +22,7 @@ class EmailBackend(AnymailRequestsBackend):
default="https://api.postmarkapp.com/")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return PostmarkPayload(message, defaults, self)
@@ -30,7 +30,7 @@ class EmailBackend(AnymailRequestsBackend):
def raise_for_status(self, response, payload, message):
# We need to handle 422 responses in parse_recipient_status
if response.status_code != 422:
super(EmailBackend, self).raise_for_status(response, payload, message)
super().raise_for_status(response, payload, message)
def parse_recipient_status(self, response, payload, message):
# Default to "unknown" status for each recipient, unless/until we find otherwise.
@@ -51,19 +51,19 @@ class EmailBackend(AnymailRequestsBackend):
# these fields should always be present
error_code = one_response["ErrorCode"]
msg = one_response["Message"]
except (KeyError, TypeError):
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid Postmark API response format",
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
if error_code == 0:
# At least partial success, and (some) email was sent.
try:
message_id = one_response["MessageID"]
except KeyError:
except KeyError as err:
raise AnymailRequestsAPIError("Invalid Postmark API success response format",
email_message=message, payload=payload,
response=response, backend=self)
response=response, backend=self) from err
# Assume all To recipients are "sent" unless proven otherwise below.
# (Must use "To" from API response to get correct individual MessageIDs in batch send.)
@@ -157,7 +157,7 @@ class PostmarkPayload(RequestsPayload):
self.cc_and_bcc_emails = [] # need to track (separately) for parse_recipient_status
self.merge_data = None
self.merge_metadata = None
super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs)
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
def get_api_endpoint(self):
batch_send = self.is_batch() and len(self.to_emails) > 1
@@ -174,7 +174,7 @@ class PostmarkPayload(RequestsPayload):
return "email"
def get_request_params(self, api_url):
params = super(PostmarkPayload, self).get_request_params(api_url)
params = super().get_request_params(api_url)
params['headers']['X-Postmark-Server-Token'] = self.server_token
return params

View File

@@ -7,7 +7,7 @@ from requests.structures import CaseInsensitiveDict
from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning
from ..message import AnymailRecipientStatus
from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, timestamp, update_deep
from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, update_deep
class EmailBackend(AnymailRequestsBackend):
@@ -47,7 +47,7 @@ class EmailBackend(AnymailRequestsBackend):
default="https://api.sendgrid.com/v3/")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return SendGridPayload(message, defaults, self)
@@ -84,9 +84,7 @@ class SendGridPayload(RequestsPayload):
http_headers['Authorization'] = 'Bearer %s' % backend.api_key
http_headers['Content-Type'] = 'application/json'
http_headers['Accept'] = 'application/json'
super(SendGridPayload, self).__init__(message, defaults, backend,
headers=http_headers,
*args, **kwargs)
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self):
return "mail/send"
@@ -294,7 +292,7 @@ class SendGridPayload(RequestsPayload):
def set_send_at(self, send_at):
# Backend has converted pretty much everything to
# a datetime by here; SendGrid expects unix timestamp
self.data["send_at"] = int(timestamp(send_at)) # strip microseconds
self.data["send_at"] = int(send_at.timestamp()) # strip microseconds
def set_tags(self, tags):
self.data["categories"] = tags

View File

@@ -30,7 +30,7 @@ class EmailBackend(AnymailRequestsBackend):
)
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return SendinBluePayload(message, defaults, self)
@@ -53,10 +53,10 @@ class EmailBackend(AnymailRequestsBackend):
parsed_response = self.deserialize_json_response(response, payload, message)
try:
message_id = parsed_response['messageId']
except (KeyError, TypeError):
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid SendinBlue API response format",
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
status = AnymailRecipientStatus(message_id=message_id, status="queued")
return {recipient.addr_spec: status for recipient in payload.all_recipients}
@@ -71,7 +71,7 @@ class SendinBluePayload(RequestsPayload):
http_headers['api-key'] = backend.api_key
http_headers['Content-Type'] = 'application/json'
super(SendinBluePayload, self).__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self):
return "smtp/email"

View File

@@ -1,5 +1,3 @@
from __future__ import absolute_import # we want the sparkpost package, not our own module
from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError
from ..message import AnymailRecipientStatus
@@ -7,8 +5,8 @@ from ..utils import get_anymail_setting
try:
from sparkpost import SparkPost, SparkPostException
except ImportError:
raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost')
except ImportError as err:
raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost') from err
class EmailBackend(AnymailBaseBackend):
@@ -20,7 +18,7 @@ class EmailBackend(AnymailBaseBackend):
def __init__(self, **kwargs):
"""Init options from Django settings"""
super(EmailBackend, self).__init__(**kwargs)
super().__init__(**kwargs)
# SPARKPOST_API_KEY is optional - library reads from env by default
self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
kwargs=kwargs, allow_bare=True, default=None)
@@ -43,7 +41,7 @@ class EmailBackend(AnymailBaseBackend):
"You may need to set ANYMAIL = {'SPARKPOST_API_KEY': ...} "
"or ANYMAIL_SPARKPOST_API_KEY in your Django settings, "
"or SPARKPOST_API_KEY in your environment." % str(err)
)
) from err
# Note: SparkPost python API doesn't expose requests session sharing
# (so there's no need to implement open/close connection management here)
@@ -60,7 +58,7 @@ class EmailBackend(AnymailBaseBackend):
str(err), backend=self, email_message=message, payload=payload,
response=getattr(err, 'response', None), # SparkPostAPIException requests.Response
status_code=getattr(err, 'status', None), # SparkPostAPIException HTTP status_code
)
) from err
return response
def parse_recipient_status(self, response, payload, message):
@@ -72,7 +70,7 @@ class EmailBackend(AnymailBaseBackend):
raise AnymailAPIError(
"%s in SparkPost.transmissions.send result %r" % (str(err), response),
backend=self, email_message=message, payload=payload,
)
) from err
# SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected.
# (* looks like undocumented 'rcpt_to_errors' might provide this info.)

View File

@@ -25,7 +25,7 @@ class EmailBackend(AnymailBaseBackend):
# Allow replacing the payload, for testing.
# (Real backends would generally not implement this option.)
self._payload_class = kwargs.pop('payload_class', TestPayload)
super(EmailBackend, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
if not hasattr(mail, 'outbox'):
mail.outbox = [] # see django.core.mail.backends.locmem
@@ -60,8 +60,8 @@ class EmailBackend(AnymailBaseBackend):
def parse_recipient_status(self, response, payload, message):
try:
return response['recipient_status']
except KeyError:
raise AnymailAPIError('Unparsable test response')
except KeyError as err:
raise AnymailAPIError('Unparsable test response') from err
class TestPayload(BasePayload):