Files
django-anymail/anymail/backends/sparkpost.py
Mike Edmunds 8c1749c6f3 SparkPost: drop support for multiple from_email (#213)
SparkPost's API no longer allows this, and now returns
a confusing error message about return_path.

(Not treating as a breaking change in Anymail, because
the breaking change was in the SparkPost API. This just
improves the error message in the unlikely event anyone
is trying to use this feature.)

Closes #212
2020-11-28 18:02:59 -08:00

252 lines
10 KiB
Python

from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, update_deep
class EmailBackend(AnymailRequestsBackend):
"""
SparkPost Email Backend
"""
esp_name = "SparkPost"
def __init__(self, **kwargs):
"""Init options from Django settings"""
self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
kwargs=kwargs, allow_bare=True)
self.subaccount = get_anymail_setting('subaccount', esp_name=self.esp_name,
kwargs=kwargs, default=None)
api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs,
default="https://api.sparkpost.com/api/v1/")
if not api_url.endswith("/"):
api_url += "/"
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return SparkPostPayload(message, defaults, self)
def parse_recipient_status(self, response, payload, message):
parsed_response = self.deserialize_json_response(response, payload, message)
try:
results = parsed_response["results"]
accepted = results["total_accepted_recipients"]
rejected = results["total_rejected_recipients"]
transmission_id = results["id"]
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid SparkPost API response format",
email_message=message, payload=payload,
response=response, backend=self) from err
# SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected.
# (* looks like undocumented 'rcpt_to_errors' might provide this info.)
# If all are one or the other, we can report a specific status;
# else just report 'unknown' for all recipients.
recipient_count = len(payload.recipients)
if accepted == recipient_count and rejected == 0:
status = 'queued'
elif rejected == recipient_count and accepted == 0:
status = 'rejected'
else: # mixed results, or wrong total
status = 'unknown'
recipient_status = AnymailRecipientStatus(message_id=transmission_id, status=status)
return {recipient.addr_spec: recipient_status for recipient in payload.recipients}
class SparkPostPayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
http_headers = {
'Authorization': backend.api_key,
'Content-Type': 'application/json',
}
if backend.subaccount is not None:
http_headers['X-MSYS-SUBACCOUNT'] = backend.subaccount
self.recipients = [] # all recipients, for backend parse_recipient_status
self.cc_and_bcc = [] # for _finalize_recipients
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self):
return "transmissions/"
def serialize_data(self):
self._finalize_recipients()
return self.serialize_json(self.data)
def _finalize_recipients(self):
# https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/
# self.data["recipients"] is currently a list of all to-recipients. We need to add
# all cc and bcc recipients. Exactly how depends on whether this is a batch send.
if self.is_batch():
# For batch sends, must duplicate the cc/bcc for *every* to-recipient
# (using each to-recipient's metadata and substitutions).
extra_recipients = []
for to_recipient in self.data["recipients"]:
for email in self.cc_and_bcc:
extra = to_recipient.copy() # capture "metadata" and "substitutions", if any
extra["address"] = {
"email": email.addr_spec,
"header_to": to_recipient["address"]["header_to"],
}
extra_recipients.append(extra)
self.data["recipients"].extend(extra_recipients)
else:
# For non-batch sends, we need to patch up *everyone's* displayed
# "To" header to show all the "To" recipients...
full_to_header = ", ".join(
to_recipient["address"]["header_to"]
for to_recipient in self.data["recipients"])
for recipient in self.data["recipients"]:
recipient["address"]["header_to"] = full_to_header
# ... and then simply add the cc/bcc to the end of the list.
# (There is no per-recipient data, or it would be a batch send.)
self.data["recipients"].extend(
{"address": {
"email": email.addr_spec,
"header_to": full_to_header,
}}
for email in self.cc_and_bcc)
#
# Payload construction
#
def init_payload(self):
# The JSON payload:
self.data = {
"content": {},
"recipients": [],
}
def set_from_email(self, email):
self.data["content"]["from"] = email.address
def set_to(self, emails):
if emails:
# In the recipient address, "email" is the addr spec to deliver to,
# and "header_to" is a fully-composed "To" header to display.
# (We use "header_to" rather than "name" to simplify some logic
# in _finalize_recipients; the results end up the same.)
self.data["recipients"].extend(
{"address": {
"email": email.addr_spec,
"header_to": email.address,
}}
for email in emails)
self.recipients += emails
def set_cc(self, emails):
# https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/
if emails:
# Add the Cc header, visible to all recipients:
cc_header = ", ".join(email.address for email in emails)
self.data["content"].setdefault("headers", {})["Cc"] = cc_header
# Actual recipients are added later, in _finalize_recipients
self.cc_and_bcc += emails
self.recipients += emails
def set_bcc(self, emails):
if emails:
# Actual recipients are added later, in _finalize_recipients
self.cc_and_bcc += emails
self.recipients += emails
def set_subject(self, subject):
self.data["content"]["subject"] = subject
def set_reply_to(self, emails):
if emails:
self.data["content"]["reply_to"] = ", ".join(email.address for email in emails)
def set_extra_headers(self, headers):
if headers:
self.data["content"].setdefault("headers", {}).update(headers)
def set_text_body(self, body):
self.data["content"]["text"] = body
def set_html_body(self, body):
if "html" in self.data["content"]:
# second html body could show up through multiple alternatives, or html body + alternative
self.unsupported_feature("multiple html parts")
self.data["content"]["html"] = body
def add_alternative(self, content, mimetype):
if mimetype.lower() == "text/x-amp-html":
if "amp_html" in self.data["content"]:
self.unsupported_feature("multiple html parts")
self.data["content"]["amp_html"] = content
else:
super().add_alternative(content, mimetype)
def set_attachments(self, atts):
attachments = [{
"name": att.name or "",
"type": att.content_type,
"data": att.b64content,
} for att in atts if not att.inline]
if attachments:
self.data["content"]["attachments"] = attachments
inline_images = [{
"name": att.cid,
"type": att.mimetype,
"data": att.b64content,
} for att in atts if att.inline]
if inline_images:
self.data["content"]["inline_images"] = inline_images
# Anymail-specific payload construction
def set_envelope_sender(self, email):
self.data["return_path"] = email.addr_spec
def set_metadata(self, metadata):
self.data["metadata"] = metadata
def set_merge_metadata(self, merge_metadata):
for recipient in self.data["recipients"]:
to_email = recipient["address"]["email"]
if to_email in merge_metadata:
recipient["metadata"] = merge_metadata[to_email]
def set_send_at(self, send_at):
try:
start_time = send_at.replace(microsecond=0).isoformat()
except (AttributeError, TypeError):
start_time = send_at # assume user already formatted
self.data.setdefault("options", {})["start_time"] = start_time
def set_tags(self, tags):
if len(tags) > 0:
self.data["campaign_id"] = tags[0]
if len(tags) > 1:
self.unsupported_feature("multiple tags (%r)" % tags)
def set_track_clicks(self, track_clicks):
self.data.setdefault("options", {})["click_tracking"] = track_clicks
def set_track_opens(self, track_opens):
self.data.setdefault("options", {})["open_tracking"] = track_opens
def set_template_id(self, template_id):
self.data["content"]["template_id"] = template_id
# Must remove empty string "content" params when using stored template
for content_param in ["subject", "text", "html"]:
try:
if not self.data["content"][content_param]:
del self.data["content"][content_param]
except KeyError:
pass
def set_merge_data(self, merge_data):
for recipient in self.data["recipients"]:
to_email = recipient["address"]["email"]
if to_email in merge_data:
recipient["substitution_data"] = merge_data[to_email]
def set_merge_global_data(self, merge_global_data):
self.data["substitution_data"] = merge_global_data
# ESP-specific payload construction
def set_esp_extra(self, extra):
update_deep(self.data, extra)