mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
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
252 lines
10 KiB
Python
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)
|