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
This commit is contained in:
Mike Edmunds
2023-05-05 18:24:01 -07:00
parent 746cf0e24e
commit 744d467f70
8 changed files with 425 additions and 216 deletions

View File

@@ -1,9 +1,9 @@
import json
import warnings
from email.utils import unquote
from django.utils.dateparse import parse_datetime
from ..exceptions import AnymailConfigurationError, AnymailWarning
from ..exceptions import AnymailConfigurationError
from ..inbound import AnymailInboundMessage
from ..signals import (
AnymailInboundEvent,
@@ -161,77 +161,65 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
signal = inbound
def esp_to_anymail_event(self, esp_event):
if esp_event.get("RecordType", "Inbound") != "Inbound":
# Check correct webhook (inbound events don't have RecordType):
esp_record_type = esp_event.get("RecordType", "Inbound")
if esp_record_type != "Inbound":
raise AnymailConfigurationError(
"You seem to have set Postmark's *%s* webhook "
"to Anymail's Postmark *inbound* webhook URL." % esp_event["RecordType"]
f"You seem to have set Postmark's *{esp_record_type}* webhook"
f" to Anymail's Postmark *inbound* webhook URL."
)
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,
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,
)
for attachment in esp_event.get("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"]
# 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.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:
@@ -249,11 +237,11 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
message=message,
)
@staticmethod
def _address(full):
@classmethod
def _address(cls, full):
"""
Return a formatted email address
from a Postmark inbound {From,To,Cc}Full dict
from a Postmark inbound {From,To,Cc,Bcc}Full dict
"""
if full is None:
return ""
@@ -263,3 +251,13 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
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)