diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 92807a1..bb69d26 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -59,6 +59,19 @@ Breaking changes * 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 ~~~~~ diff --git a/anymail/inbound.py b/anymail/inbound.py index 1b3d35c..97d7d54 100644 --- a/anymail/inbound.py +++ b/anymail/inbound.py @@ -71,6 +71,12 @@ class AnymailInboundMessage(Message): # equivalent to Python 3.2+ message['Cc'].addresses 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 def subject(self): """str value of Subject header, or None""" @@ -233,6 +239,7 @@ class AnymailInboundMessage(Message): from_email=None, to=None, cc=None, + bcc=None, subject=None, headers=None, text=None, @@ -252,6 +259,7 @@ class AnymailInboundMessage(Message): :param from_email: {str|None} value for From header :param to: {str|None} value for To 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 headers: {sequence[(str, str)]|mapping|None} additional headers :param text: {str|None} plaintext body @@ -279,6 +287,9 @@ class AnymailInboundMessage(Message): if cc is not None: del msg["Cc"] msg["Cc"] = cc + if bcc is not None: + del msg["Bcc"] + msg["Bcc"] = bcc if subject is not None: del msg["Subject"] msg["Subject"] = subject diff --git a/anymail/webhooks/postmark.py b/anymail/webhooks/postmark.py index 652947d..bbc53e6 100644 --- a/anymail/webhooks/postmark.py +++ b/anymail/webhooks/postmark.py @@ -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) diff --git a/docs/esps/postmark.rst b/docs/esps/postmark.rst index 2305f3d..7360c3e 100644 --- a/docs/esps/postmark.rst +++ b/docs/esps/postmark.rst @@ -244,19 +244,28 @@ a `dict` of Postmark `delivery ` -handling, follow Postmark's `Inbound Processing`_ guide to configure -an inbound server pointing to Anymail's inbound webhook. +To receive email from Postmark through Anymail's normalized +:ref:`inbound ` handling, follow Postmark's guide to +`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/` * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site -Anymail handles the "parse an email" part of Postmark's instructions for you, but you'll -likely want to work through the other sections to set up a custom inbound domain, and -perhaps configure inbound spam blocking. +We recommend enabling the "Include raw email content in JSON payload" checkbox. +Anymail's inbound handling supports either choice, but raw email is preferred +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 diff --git a/tests/test_files/postmark-inbound-test-payload-with-raw.json b/tests/test_files/postmark-inbound-test-payload-with-raw.json new file mode 100644 index 0000000..2373ae8 --- /dev/null +++ b/tests/test_files/postmark-inbound-test-payload-with-raw.json @@ -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\" ", + "ToFull": [ + { + "Email": "mailbox+SampleHash@inbound.postmarkapp.com", + "Name": "Firstname Lastname", + "MailboxHash": "SampleHash" + } + ], + "Cc": "\"First Cc\" , secondCc@postmarkapp.com", + "CcFull": [ + { + "Email": "firstcc@postmarkapp.com", + "Name": "First Cc", + "MailboxHash": "" + }, + { + "Email": "secondCc@postmarkapp.com", + "Name": "", + "MailboxHash": "" + } + ], + "Bcc": "\"First Bcc\" ", + "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": "

This is a test html body.<\/p><\/body><\/html>", + "StrippedTextReply": "This is the reply text", + "RawEmail": "From: Postmarkapp Support \r\nTo: Firstname Lastname \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 + } + ] +} diff --git a/tests/test_files/postmark-inbound-test-payload.json b/tests/test_files/postmark-inbound-test-payload.json new file mode 100644 index 0000000..b2cd137 --- /dev/null +++ b/tests/test_files/postmark-inbound-test-payload.json @@ -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\" ", + "ToFull": [ + { + "Email": "mailbox+SampleHash@inbound.postmarkapp.com", + "Name": "Firstname Lastname", + "MailboxHash": "SampleHash" + } + ], + "Cc": "\"First Cc\" , secondCc@postmarkapp.com", + "CcFull": [ + { + "Email": "firstcc@postmarkapp.com", + "Name": "First Cc", + "MailboxHash": "" + }, + { + "Email": "secondCc@postmarkapp.com", + "Name": "", + "MailboxHash": "" + } + ], + "Bcc": "\"First Bcc\" ", + "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": "

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 + } + ] +} diff --git a/tests/test_inbound.py b/tests/test_inbound.py index 224f827..0f0d75b 100644 --- a/tests/test_inbound.py +++ b/tests/test_inbound.py @@ -284,6 +284,7 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase): from_email='"Sender, Inc." ', to="First To , to2@example.com", cc="First Cc , cc2@example.com", + bcc="bcc@example.com", ) self.assertEqual(str(msg.from_email), '"Sender, Inc." ') 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 ") 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 msg = AnymailInboundMessage() self.assertIsNone(msg.from_email) diff --git a/tests/test_postmark_inbound.py b/tests/test_postmark_inbound.py index 7dd8d4b..a88fe05 100644 --- a/tests/test_postmark_inbound.py +++ b/tests/test_postmark_inbound.py @@ -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": ""}, { "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 ...", }, { "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": "", }, {"Name": "MIME-Version", "Value": "1.0"}, {"Name": "Message-ID", "Value": ""}, @@ -114,6 +112,7 @@ class PostmarkInboundTestCase(WebhookTestCase): ["Test Inbound ", "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 ... + Received: by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT) + MIME-Version: 1.0 + Message-ID: + Return-Path: + From: "Displayed From" + To: Test Inbound , 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 + +

Test body html
+ --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": ""}, + # ... 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 ", "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, "
Test body html
") - # 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"], "") + 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 ...", + "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.