Files
django-anymail/anymail/webhooks/postmark.py
Mike Edmunds 744d467f70 Postmark inbound: improve inbound parsing
- Support Postmark's RawEmail option;
  recommend it in docs
- Handle Bcc when provided by Postmark
- Obtain `envelope_sender` from Return-Path info
  Postmark now adds, rather than parsing Received-SPF

Related:
- Add `AnymailInboundMessage.bcc` convenience prop
- Test against full Postmark "check" inbound payloads
  (which don't match their docs or real inbound payloads)
- Don't warn about receiving "check" payload
2023-05-06 11:40:00 -07:00

264 lines
10 KiB
Python

import json
from email.utils import unquote
from django.utils.dateparse import parse_datetime
from ..exceptions import AnymailConfigurationError
from ..inbound import AnymailInboundMessage
from ..signals import (
AnymailInboundEvent,
AnymailTrackingEvent,
EventType,
RejectReason,
inbound,
tracking,
)
from ..utils import EmailAddress, getfirst
from .base import AnymailBaseWebhookView
class PostmarkBaseWebhookView(AnymailBaseWebhookView):
"""Base view class for Postmark webhooks"""
esp_name = "Postmark"
def parse_events(self, request):
esp_event = json.loads(request.body.decode("utf-8"))
return [self.esp_to_anymail_event(esp_event)]
def esp_to_anymail_event(self, esp_event):
raise NotImplementedError()
class PostmarkTrackingWebhookView(PostmarkBaseWebhookView):
"""Handler for Postmark delivery and engagement tracking webhooks"""
signal = tracking
event_record_types = {
# Map Postmark event RecordType --> Anymail normalized event type
"Bounce": EventType.BOUNCED, # but check Type field for further info (below)
"Click": EventType.CLICKED,
"Delivery": EventType.DELIVERED,
"Open": EventType.OPENED,
"SpamComplaint": EventType.COMPLAINED,
"SubscriptionChange": EventType.UNSUBSCRIBED,
"Inbound": EventType.INBOUND, # future, probably
}
event_types = {
# Map Postmark bounce/spam event Type
# --> Anymail normalized (event type, reject reason)
"HardBounce": (EventType.BOUNCED, RejectReason.BOUNCED),
"Transient": (EventType.DEFERRED, None),
"Unsubscribe": (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED),
"Subscribe": (EventType.SUBSCRIBED, None),
"AutoResponder": (EventType.AUTORESPONDED, None),
"AddressChange": (EventType.AUTORESPONDED, None),
"DnsError": (EventType.DEFERRED, None), # "temporary DNS error"
"SpamNotification": (EventType.COMPLAINED, RejectReason.SPAM),
# Receiving MTA is testing Postmark:
"OpenRelayTest": (EventType.DEFERRED, None),
"Unknown": (EventType.UNKNOWN, None),
# might also receive HardBounce later:
"SoftBounce": (EventType.BOUNCED, RejectReason.BOUNCED),
"VirusNotification": (EventType.BOUNCED, RejectReason.OTHER),
"ChallengeVerification": (EventType.AUTORESPONDED, None),
"BadEmailAddress": (EventType.REJECTED, RejectReason.INVALID),
"SpamComplaint": (EventType.COMPLAINED, RejectReason.SPAM),
"ManuallyDeactivated": (EventType.REJECTED, RejectReason.BLOCKED),
"Unconfirmed": (EventType.REJECTED, None),
"Blocked": (EventType.REJECTED, RejectReason.BLOCKED),
# could occur if user also using Postmark SMTP directly:
"SMTPApiError": (EventType.FAILED, None),
"InboundError": (EventType.INBOUND_FAILED, None),
"DMARCPolicy": (EventType.REJECTED, RejectReason.BLOCKED),
"TemplateRenderingFailed": (EventType.FAILED, None),
"ManualSuppression": (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED),
}
def esp_to_anymail_event(self, esp_event):
reject_reason = None
try:
esp_record_type = esp_event["RecordType"]
except KeyError:
if "FromFull" in esp_event:
# This is an inbound event
event_type = EventType.INBOUND
else:
event_type = EventType.UNKNOWN
else:
event_type = self.event_record_types.get(esp_record_type, EventType.UNKNOWN)
if event_type == EventType.INBOUND:
raise AnymailConfigurationError(
"You seem to have set Postmark's *inbound* webhook "
"to Anymail's Postmark *tracking* webhook URL."
)
if event_type in (EventType.BOUNCED, EventType.COMPLAINED):
# additional info is in the Type field
try:
event_type, reject_reason = self.event_types[esp_event["Type"]]
except KeyError:
pass
if event_type == EventType.UNSUBSCRIBED:
if esp_event["SuppressSending"]:
# Postmark doesn't provide a way to distinguish between
# explicit unsubscribes and bounces
try:
event_type, reject_reason = self.event_types[
esp_event["SuppressionReason"]
]
except KeyError:
pass
else:
event_type, reject_reason = self.event_types["Subscribe"]
# Email for bounce; Recipient for open:
recipient = getfirst(esp_event, ["Email", "Recipient"], None)
try:
timestr = getfirst(
esp_event, ["DeliveredAt", "BouncedAt", "ReceivedAt", "ChangedAt"]
)
except KeyError:
timestamp = None
else:
timestamp = parse_datetime(timestr)
try:
event_id = str(esp_event["ID"]) # only in bounce events
except KeyError:
event_id = None
metadata = esp_event.get("Metadata", {})
try:
tags = [esp_event["Tag"]]
except KeyError:
tags = []
return AnymailTrackingEvent(
description=esp_event.get("Description", None),
esp_event=esp_event,
event_id=event_id,
event_type=event_type,
message_id=esp_event.get("MessageID", None),
metadata=metadata,
mta_response=esp_event.get("Details", None),
recipient=recipient,
reject_reason=reject_reason,
tags=tags,
timestamp=timestamp,
user_agent=esp_event.get("UserAgent", None),
click_url=esp_event.get("OriginalLink", None),
)
class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
"""Handler for Postmark inbound webhook"""
signal = inbound
def esp_to_anymail_event(self, esp_event):
# Check correct webhook (inbound events don't have RecordType):
esp_record_type = esp_event.get("RecordType", "Inbound")
if esp_record_type != "Inbound":
raise AnymailConfigurationError(
f"You seem to have set Postmark's *{esp_record_type}* webhook"
f" to Anymail's Postmark *inbound* webhook URL."
)
headers = esp_event.get("Headers", [])
# Postmark inbound prepends "Return-Path" to Headers list
# (but it doesn't appear in original message or RawEmail).
# (A Return-Path anywhere else in the headers or RawEmail
# can't be considered legitimate.)
envelope_sender = None
if len(headers) > 0 and headers[0]["Name"].lower() == "return-path":
envelope_sender = unquote(headers[0]["Value"]) # remove <>
headers = headers[1:] # don't include in message construction
if "RawEmail" in esp_event:
message = AnymailInboundMessage.parse_raw_mime(esp_event["RawEmail"])
# Postmark provides Bcc when delivered-to is not in To header,
# but doesn't add it to the RawEmail.
if esp_event.get("BccFull") and "Bcc" not in message:
message["Bcc"] = self._addresses(esp_event["BccFull"])
else:
# RawEmail not included in payload; construct from parsed data.
attachments = [
AnymailInboundMessage.construct_attachment(
content_type=attachment["ContentType"],
# Real payloads have "Content", test payloads have "Data" (?!):
content=attachment.get("Content") or attachment["Data"],
base64=True,
filename=attachment.get("Name"),
content_id=attachment.get("ContentID"),
)
for attachment in esp_event.get("Attachments", [])
]
message = AnymailInboundMessage.construct(
from_email=self._address(esp_event.get("FromFull")),
to=self._addresses(esp_event.get("ToFull")),
cc=self._addresses(esp_event.get("CcFull")),
bcc=self._addresses(esp_event.get("BccFull")),
subject=esp_event.get("Subject", ""),
headers=((header["Name"], header["Value"]) for header in headers),
text=esp_event.get("TextBody", ""),
html=esp_event.get("HtmlBody", ""),
attachments=attachments,
)
# Postmark strips these headers and provides them as separate event fields:
if esp_event.get("Date") and "Date" not in message:
message["Date"] = esp_event["Date"]
if esp_event.get("ReplyTo") and "Reply-To" not in message:
message["Reply-To"] = esp_event["ReplyTo"]
message.envelope_sender = envelope_sender
message.envelope_recipient = esp_event.get("OriginalRecipient")
message.stripped_text = esp_event.get("StrippedTextReply")
message.spam_detected = message.get("X-Spam-Status", "No").lower() == "yes"
try:
message.spam_score = float(message["X-Spam-Score"])
except (TypeError, ValueError):
pass
return AnymailInboundEvent(
event_type=EventType.INBOUND,
# Postmark doesn't provide inbound event timestamp:
timestamp=None,
# Postmark uuid, different from Message-ID mime header:
event_id=esp_event.get("MessageID", None),
esp_event=esp_event,
message=message,
)
@classmethod
def _address(cls, full):
"""
Return a formatted email address
from a Postmark inbound {From,To,Cc,Bcc}Full dict
"""
if full is None:
return ""
return str(
EmailAddress(
display_name=full.get("Name", ""),
addr_spec=full.get("Email", ""),
)
)
@classmethod
def _addresses(cls, full_list):
"""
Return a formatted email address list string
from a Postmark inbound {To,Cc,Bcc}Full[] list of dicts
"""
if full_list is None:
return None
return ", ".join(cls._address(addr) for addr in full_list)