mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-19 19:31:06 -05:00
415 lines
17 KiB
Python
415 lines
17 KiB
Python
import re
|
|
|
|
from requests.structures import CaseInsensitiveDict
|
|
|
|
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
|
|
# (but ErrorCode may be missing for Postmark service delays)
|
|
error_code = one_response.get("ErrorCode", 0)
|
|
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
|
|
|
|
status = "sent"
|
|
# "Message accepted, but delivery may be delayed." (See #392.)
|
|
if "delivery may be delayed" in msg:
|
|
status = "queued"
|
|
|
|
# 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=status
|
|
)
|
|
|
|
# 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=status
|
|
)
|
|
|
|
# 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
|
|
self.merge_headers = {}
|
|
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
|
|
if to.addr_spec in self.merge_headers:
|
|
if "Headers" in data:
|
|
# merge global and recipient headers
|
|
headers = CaseInsensitiveDict(
|
|
(item["Name"], item["Value"]) for item in data["Headers"]
|
|
)
|
|
headers.update(self.merge_headers[to.addr_spec])
|
|
else:
|
|
headers = self.merge_headers[to.addr_spec]
|
|
data["Headers"] = [
|
|
{"Name": name, "Value": value} for name, value in headers.items()
|
|
]
|
|
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_merge_headers(self, merge_headers):
|
|
# late-bind
|
|
self.merge_headers = merge_headers
|
|
|
|
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)
|