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_merge_headers(self, merge_headers): def header_var(field): return "Header__" + field.title().replace("-", "_") merge_header_fields = set() for recipient in self.data["recipients"]: to_email = recipient["address"]["email"] if to_email in merge_headers: recipient_headers = merge_headers[to_email] recipient.setdefault("substitution_data", {}).update( {header_var(key): value for key, value in recipient_headers.items()} ) merge_header_fields.update(recipient_headers.keys()) if merge_header_fields: headers = self.data.setdefault("content", {}).setdefault("headers", {}) # Global substitution_data supplies defaults for defined headers: self.data.setdefault("substitution_data", {}).update( { header_var(field): headers[field] for field in merge_header_fields if field in headers } ) # Indirect merge_headers through substitution_data: headers.update( {field: "{{%s}}" % header_var(field) for field in merge_header_fields} ) 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)