Files
django-anymail/anymail/webhooks/postmark.py
Therry van Neerven 885eb9b98a Postmark: workaround invalid "test inbound" data
Postmark's "test" button in their inbound settings
posts data with attachments that don't match their docs or
actual inbound behavior. Accept that and issue a warning.

Closes #304
2023-04-22 12:00:05 -07:00

266 lines
10 KiB
Python

import json
import warnings
from django.utils.dateparse import parse_datetime
from ..exceptions import AnymailConfigurationError, AnymailWarning
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):
if esp_event.get("RecordType", "Inbound") != "Inbound":
raise AnymailConfigurationError(
"You seem to have set Postmark's *%s* webhook "
"to Anymail's Postmark *inbound* webhook URL." % esp_event["RecordType"]
)
attachments = [
AnymailInboundMessage.construct_attachment(
content_type=attachment["ContentType"],
content=(
attachment.get("Content")
# WORKAROUND:
# The test webhooks are not like their real webhooks
# This allows the test webhooks to be parsed.
or attachment["Data"]
),
base64=True,
filename=attachment.get("Name", "") or None,
content_id=attachment.get("ContentID", "") or None,
)
for attachment in esp_event.get("Attachments", [])
]
# Warning to the user regarding the workaround of above.
for attachment in esp_event.get("Attachments", []):
if "Data" in attachment:
warnings.warn(
"Received a test webhook attachment. "
"It is recommended to test with real inbound events. "
"See https://github.com/anymail/django-anymail/issues/304 "
"for more information.",
AnymailWarning,
)
break
message = AnymailInboundMessage.construct(
from_email=self._address(esp_event.get("FromFull")),
to=", ".join([self._address(to) for to in esp_event.get("ToFull", [])]),
cc=", ".join([self._address(cc) for cc in esp_event.get("CcFull", [])]),
# bcc? Postmark specs this for inbound events,
# but it's unclear how it could occur
subject=esp_event.get("Subject", ""),
headers=[
(header["Name"], header["Value"])
for header in esp_event.get("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 "Date" in esp_event and "Date" not in message:
message["Date"] = esp_event["Date"]
if "ReplyTo" in esp_event and "Reply-To" not in message:
message["Reply-To"] = esp_event["ReplyTo"]
# Postmark doesn't have a separate envelope-sender field, but it can
# be extracted from the Received-SPF header that Postmark will have added.
# (More than one Received-SPF? someone's up to something weird?)
if len(message.get_all("Received-SPF", [])) == 1:
received_spf = message["Received-SPF"].lower()
if received_spf.startswith( # not fail/softfail
"pass"
) or received_spf.startswith("neutral"):
message.envelope_sender = message.get_param(
"envelope-from", None, header="Received-SPF"
)
message.envelope_recipient = esp_event.get("OriginalRecipient", None)
message.stripped_text = esp_event.get("StrippedTextReply", None)
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,
)
@staticmethod
def _address(full):
"""
Return a formatted email address
from a Postmark inbound {From,To,Cc}Full dict
"""
if full is None:
return ""
return str(
EmailAddress(
display_name=full.get("Name", ""),
addr_spec=full.get("Email", ""),
)
)