Files
django-anymail/anymail/backends/sparkpost.py
medmunds b4e22c63b3 Reformat code with automated tools
Apply standardized code style
2023-02-06 15:05:24 -08:00

286 lines
11 KiB
Python

from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, update_deep
from .base_requests import AnymailRequestsBackend, RequestsPayload
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. Must 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() # gets "metadata" and "substitutions"
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)