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

390 lines
16 KiB
Python

import re
from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import (
CaseInsensitiveCasePreservingDict,
get_anymail_setting,
parse_address_list,
)
from .base_requests import AnymailRequestsBackend, RequestsPayload
class EmailBackend(AnymailRequestsBackend):
"""
Postmark API Email Backend
"""
esp_name = "Postmark"
def __init__(self, **kwargs):
"""Init options from Django settings"""
esp_name = self.esp_name
self.server_token = get_anymail_setting(
"server_token", esp_name=esp_name, kwargs=kwargs, allow_bare=True
)
api_url = get_anymail_setting(
"api_url",
esp_name=esp_name,
kwargs=kwargs,
default="https://api.postmarkapp.com/",
)
if not api_url.endswith("/"):
api_url += "/"
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return PostmarkPayload(message, defaults, self)
def raise_for_status(self, response, payload, message):
# We need to handle 422 responses in parse_recipient_status
if response.status_code != 422:
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. (This also forces recipient_status email capitalization to match
# that as sent, while correctly handling Postmark's lowercase-only inactive
# recipient reporting.)
unknown_status = AnymailRecipientStatus(message_id=None, status="unknown")
recipient_status = CaseInsensitiveCasePreservingDict(
{
recip.addr_spec: unknown_status
for recip in payload.to_emails + payload.cc_and_bcc_emails
}
)
parsed_response = self.deserialize_json_response(response, payload, message)
if not isinstance(parsed_response, list):
# non-batch calls return a single response object
parsed_response = [parsed_response]
for one_response in parsed_response:
try:
# these fields should always be present
error_code = one_response["ErrorCode"]
msg = one_response["Message"]
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError(
"Invalid Postmark API response format",
email_message=message,
payload=payload,
response=response,
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 as err:
raise AnymailRequestsAPIError(
"Invalid Postmark API success response format",
email_message=message,
payload=payload,
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.)
try:
to_header = one_response["To"] # (missing if cc- or bcc-only send)
except KeyError:
pass # cc- or bcc-only send; per-recipient status not available
else:
for to in parse_address_list(to_header):
recipient_status[to.addr_spec] = AnymailRecipientStatus(
message_id=message_id, status="sent"
)
# Assume all Cc and Bcc recipients are "sent" unless proven otherwise
# below. (Postmark doesn't report "Cc" or "Bcc" in API response; use
# original payload values.)
for recip in payload.cc_and_bcc_emails:
recipient_status[recip.addr_spec] = AnymailRecipientStatus(
message_id=message_id, status="sent"
)
# Change "sent" to "rejected" if Postmark reported an address as
# "Inactive". Sadly, have to parse human-readable message to figure out
# if everyone got it:
# "Message OK, but will not deliver to these inactive addresses:
# {addr_spec, ...}. Inactive recipients are ones that have generated
# a hard bounce or a spam complaint."
# Note that error message emails are addr_spec only (no display names)
# and forced lowercase.
reject_addr_specs = self._addr_specs_from_error_msg(
msg, r"inactive addresses:\s*(.*)\.\s*Inactive recipients"
)
for reject_addr_spec in reject_addr_specs:
recipient_status[reject_addr_spec] = AnymailRecipientStatus(
message_id=None, status="rejected"
)
elif error_code == 300: # Invalid email request
# Various parse-time validation errors, which may include invalid
# recipients. Email not sent. response["To"] is not populated for this
# error; must examine response["Message"]:
if re.match(
r"^(Invalid|Error\s+parsing)\s+'(To|Cc|Bcc)'", msg, re.IGNORECASE
):
# Recipient-related errors: use AnymailRecipientsRefused logic
# - "Invalid 'To' address: '{addr_spec}'."
# - "Error parsing 'Cc': Illegal email domain '{domain}'
# in address '{addr_spec}'."
# - "Error parsing 'Bcc': Illegal email address '{addr_spec}'.
# It must contain the '@' symbol."
invalid_addr_specs = self._addr_specs_from_error_msg(
msg, r"address:?\s*'(.*)'"
)
for invalid_addr_spec in invalid_addr_specs:
recipient_status[invalid_addr_spec] = AnymailRecipientStatus(
message_id=None, status="invalid"
)
else:
# Non-recipient errors; handle as normal API error response
# - "Invalid 'From' address: '{email_address}'."
# - "Error parsing 'Reply-To': Illegal email domain '{domain}'
# in address '{addr_spec}'."
# - "Invalid metadata content. ..."
raise AnymailRequestsAPIError(
email_message=message,
payload=payload,
response=response,
backend=self,
)
elif error_code == 406: # Inactive recipient
# All recipients were rejected as hard-bounce or spam-complaint. Email
# not sent. response["To"] is not populated for this error; must examine
# response["Message"]:
# "You tried to send to a recipient that has been marked as
# inactive.\n Found inactive addresses: {addr_spec, ...}.\n
# Inactive recipients are ones that have generated a hard bounce
# or a spam complaint. "
reject_addr_specs = self._addr_specs_from_error_msg(
msg, r"inactive addresses:\s*(.*)\.\s*Inactive recipients"
)
for reject_addr_spec in reject_addr_specs:
recipient_status[reject_addr_spec] = AnymailRecipientStatus(
message_id=None, status="rejected"
)
else: # Other error
raise AnymailRequestsAPIError(
email_message=message,
payload=payload,
response=response,
backend=self,
)
return dict(recipient_status)
@staticmethod
def _addr_specs_from_error_msg(error_msg, pattern):
"""Extract a list of email addr_specs from Postmark error_msg.
pattern must be a re whose first group matches a comma-separated
list of addr_specs in the message
"""
match = re.search(pattern, error_msg, re.MULTILINE)
if match:
emails = match.group(1) # "one@xample.com, two@example.com"
return [email.strip().lower() for email in emails.split(",")]
else:
return []
class PostmarkPayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
# "X-Postmark-Server-Token": see get_request_params (and set_esp_extra)
}
self.server_token = backend.server_token # esp_extra can override
self.to_emails = []
self.cc_and_bcc_emails = [] # needed for parse_recipient_status
self.merge_data = None
self.merge_metadata = None
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
def get_api_endpoint(self):
batch_send = self.is_batch()
if (
"TemplateAlias" in self.data
or "TemplateId" in self.data
or "TemplateModel" in self.data
):
if batch_send:
return "email/batchWithTemplates"
else:
# This is the one Postmark API documented to have a trailing slash.
# (Typo?)
return "email/withTemplate/"
else:
if batch_send:
return "email/batch"
else:
return "email"
def get_request_params(self, api_url):
params = super().get_request_params(api_url)
params["headers"]["X-Postmark-Server-Token"] = self.server_token
return params
def serialize_data(self):
api_endpoint = self.get_api_endpoint()
if api_endpoint == "email":
data = self.data
elif api_endpoint == "email/batchWithTemplates":
data = {"Messages": [self.data_for_recipient(to) for to in self.to_emails]}
elif api_endpoint == "email/batch":
data = [self.data_for_recipient(to) for to in self.to_emails]
elif api_endpoint == "email/withTemplate/":
assert (
self.merge_data is None and self.merge_metadata is None
) # else it's a batch send
data = self.data
else:
raise AssertionError(
"PostmarkPayload.serialize_data missing"
" case for api_endpoint %r" % api_endpoint
)
return self.serialize_json(data)
def data_for_recipient(self, to):
data = self.data.copy()
data["To"] = to.address
if self.merge_data and to.addr_spec in self.merge_data:
recipient_data = self.merge_data[to.addr_spec]
if "TemplateModel" in data:
# merge recipient_data into merge_global_data
data["TemplateModel"] = data["TemplateModel"].copy()
data["TemplateModel"].update(recipient_data)
else:
data["TemplateModel"] = recipient_data
if self.merge_metadata and to.addr_spec in self.merge_metadata:
recipient_metadata = self.merge_metadata[to.addr_spec]
if "Metadata" in data:
# merge recipient_metadata into toplevel metadata
data["Metadata"] = data["Metadata"].copy()
data["Metadata"].update(recipient_metadata)
else:
data["Metadata"] = recipient_metadata
return data
#
# Payload construction
#
def init_payload(self):
self.data = {} # becomes json
def set_from_email_list(self, emails):
# Postmark accepts multiple From email addresses
# (though truncates to just the first, on their end, as of 4/2017)
self.data["From"] = ", ".join([email.address for email in emails])
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]
if emails:
field = recipient_type.capitalize()
self.data[field] = ", ".join([email.address for email in emails])
if recipient_type == "to":
self.to_emails = emails
else:
self.cc_and_bcc_emails += emails
def set_subject(self, subject):
self.data["Subject"] = subject
def set_reply_to(self, emails):
if emails:
reply_to = ", ".join([email.address for email in emails])
self.data["ReplyTo"] = reply_to
def set_extra_headers(self, headers):
self.data["Headers"] = [
{"Name": key, "Value": value} for key, value in headers.items()
]
def set_text_body(self, body):
self.data["TextBody"] = body
def set_html_body(self, body):
if "HtmlBody" in self.data:
# second html body could show up through multiple alternatives,
# or html body + alternative
self.unsupported_feature("multiple html parts")
self.data["HtmlBody"] = body
def make_attachment(self, attachment):
"""Returns Postmark attachment dict for attachment"""
att = {
"Name": attachment.name or "",
"Content": attachment.b64content,
"ContentType": attachment.mimetype,
}
if attachment.inline:
att["ContentID"] = "cid:%s" % attachment.cid
return att
def set_attachments(self, attachments):
if attachments:
self.data["Attachments"] = [
self.make_attachment(attachment) for attachment in attachments
]
def set_metadata(self, metadata):
self.data["Metadata"] = metadata
# Postmark doesn't support delayed sending
# def set_send_at(self, send_at):
def set_tags(self, tags):
if len(tags) > 0:
self.data["Tag"] = tags[0]
if len(tags) > 1:
self.unsupported_feature("multiple tags (%r)" % tags)
def set_track_clicks(self, track_clicks):
self.data["TrackLinks"] = "HtmlAndText" if track_clicks else "None"
def set_track_opens(self, track_opens):
self.data["TrackOpens"] = track_opens
def set_template_id(self, template_id):
try:
self.data["TemplateId"] = int(template_id)
except ValueError:
self.data["TemplateAlias"] = template_id
# Postmark requires TemplateModel (empty ok) when TemplateId/TemplateAlias
# specified. (This may get overwritten by a real TemplateModel later.)
self.data.setdefault("TemplateModel", {})
# Subject, TextBody, and HtmlBody aren't allowed with TemplateId;
# delete Django default subject and body empty strings:
for field in ("Subject", "TextBody", "HtmlBody"):
if field in self.data and not self.data[field]:
del self.data[field]
def set_merge_data(self, merge_data):
# late-bind
self.merge_data = merge_data
def set_merge_global_data(self, merge_global_data):
self.data["TemplateModel"] = merge_global_data
def set_merge_metadata(self, merge_metadata):
# late-bind
self.merge_metadata = merge_metadata
def set_esp_extra(self, extra):
self.data.update(extra)
# Special handling for 'server_token':
self.server_token = self.data.pop("server_token", self.server_token)