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

@@ -59,6 +59,19 @@ Breaking changes
* Require urllib3 1.25 or later (released 2019-04-29). * Require urllib3 1.25 or later (released 2019-04-29).
Features
~~~~~~~~
* **Postmark inbound:**
* Handle Postmark's "Include raw email content in JSON payload"
inbound option. Enabling this setting is recommended to get
the most accurate representation of any received email.
* Obtain ``envelope_sender`` from *Return-Path* Postmark now provides.
(Replaces potentially faulty *Received-SPF* header parsing.)
* Add *Bcc* header to inbound message if provided. Postmark adds bcc
when the delivered-to address does not appear in the *To* header.
Other Other
~~~~~ ~~~~~

View File

@@ -71,6 +71,12 @@ class AnymailInboundMessage(Message):
# equivalent to Python 3.2+ message['Cc'].addresses # equivalent to Python 3.2+ message['Cc'].addresses
return self.get_address_header("Cc") return self.get_address_header("Cc")
@property
def bcc(self):
"""list of EmailAddress objects from Bcc header"""
# equivalent to Python 3.2+ message['Bcc'].addresses
return self.get_address_header("Bcc")
@property @property
def subject(self): def subject(self):
"""str value of Subject header, or None""" """str value of Subject header, or None"""
@@ -233,6 +239,7 @@ class AnymailInboundMessage(Message):
from_email=None, from_email=None,
to=None, to=None,
cc=None, cc=None,
bcc=None,
subject=None, subject=None,
headers=None, headers=None,
text=None, text=None,
@@ -252,6 +259,7 @@ class AnymailInboundMessage(Message):
:param from_email: {str|None} value for From header :param from_email: {str|None} value for From header
:param to: {str|None} value for To header :param to: {str|None} value for To header
:param cc: {str|None} value for Cc header :param cc: {str|None} value for Cc header
:param bcc: {str|None} value for Bcc header
:param subject: {str|None} value for Subject header :param subject: {str|None} value for Subject header
:param headers: {sequence[(str, str)]|mapping|None} additional headers :param headers: {sequence[(str, str)]|mapping|None} additional headers
:param text: {str|None} plaintext body :param text: {str|None} plaintext body
@@ -279,6 +287,9 @@ class AnymailInboundMessage(Message):
if cc is not None: if cc is not None:
del msg["Cc"] del msg["Cc"]
msg["Cc"] = cc msg["Cc"] = cc
if bcc is not None:
del msg["Bcc"]
msg["Bcc"] = bcc
if subject is not None: if subject is not None:
del msg["Subject"] del msg["Subject"]
msg["Subject"] = subject msg["Subject"] = subject

View File

@@ -1,9 +1,9 @@
import json import json
import warnings from email.utils import unquote
from django.utils.dateparse import parse_datetime from django.utils.dateparse import parse_datetime
from ..exceptions import AnymailConfigurationError, AnymailWarning from ..exceptions import AnymailConfigurationError
from ..inbound import AnymailInboundMessage from ..inbound import AnymailInboundMessage
from ..signals import ( from ..signals import (
AnymailInboundEvent, AnymailInboundEvent,
@@ -161,77 +161,65 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
signal = inbound signal = inbound
def esp_to_anymail_event(self, esp_event): 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( raise AnymailConfigurationError(
"You seem to have set Postmark's *%s* webhook " f"You seem to have set Postmark's *{esp_record_type}* webhook"
"to Anymail's Postmark *inbound* webhook URL." % esp_event["RecordType"] f" to Anymail's Postmark *inbound* webhook URL."
) )
attachments = [ headers = esp_event.get("Headers", [])
AnymailInboundMessage.construct_attachment(
content_type=attachment["ContentType"], # Postmark inbound prepends "Return-Path" to Headers list
content=( # (but it doesn't appear in original message or RawEmail).
attachment.get("Content") # (A Return-Path anywhere else in the headers or RawEmail
# WORKAROUND: # can't be considered legitimate.)
# The test webhooks are not like their real webhooks envelope_sender = None
# This allows the test webhooks to be parsed. if len(headers) > 0 and headers[0]["Name"].lower() == "return-path":
or attachment["Data"] envelope_sender = unquote(headers[0]["Value"]) # remove <>
), headers = headers[1:] # don't include in message construction
base64=True,
filename=attachment.get("Name", "") or None, if "RawEmail" in esp_event:
content_id=attachment.get("ContentID", "") or None, 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. message.envelope_sender = envelope_sender
for attachment in esp_event.get("Attachments", []): message.envelope_recipient = esp_event.get("OriginalRecipient")
if "Data" in attachment: message.stripped_text = esp_event.get("StrippedTextReply")
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" message.spam_detected = message.get("X-Spam-Status", "No").lower() == "yes"
try: try:
@@ -249,11 +237,11 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
message=message, message=message,
) )
@staticmethod @classmethod
def _address(full): def _address(cls, full):
""" """
Return a formatted email address 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: if full is None:
return "" return ""
@@ -263,3 +251,13 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
addr_spec=full.get("Email", ""), 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)

View File

@@ -244,19 +244,28 @@ a `dict` of Postmark `delivery <https://postmarkapp.com/developer/webhooks/deliv
Inbound webhook Inbound webhook
--------------- ---------------
If you want to receive email from Postmark through Anymail's normalized :ref:`inbound <inbound>` To receive email from Postmark through Anymail's normalized
handling, follow Postmark's `Inbound Processing`_ guide to configure :ref:`inbound <inbound>` handling, follow Postmark's guide to
an inbound server pointing to Anymail's inbound webhook. `Configure an inbound server`_ that posts to Anymail's inbound webhook.
The InboundHookUrl setting will be: In their step 4, set the inbound webhook URL to:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/postmark/inbound/` :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/postmark/inbound/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret
* *yoursite.example.com* is your Django site * *yoursite.example.com* is your Django site
Anymail handles the "parse an email" part of Postmark's instructions for you, but you'll We recommend enabling the "Include raw email content in JSON payload" checkbox.
likely want to work through the other sections to set up a custom inbound domain, and Anymail's inbound handling supports either choice, but raw email is preferred
perhaps configure inbound spam blocking. to get the most accurate representation of any received message. (If you are using
Postmark's server API, this is the ``RawEmailEnabled`` option.)
.. versionchanged:: 10.0
Added handling for Postmark's "include raw email content".
You may also want to read through the "Inbound domain forwarding" and
"Configure inbound blocking" sections of Postmark's `Inbound Processing`_ guide.
.. _Configure an inbound server:
https://postmarkapp.com/developer/user-guide/inbound/configure-an-inbound-server
.. _Inbound Processing: https://postmarkapp.com/developer/user-guide/inbound .. _Inbound Processing: https://postmarkapp.com/developer/user-guide/inbound

View File

@@ -0,0 +1,64 @@
{
"FromName": "Postmarkapp Support",
"MessageStream": "inbound",
"From": "support@postmarkapp.com",
"FromFull": {
"Email": "support@postmarkapp.com",
"Name": "Postmarkapp Support",
"MailboxHash": ""
},
"To": "\"Firstname Lastname\" <mailbox+SampleHash@inbound.postmarkapp.com>",
"ToFull": [
{
"Email": "mailbox+SampleHash@inbound.postmarkapp.com",
"Name": "Firstname Lastname",
"MailboxHash": "SampleHash"
}
],
"Cc": "\"First Cc\" <firstcc@postmarkapp.com>, secondCc@postmarkapp.com",
"CcFull": [
{
"Email": "firstcc@postmarkapp.com",
"Name": "First Cc",
"MailboxHash": ""
},
{
"Email": "secondCc@postmarkapp.com",
"Name": "",
"MailboxHash": ""
}
],
"Bcc": "\"First Bcc\" <firstbcc@postmarkapp.com>",
"BccFull": [
{
"Email": "firstbcc@postmarkapp.com",
"Name": "First Bcc",
"MailboxHash": ""
}
],
"OriginalRecipient": "mailbox+SampleHash@inbound.postmarkapp.com",
"Subject": "Test subject",
"MessageID": "00000000-0000-0000-0000-000000000000",
"ReplyTo": "replyto@example.com",
"MailboxHash": "SampleHash",
"Date": "Fri, 5 May 2023 17:41:16 -0400",
"TextBody": "This is a test text body.",
"HtmlBody": "<html><body><p>This is a test html body.<\/p><\/body><\/html>",
"StrippedTextReply": "This is the reply text",
"RawEmail": "From: Postmarkapp Support <support@postmarkapp.com>\r\nTo: Firstname Lastname <mailbox+SampleHash@inbound.postmarkapp.com>\r\nSubject: Test subject\r\n\r\nThis is a test text body.\r\n",
"Tag": "TestTag",
"Headers": [
{
"Name": "X-Header-Test",
"Value": ""
}
],
"Attachments": [
{
"Name": "test.txt",
"ContentType": "text/plain",
"Data": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu",
"ContentLength": 45
}
]
}

View File

@@ -0,0 +1,63 @@
{
"FromName": "Postmarkapp Support",
"MessageStream": "inbound",
"From": "support@postmarkapp.com",
"FromFull": {
"Email": "support@postmarkapp.com",
"Name": "Postmarkapp Support",
"MailboxHash": ""
},
"To": "\"Firstname Lastname\" <mailbox+SampleHash@inbound.postmarkapp.com>",
"ToFull": [
{
"Email": "mailbox+SampleHash@inbound.postmarkapp.com",
"Name": "Firstname Lastname",
"MailboxHash": "SampleHash"
}
],
"Cc": "\"First Cc\" <firstcc@postmarkapp.com>, secondCc@postmarkapp.com",
"CcFull": [
{
"Email": "firstcc@postmarkapp.com",
"Name": "First Cc",
"MailboxHash": ""
},
{
"Email": "secondCc@postmarkapp.com",
"Name": "",
"MailboxHash": ""
}
],
"Bcc": "\"First Bcc\" <firstbcc@postmarkapp.com>",
"BccFull": [
{
"Email": "firstbcc@postmarkapp.com",
"Name": "First Bcc",
"MailboxHash": ""
}
],
"OriginalRecipient": "mailbox+SampleHash@inbound.postmarkapp.com",
"Subject": "Test subject",
"MessageID": "00000000-0000-0000-0000-000000000000",
"ReplyTo": "replyto@example.com",
"MailboxHash": "SampleHash",
"Date": "Fri, 5 May 2023 17:44:33 -0400",
"TextBody": "This is a test text body.",
"HtmlBody": "<html><body><p>This is a test html body.<\/p><\/body><\/html>",
"StrippedTextReply": "This is the reply text",
"Tag": "TestTag",
"Headers": [
{
"Name": "X-Header-Test",
"Value": ""
}
],
"Attachments": [
{
"Name": "test.txt",
"ContentType": "text/plain",
"Data": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu",
"ContentLength": 45
}
]
}

View File

@@ -284,6 +284,7 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase):
from_email='"Sender, Inc." <sender@example.com>', from_email='"Sender, Inc." <sender@example.com>',
to="First To <to1@example.com>, to2@example.com", to="First To <to1@example.com>, to2@example.com",
cc="First Cc <cc1@example.com>, cc2@example.com", cc="First Cc <cc1@example.com>, cc2@example.com",
bcc="bcc@example.com",
) )
self.assertEqual(str(msg.from_email), '"Sender, Inc." <sender@example.com>') self.assertEqual(str(msg.from_email), '"Sender, Inc." <sender@example.com>')
self.assertEqual(msg.from_email.addr_spec, "sender@example.com") self.assertEqual(msg.from_email.addr_spec, "sender@example.com")
@@ -301,6 +302,9 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase):
self.assertEqual(msg.cc[0].address, "First Cc <cc1@example.com>") self.assertEqual(msg.cc[0].address, "First Cc <cc1@example.com>")
self.assertEqual(msg.cc[1].address, "cc2@example.com") self.assertEqual(msg.cc[1].address, "cc2@example.com")
self.assertEqual(len(msg.bcc), 1)
self.assertEqual(msg.bcc[0].address, "bcc@example.com")
# Default None/empty lists # Default None/empty lists
msg = AnymailInboundMessage() msg = AnymailInboundMessage()
self.assertIsNone(msg.from_email) self.assertIsNone(msg.from_email)

View File

@@ -1,21 +1,23 @@
import json import json
from base64 import b64encode from base64 import b64encode
from textwrap import dedent
from unittest.mock import ANY from unittest.mock import ANY
from django.test import tag from django.test import tag
from anymail.exceptions import AnymailConfigurationError, AnymailWarning from anymail.exceptions import AnymailConfigurationError
from anymail.inbound import AnymailInboundMessage from anymail.inbound import AnymailInboundMessage
from anymail.signals import AnymailInboundEvent from anymail.signals import AnymailInboundEvent
from anymail.webhooks.postmark import PostmarkInboundWebhookView 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 from .webhook_cases import WebhookTestCase
@tag("postmark") @tag("postmark")
class PostmarkInboundTestCase(WebhookTestCase): class PostmarkInboundTestCase(WebhookTestCase):
def test_inbound_basics(self): def test_inbound_basics(self):
# without "Include raw email content in JSON payload"
raw_event = { raw_event = {
"FromFull": { "FromFull": {
"Email": "from+test@example.org", "Email": "from+test@example.org",
@@ -32,9 +34,10 @@ class PostmarkInboundTestCase(WebhookTestCase):
], ],
"CcFull": [{"Email": "cc@example.com", "Name": "", "MailboxHash": ""}], "CcFull": [{"Email": "cc@example.com", "Name": "", "MailboxHash": ""}],
"BccFull": [ "BccFull": [
# Postmark provides Bcc if delivered-to address is not in To field:
{ {
"Email": "bcc@example.com", "Email": "test@inbound.example.com",
"Name": "Postmark documents blind cc on inbound email (?)", "Name": "",
"MailboxHash": "", "MailboxHash": "",
} }
], ],
@@ -48,34 +51,29 @@ class PostmarkInboundTestCase(WebhookTestCase):
"StrippedTextReply": "stripped plaintext body", "StrippedTextReply": "stripped plaintext body",
"Tag": "", "Tag": "",
"Headers": [ "Headers": [
{"Name": "Return-Path", "Value": "<envelope-from@example.org>"},
{ {
"Name": "Received", "Name": "Received",
"Value": "from mail.example.org by inbound.postmarkapp.com ...", "Value": "from mail.example.org by inbound.postmarkapp.com ...",
}, },
{ {
"Name": "X-Spam-Checker-Version", "Name": "X-Spam-Checker-Version",
"Value": "SpamAssassin 3.4.0 (2014-02-07)" "Value": "SpamAssassin 3.4.0 (2014-02-07) on p-pm-smtp-inbound01b-aws-useast2b", # NOQA: E501
" onp-pm-smtp-inbound01b-aws-useast2b",
}, },
{"Name": "X-Spam-Status", "Value": "No"}, {"Name": "X-Spam-Status", "Value": "No"},
{"Name": "X-Spam-Score", "Value": "1.7"}, {"Name": "X-Spam-Score", "Value": "1.7"},
{"Name": "X-Spam-Tests", "Value": "SPF_PASS"}, {"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", "Name": "Received",
"Value": "by mail.example.org for <test@inbound.example.com> ...", "Value": "by mail.example.org for <test@inbound.example.com> ...",
}, },
{ {
"Name": "Received", "Name": "Received",
"Value": "by 10.10.1.71 with HTTP;" "Value": "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)",
" 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": "MIME-Version", "Value": "1.0"},
{"Name": "Message-ID", "Value": "<CAEPk3R+4Zr@mail.example.org>"}, {"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"], ["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.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.subject, "Test subject")
self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00") self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00")
self.assertEqual(message.text, "Test body plain") self.assertEqual(message.text, "Test body plain")
@@ -165,27 +164,14 @@ class PostmarkInboundTestCase(WebhookTestCase):
"ContentType": 'message/rfc822; charset="us-ascii"', "ContentType": 'message/rfc822; charset="us-ascii"',
"ContentLength": len(email_content), "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( response = self.client.post(
AnymailWarning, r"Received a test webhook attachment. " "/anymail/postmark/inbound/",
): content_type="application/json",
response = self.client.post( data=json.dumps(raw_event),
"/anymail/postmark/inbound/", )
content_type="application/json",
data=json.dumps(raw_event),
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.inbound_handler, self.inbound_handler,
@@ -196,7 +182,7 @@ class PostmarkInboundTestCase(WebhookTestCase):
event = kwargs["event"] event = kwargs["event"]
message = event.message message = event.message
attachments = message.attachments # AnymailInboundMessage convenience accessor 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_filename(), "test.txt")
self.assertEqual(attachments[0].get_content_type(), "text/plain") self.assertEqual(attachments[0].get_content_type(), "text/plain")
self.assertEqual(attachments[0].get_content_text(), "test attachment") self.assertEqual(attachments[0].get_content_text(), "test attachment")
@@ -205,14 +191,6 @@ class PostmarkInboundTestCase(WebhookTestCase):
attachments[1].get_content_bytes(), email_content 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 inlines = message.inline_attachments
self.assertEqual(len(inlines), 1) self.assertEqual(len(inlines), 1)
inline = inlines["abc123"] inline = inlines["abc123"]
@@ -220,119 +198,160 @@ class PostmarkInboundTestCase(WebhookTestCase):
self.assertEqual(inline.get_content_type(), "image/png") self.assertEqual(inline.get_content_type(), "image/png")
self.assertEqual(inline.get_content_bytes(), image_content) self.assertEqual(inline.get_content_bytes(), image_content)
def test_envelope_sender(self): def test_inbound_with_raw_email(self):
# Anymail extracts envelope-sender from Postmark Received-SPF header # With "Include raw email content in JSON payload"
raw_event = { 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", "Email": "test@inbound.example.com",
"Value": "Pass (sender SPF authorized) identity=mailfrom;" "Name": "",
" client-ip=333.3.3.3;" "MailboxHash": "",
" helo=mail-02.example.org;"
" envelope-from=envelope-from@example.org;"
" receiver=test@inbound.example.com",
} }
], ],
"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( response = self.client.post(
"/anymail/postmark/inbound/", "/anymail/postmark/inbound/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) 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.assertEqual(
self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender, [str(e) for e in message.to],
"envelope-from@example.org", ["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.assertEqual(message.envelope_sender, "envelope-from@example.org")
self.client.post( self.assertEqual(message.envelope_recipient, "test@inbound.example.com")
"/anymail/postmark/inbound/", self.assertEqual(message.stripped_text, "stripped plaintext body")
content_type="application/json", # Postmark doesn't provide stripped html:
data=json.dumps( self.assertIsNone(message.stripped_html)
{ self.assertIs(message.spam_detected, False)
"Headers": [ self.assertEqual(message.spam_score, 1.7)
{
"Name": "Received-SPF", # AnymailInboundMessage - other headers
"Value": "Neutral (no SPF record exists)" self.assertEqual(message["Message-ID"], "<CAEPk3R+4Zr@mail.example.org>")
" identity=mailfrom;" self.assertEqual(message["Reply-To"], "from+test@milter.example.org")
" envelope-from=envelope-from@example.org",
}
]
}
),
)
self.assertEqual( self.assertEqual(
self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender, message.get_all("Received"),
"envelope-from@example.org", [
"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 # Attachments (from RawEmail only, not also from parsed):
self.client.post( attachments = message.attachments
"/anymail/postmark/inbound/", self.assertEqual(len(attachments), 1)
content_type="application/json", self.assertEqual(attachments[0].get_filename(), "attachment.txt")
data=json.dumps( self.assertEqual(attachments[0].get_content_type(), "text/plain")
{ self.assertEqual(attachments[0].get_content_text(), "This is an attachment")
"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
)
# Ignore garbage # Make sure we didn't load headers from both RawEmail and parsed:
self.client.post( self.assertNotIn("X-No-Duplicates", message)
"/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
)
def test_misconfigured_tracking(self): def test_misconfigured_tracking(self):
errmsg = ( errmsg = (
@@ -345,3 +364,31 @@ class PostmarkInboundTestCase(WebhookTestCase):
content_type="application/json", content_type="application/json",
data=json.dumps({"RecordType": "Delivery"}), 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.