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,21 +1,23 @@
import json
from base64 import b64encode
from textwrap import dedent
from unittest.mock import ANY
from django.test import tag
from anymail.exceptions import AnymailConfigurationError, AnymailWarning
from anymail.exceptions import AnymailConfigurationError
from anymail.inbound import AnymailInboundMessage
from anymail.signals import AnymailInboundEvent
from anymail.webhooks.postmark import PostmarkInboundWebhookView
from .utils import sample_email_content, sample_image_content
from .utils import sample_email_content, sample_image_content, test_file_content
from .webhook_cases import WebhookTestCase
@tag("postmark")
class PostmarkInboundTestCase(WebhookTestCase):
def test_inbound_basics(self):
# without "Include raw email content in JSON payload"
raw_event = {
"FromFull": {
"Email": "from+test@example.org",
@@ -32,9 +34,10 @@ class PostmarkInboundTestCase(WebhookTestCase):
],
"CcFull": [{"Email": "cc@example.com", "Name": "", "MailboxHash": ""}],
"BccFull": [
# Postmark provides Bcc if delivered-to address is not in To field:
{
"Email": "bcc@example.com",
"Name": "Postmark documents blind cc on inbound email (?)",
"Email": "test@inbound.example.com",
"Name": "",
"MailboxHash": "",
}
],
@@ -48,34 +51,29 @@ class PostmarkInboundTestCase(WebhookTestCase):
"StrippedTextReply": "stripped plaintext body",
"Tag": "",
"Headers": [
{"Name": "Return-Path", "Value": "<envelope-from@example.org>"},
{
"Name": "Received",
"Value": "from mail.example.org by inbound.postmarkapp.com ...",
},
{
"Name": "X-Spam-Checker-Version",
"Value": "SpamAssassin 3.4.0 (2014-02-07)"
" onp-pm-smtp-inbound01b-aws-useast2b",
"Value": "SpamAssassin 3.4.0 (2014-02-07) on p-pm-smtp-inbound01b-aws-useast2b", # NOQA: E501
},
{"Name": "X-Spam-Status", "Value": "No"},
{"Name": "X-Spam-Score", "Value": "1.7"},
{"Name": "X-Spam-Tests", "Value": "SPF_PASS"},
{
"Name": "Received-SPF",
"Value": "Pass (sender SPF authorized) identity=mailfrom;"
" client-ip=333.3.3.3;"
" helo=mail-02.example.org;"
" envelope-from=envelope-from@example.org;"
" receiver=test@inbound.example.com",
},
{
"Name": "Received",
"Value": "by mail.example.org for <test@inbound.example.com> ...",
},
{
"Name": "Received",
"Value": "by 10.10.1.71 with HTTP;"
" Wed, 11 Oct 2017 18:31:04 -0700 (PDT)",
"Value": "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)",
},
{
"Name": "Return-Path",
"Value": "<fake-return-path@postmark-should-have-removed>",
},
{"Name": "MIME-Version", "Value": "1.0"},
{"Name": "Message-ID", "Value": "<CAEPk3R+4Zr@mail.example.org>"},
@@ -114,6 +112,7 @@ class PostmarkInboundTestCase(WebhookTestCase):
["Test Inbound <test@inbound.example.com>", "other@example.com"],
)
self.assertEqual([str(e) for e in message.cc], ["cc@example.com"])
self.assertEqual([str(e) for e in message.bcc], ["test@inbound.example.com"])
self.assertEqual(message.subject, "Test subject")
self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00")
self.assertEqual(message.text, "Test body plain")
@@ -165,27 +164,14 @@ class PostmarkInboundTestCase(WebhookTestCase):
"ContentType": 'message/rfc822; charset="us-ascii"',
"ContentLength": len(email_content),
},
# This is an attachement like send by the test webhook
# A workaround is implemented to handle it.
# Once Postmark solves the bug on their side this workaround
# can be reverted.
{
"Name": "test.txt",
"ContentType": "text/plain",
"Data": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu",
"ContentLength": 45,
},
]
}
with self.assertWarnsRegex(
AnymailWarning, r"Received a test webhook attachment. "
):
response = self.client.post(
"/anymail/postmark/inbound/",
content_type="application/json",
data=json.dumps(raw_event),
)
response = self.client.post(
"/anymail/postmark/inbound/",
content_type="application/json",
data=json.dumps(raw_event),
)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.inbound_handler,
@@ -196,7 +182,7 @@ class PostmarkInboundTestCase(WebhookTestCase):
event = kwargs["event"]
message = event.message
attachments = message.attachments # AnymailInboundMessage convenience accessor
self.assertEqual(len(attachments), 3)
self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0].get_filename(), "test.txt")
self.assertEqual(attachments[0].get_content_type(), "text/plain")
self.assertEqual(attachments[0].get_content_text(), "test attachment")
@@ -205,14 +191,6 @@ class PostmarkInboundTestCase(WebhookTestCase):
attachments[1].get_content_bytes(), email_content
)
# Attachment of test webhook
self.assertEqual(attachments[2].get_filename(), "test.txt")
self.assertEqual(attachments[2].get_content_type(), "text/plain")
self.assertEqual(
attachments[2].get_content_text(),
"This is attachment contents, base-64 encoded.",
)
inlines = message.inline_attachments
self.assertEqual(len(inlines), 1)
inline = inlines["abc123"]
@@ -220,119 +198,160 @@ class PostmarkInboundTestCase(WebhookTestCase):
self.assertEqual(inline.get_content_type(), "image/png")
self.assertEqual(inline.get_content_bytes(), image_content)
def test_envelope_sender(self):
# Anymail extracts envelope-sender from Postmark Received-SPF header
def test_inbound_with_raw_email(self):
# With "Include raw email content in JSON payload"
raw_event = {
"Headers": [
# (Postmark's "RawEmail" actually uses \n rather than \r\n)
"RawEmail": dedent(
"""\
Received: from mail.example.org by inbound.postmarkapp.com ...
X-Spam-Checker-Version: SpamAssassin 3.4.0 (2014-02-07)
\ton p-pm-smtp-inbound01b-aws-useast2b
X-Spam-Status: No
X-Spam-Score: 1.7
X-Spam-Tests: SPF_PASS
Received: by mail.example.org for <test@inbound.example.com> ...
Received: by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)
MIME-Version: 1.0
Message-ID: <CAEPk3R+4Zr@mail.example.org>
Return-Path: <fake-return-path@postmark-should-have-removed>
From: "Displayed From" <from+test@example.org>
To: Test Inbound <test@inbound.example.com>, other@example.com
Cc: cc@example.com
Reply-To: from+test@milter.example.org
Subject: Test subject
Date: Wed, 11 Oct 2017 18:31:04 -0700
Content-Type: multipart/alternative; boundary="BoUnDaRy1"
--BoUnDaRy1
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
Test body plain
--BoUnDaRy1
Content-Type: multipart/related; boundary="bOuNdArY2"
--bOuNdArY2
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
<div>Test body html</div>
--bOuNdArY2
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
Content-Disposition: attachment; filename="attachment.txt"
This is an attachment
--bOuNdArY2--
--BoUnDaRy1--
"""
),
"BccFull": [
# Postmark provides Bcc if delivered-to address is not in To field
# (but not in RawEmail)
{
"Name": "Received-SPF",
"Value": "Pass (sender SPF authorized) identity=mailfrom;"
" client-ip=333.3.3.3;"
" helo=mail-02.example.org;"
" envelope-from=envelope-from@example.org;"
" receiver=test@inbound.example.com",
"Email": "test@inbound.example.com",
"Name": "",
"MailboxHash": "",
}
],
"OriginalRecipient": "test@inbound.example.com",
"MessageID": "22c74902-a0c1-4511-804f2-341342852c90",
"StrippedTextReply": "stripped plaintext body",
"Tag": "",
"Headers": [
{"Name": "Return-Path", "Value": "<envelope-from@example.org>"},
# ... All the other headers would be here ...
# This is a fake header (not in RawEmail) to make sure we only
# add headers in one place:
{
"Name": "X-No-Duplicates",
"Value": "headers only from RawEmail",
},
],
"Attachments": [
# ... Real attachments would appear here. ...
# This is a fake one (not in RawEmail) to make sure we only
# add attachments in one place:
{
"Name": "no-duplicates.txt",
"Content": b64encode("fake attachment".encode("utf-8")).decode(
"ascii"
),
"ContentType": "text/plain",
"ContentLength": len("fake attachment"),
},
],
}
response = self.client.post(
"/anymail/postmark/inbound/",
content_type="application/json",
data=json.dumps(raw_event),
)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.inbound_handler,
sender=PostmarkInboundWebhookView,
event=ANY,
esp_name="Postmark",
)
# AnymailInboundEvent
event = kwargs["event"]
self.assertIsInstance(event, AnymailInboundEvent)
self.assertEqual(event.event_type, "inbound")
# Postmark doesn't provide inbound event timestamp:
self.assertIsNone(event.timestamp)
self.assertEqual(event.event_id, "22c74902-a0c1-4511-804f2-341342852c90")
self.assertIsInstance(event.message, AnymailInboundMessage)
self.assertEqual(event.esp_event, raw_event)
# AnymailInboundMessage - convenience properties
message = event.message
self.assertEqual(message.from_email.display_name, "Displayed From")
self.assertEqual(message.from_email.addr_spec, "from+test@example.org")
self.assertEqual(
self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender,
"envelope-from@example.org",
[str(e) for e in message.to],
["Test Inbound <test@inbound.example.com>", "other@example.com"],
)
self.assertEqual([str(e) for e in message.cc], ["cc@example.com"])
self.assertEqual([str(e) for e in message.bcc], ["test@inbound.example.com"])
self.assertEqual(message.subject, "Test subject")
self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00")
self.assertEqual(message.text, "Test body plain")
self.assertEqual(message.html, "<div>Test body html</div>")
# Allow neutral SPF response
self.client.post(
"/anymail/postmark/inbound/",
content_type="application/json",
data=json.dumps(
{
"Headers": [
{
"Name": "Received-SPF",
"Value": "Neutral (no SPF record exists)"
" identity=mailfrom;"
" envelope-from=envelope-from@example.org",
}
]
}
),
)
self.assertEqual(message.envelope_sender, "envelope-from@example.org")
self.assertEqual(message.envelope_recipient, "test@inbound.example.com")
self.assertEqual(message.stripped_text, "stripped plaintext body")
# Postmark doesn't provide stripped html:
self.assertIsNone(message.stripped_html)
self.assertIs(message.spam_detected, False)
self.assertEqual(message.spam_score, 1.7)
# AnymailInboundMessage - other headers
self.assertEqual(message["Message-ID"], "<CAEPk3R+4Zr@mail.example.org>")
self.assertEqual(message["Reply-To"], "from+test@milter.example.org")
self.assertEqual(
self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender,
"envelope-from@example.org",
message.get_all("Received"),
[
"from mail.example.org by inbound.postmarkapp.com ...",
"by mail.example.org for <test@inbound.example.com> ...",
"by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)",
],
)
# Ignore fail/softfail
self.client.post(
"/anymail/postmark/inbound/",
content_type="application/json",
data=json.dumps(
{
"Headers": [
{
"Name": "Received-SPF",
"Value": "Fail (sender not SPF authorized)"
" identity=mailfrom;"
" envelope-from=spoofed@example.org",
}
]
}
),
)
self.assertIsNone(
self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender
)
# Attachments (from RawEmail only, not also from parsed):
attachments = message.attachments
self.assertEqual(len(attachments), 1)
self.assertEqual(attachments[0].get_filename(), "attachment.txt")
self.assertEqual(attachments[0].get_content_type(), "text/plain")
self.assertEqual(attachments[0].get_content_text(), "This is an attachment")
# Ignore garbage
self.client.post(
"/anymail/postmark/inbound/",
content_type="application/json",
data=json.dumps(
{
"Headers": [
{
"Name": "Received-SPF",
"Value": "ThisIsNotAValidReceivedSPFHeader@example.org",
}
]
}
),
)
self.assertIsNone(
self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender
)
# Ignore multiple Received-SPF headers
self.client.post(
"/anymail/postmark/inbound/",
content_type="application/json",
data=json.dumps(
{
"Headers": [
{
"Name": "Received-SPF",
"Value": "Fail (sender not SPF authorized)"
" identity=mailfrom;"
" envelope-from=spoofed@example.org",
},
{
"Name": "Received-SPF",
"Value": "Pass (malicious sender added this)"
" identity=mailfrom;"
" envelope-from=spoofed@example.org",
},
]
}
),
)
self.assertIsNone(
self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender
)
# Make sure we didn't load headers from both RawEmail and parsed:
self.assertNotIn("X-No-Duplicates", message)
def test_misconfigured_tracking(self):
errmsg = (
@@ -345,3 +364,31 @@ class PostmarkInboundTestCase(WebhookTestCase):
content_type="application/json",
data=json.dumps({"RecordType": "Delivery"}),
)
def test_check_payload(self):
# Postmark's "Check" button in the inbound webhook dashboard posts a static
# payload that doesn't match their docs or actual inbound message payloads.
# (Its attachments have "Data" rather than "Content".)
# They apparently have no plans to fix it, so make sure Anymail can handle it.
for filename in [
# Actual test payloads from 2023-05-05:
"postmark-inbound-test-payload.json",
"postmark-inbound-test-payload-with-raw.json",
]:
with self.subTest(filename=filename):
self.inbound_handler.reset_mock() # (subTest doesn't setUp/tearDown)
test_payload = test_file_content(filename)
response = self.client.post(
"/anymail/postmark/inbound/",
content_type="application/json",
data=test_payload,
)
self.assertEqual(response.status_code, 200)
self.assert_handler_called_once_with(
self.inbound_handler,
sender=PostmarkInboundWebhookView,
event=ANY,
esp_name="Postmark",
)
# Don't care about the actual test message contents here,
# just want to make sure it parses and signals inbound without error.