Skip to content

Commit 744d467

Browse files
committed
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
1 parent 746cf0e commit 744d467

File tree

8 files changed

+423
-214
lines changed

8 files changed

+423
-214
lines changed

CHANGELOG.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ Breaking changes
5959

6060
* Require urllib3 1.25 or later (released 2019-04-29).
6161

62+
Features
63+
~~~~~~~~
64+
65+
* **Postmark inbound:**
66+
67+
* Handle Postmark's "Include raw email content in JSON payload"
68+
inbound option. Enabling this setting is recommended to get
69+
the most accurate representation of any received email.
70+
* Obtain ``envelope_sender`` from *Return-Path* Postmark now provides.
71+
(Replaces potentially faulty *Received-SPF* header parsing.)
72+
* Add *Bcc* header to inbound message if provided. Postmark adds bcc
73+
when the delivered-to address does not appear in the *To* header.
74+
6275
Other
6376
~~~~~
6477

anymail/inbound.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ def cc(self):
7171
# equivalent to Python 3.2+ message['Cc'].addresses
7272
return self.get_address_header("Cc")
7373

74+
@property
75+
def bcc(self):
76+
"""list of EmailAddress objects from Bcc header"""
77+
# equivalent to Python 3.2+ message['Bcc'].addresses
78+
return self.get_address_header("Bcc")
79+
7480
@property
7581
def subject(self):
7682
"""str value of Subject header, or None"""
@@ -233,6 +239,7 @@ def construct(
233239
from_email=None,
234240
to=None,
235241
cc=None,
242+
bcc=None,
236243
subject=None,
237244
headers=None,
238245
text=None,
@@ -252,6 +259,7 @@ def construct(
252259
:param from_email: {str|None} value for From header
253260
:param to: {str|None} value for To header
254261
:param cc: {str|None} value for Cc header
262+
:param bcc: {str|None} value for Bcc header
255263
:param subject: {str|None} value for Subject header
256264
:param headers: {sequence[(str, str)]|mapping|None} additional headers
257265
:param text: {str|None} plaintext body
@@ -279,6 +287,9 @@ def construct(
279287
if cc is not None:
280288
del msg["Cc"]
281289
msg["Cc"] = cc
290+
if bcc is not None:
291+
del msg["Bcc"]
292+
msg["Bcc"] = bcc
282293
if subject is not None:
283294
del msg["Subject"]
284295
msg["Subject"] = subject

anymail/webhooks/postmark.py

Lines changed: 68 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import json
2-
import warnings
2+
from email.utils import unquote
33

44
from django.utils.dateparse import parse_datetime
55

6-
from ..exceptions import AnymailConfigurationError, AnymailWarning
6+
from ..exceptions import AnymailConfigurationError
77
from ..inbound import AnymailInboundMessage
88
from ..signals import (
99
AnymailInboundEvent,
@@ -161,77 +161,65 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
161161
signal = inbound
162162

163163
def esp_to_anymail_event(self, esp_event):
164-
if esp_event.get("RecordType", "Inbound") != "Inbound":
164+
# Check correct webhook (inbound events don't have RecordType):
165+
esp_record_type = esp_event.get("RecordType", "Inbound")
166+
if esp_record_type != "Inbound":
165167
raise AnymailConfigurationError(
166-
"You seem to have set Postmark's *%s* webhook "
167-
"to Anymail's Postmark *inbound* webhook URL." % esp_event["RecordType"]
168+
f"You seem to have set Postmark's *{esp_record_type}* webhook"
169+
f" to Anymail's Postmark *inbound* webhook URL."
168170
)
169171

170-
attachments = [
171-
AnymailInboundMessage.construct_attachment(
172-
content_type=attachment["ContentType"],
173-
content=(
174-
attachment.get("Content")
175-
# WORKAROUND:
176-
# The test webhooks are not like their real webhooks
177-
# This allows the test webhooks to be parsed.
178-
or attachment["Data"]
179-
),
180-
base64=True,
181-
filename=attachment.get("Name", "") or None,
182-
content_id=attachment.get("ContentID", "") or None,
183-
)
184-
for attachment in esp_event.get("Attachments", [])
185-
]
186-
187-
# Warning to the user regarding the workaround of above.
188-
for attachment in esp_event.get("Attachments", []):
189-
if "Data" in attachment:
190-
warnings.warn(
191-
"Received a test webhook attachment. "
192-
"It is recommended to test with real inbound events. "
193-
"See https://github.com/anymail/django-anymail/issues/304 "
194-
"for more information.",
195-
AnymailWarning,
196-
)
197-
break
198-
199-
message = AnymailInboundMessage.construct(
200-
from_email=self._address(esp_event.get("FromFull")),
201-
to=", ".join([self._address(to) for to in esp_event.get("ToFull", [])]),
202-
cc=", ".join([self._address(cc) for cc in esp_event.get("CcFull", [])]),
203-
# bcc? Postmark specs this for inbound events,
204-
# but it's unclear how it could occur
205-
subject=esp_event.get("Subject", ""),
206-
headers=[
207-
(header["Name"], header["Value"])
208-
for header in esp_event.get("Headers", [])
209-
],
210-
text=esp_event.get("TextBody", ""),
211-
html=esp_event.get("HtmlBody", ""),
212-
attachments=attachments,
213-
)
172+
headers = esp_event.get("Headers", [])
173+
174+
# Postmark inbound prepends "Return-Path" to Headers list
175+
# (but it doesn't appear in original message or RawEmail).
176+
# (A Return-Path anywhere else in the headers or RawEmail
177+
# can't be considered legitimate.)
178+
envelope_sender = None
179+
if len(headers) > 0 and headers[0]["Name"].lower() == "return-path":
180+
envelope_sender = unquote(headers[0]["Value"]) # remove <>
181+
headers = headers[1:] # don't include in message construction
182+
183+
if "RawEmail" in esp_event:
184+
message = AnymailInboundMessage.parse_raw_mime(esp_event["RawEmail"])
185+
# Postmark provides Bcc when delivered-to is not in To header,
186+
# but doesn't add it to the RawEmail.
187+
if esp_event.get("BccFull") and "Bcc" not in message:
188+
message["Bcc"] = self._addresses(esp_event["BccFull"])
214189

215-
# Postmark strips these headers and provides them as separate event fields:
216-
if "Date" in esp_event and "Date" not in message:
217-
message["Date"] = esp_event["Date"]
218-
if "ReplyTo" in esp_event and "Reply-To" not in message:
219-
message["Reply-To"] = esp_event["ReplyTo"]
220-
221-
# Postmark doesn't have a separate envelope-sender field, but it can
222-
# be extracted from the Received-SPF header that Postmark will have added.
223-
# (More than one Received-SPF? someone's up to something weird?)
224-
if len(message.get_all("Received-SPF", [])) == 1:
225-
received_spf = message["Received-SPF"].lower()
226-
if received_spf.startswith( # not fail/softfail
227-
"pass"
228-
) or received_spf.startswith("neutral"):
229-
message.envelope_sender = message.get_param(
230-
"envelope-from", None, header="Received-SPF"
190+
else:
191+
# RawEmail not included in payload; construct from parsed data.
192+
attachments = [
193+
AnymailInboundMessage.construct_attachment(
194+
content_type=attachment["ContentType"],
195+
# Real payloads have "Content", test payloads have "Data" (?!):
196+
content=attachment.get("Content") or attachment["Data"],
197+
base64=True,
198+
filename=attachment.get("Name"),
199+
content_id=attachment.get("ContentID"),
231200
)
201+
for attachment in esp_event.get("Attachments", [])
202+
]
203+
message = AnymailInboundMessage.construct(
204+
from_email=self._address(esp_event.get("FromFull")),
205+
to=self._addresses(esp_event.get("ToFull")),
206+
cc=self._addresses(esp_event.get("CcFull")),
207+
bcc=self._addresses(esp_event.get("BccFull")),
208+
subject=esp_event.get("Subject", ""),
209+
headers=((header["Name"], header["Value"]) for header in headers),
210+
text=esp_event.get("TextBody", ""),
211+
html=esp_event.get("HtmlBody", ""),
212+
attachments=attachments,
213+
)
214+
# Postmark strips these headers and provides them as separate event fields:
215+
if esp_event.get("Date") and "Date" not in message:
216+
message["Date"] = esp_event["Date"]
217+
if esp_event.get("ReplyTo") and "Reply-To" not in message:
218+
message["Reply-To"] = esp_event["ReplyTo"]
232219

233-
message.envelope_recipient = esp_event.get("OriginalRecipient", None)
234-
message.stripped_text = esp_event.get("StrippedTextReply", None)
220+
message.envelope_sender = envelope_sender
221+
message.envelope_recipient = esp_event.get("OriginalRecipient")
222+
message.stripped_text = esp_event.get("StrippedTextReply")
235223

236224
message.spam_detected = message.get("X-Spam-Status", "No").lower() == "yes"
237225
try:
@@ -249,11 +237,11 @@ def esp_to_anymail_event(self, esp_event):
249237
message=message,
250238
)
251239

252-
@staticmethod
253-
def _address(full):
240+
@classmethod
241+
def _address(cls, full):
254242
"""
255243
Return a formatted email address
256-
from a Postmark inbound {From,To,Cc}Full dict
244+
from a Postmark inbound {From,To,Cc,Bcc}Full dict
257245
"""
258246
if full is None:
259247
return ""
@@ -263,3 +251,13 @@ def _address(full):
263251
addr_spec=full.get("Email", ""),
264252
)
265253
)
254+
255+
@classmethod
256+
def _addresses(cls, full_list):
257+
"""
258+
Return a formatted email address list string
259+
from a Postmark inbound {To,Cc,Bcc}Full[] list of dicts
260+
"""
261+
if full_list is None:
262+
return None
263+
return ", ".join(cls._address(addr) for addr in full_list)

docs/esps/postmark.rst

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -244,19 +244,28 @@ a `dict` of Postmark `delivery <https://postmarkapp.com/developer/webhooks/deliv
244244
Inbound webhook
245245
---------------
246246

247-
If you want to receive email from Postmark through Anymail's normalized :ref:`inbound <inbound>`
248-
handling, follow Postmark's `Inbound Processing`_ guide to configure
249-
an inbound server pointing to Anymail's inbound webhook.
247+
To receive email from Postmark through Anymail's normalized
248+
:ref:`inbound <inbound>` handling, follow Postmark's guide to
249+
`Configure an inbound server`_ that posts to Anymail's inbound webhook.
250250

251-
The InboundHookUrl setting will be:
251+
In their step 4, set the inbound webhook URL to:
252252

253253
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/postmark/inbound/`
254254

255255
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret
256256
* *yoursite.example.com* is your Django site
257257

258-
Anymail handles the "parse an email" part of Postmark's instructions for you, but you'll
259-
likely want to work through the other sections to set up a custom inbound domain, and
260-
perhaps configure inbound spam blocking.
258+
We recommend enabling the "Include raw email content in JSON payload" checkbox.
259+
Anymail's inbound handling supports either choice, but raw email is preferred
260+
to get the most accurate representation of any received message. (If you are using
261+
Postmark's server API, this is the ``RawEmailEnabled`` option.)
261262

263+
.. versionchanged:: 10.0
264+
Added handling for Postmark's "include raw email content".
265+
266+
You may also want to read through the "Inbound domain forwarding" and
267+
"Configure inbound blocking" sections of Postmark's `Inbound Processing`_ guide.
268+
269+
.. _Configure an inbound server:
270+
https://postmarkapp.com/developer/user-guide/inbound/configure-an-inbound-server
262271
.. _Inbound Processing: https://postmarkapp.com/developer/user-guide/inbound
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"FromName": "Postmarkapp Support",
3+
"MessageStream": "inbound",
4+
"From": "[email protected]",
5+
"FromFull": {
6+
"Email": "[email protected]",
7+
"Name": "Postmarkapp Support",
8+
"MailboxHash": ""
9+
},
10+
"To": "\"Firstname Lastname\" <[email protected]>",
11+
"ToFull": [
12+
{
13+
"Email": "[email protected]",
14+
"Name": "Firstname Lastname",
15+
"MailboxHash": "SampleHash"
16+
}
17+
],
18+
"Cc": "\"First Cc\" <[email protected]>, [email protected]",
19+
"CcFull": [
20+
{
21+
"Email": "[email protected]",
22+
"Name": "First Cc",
23+
"MailboxHash": ""
24+
},
25+
{
26+
"Email": "[email protected]",
27+
"Name": "",
28+
"MailboxHash": ""
29+
}
30+
],
31+
"Bcc": "\"First Bcc\" <[email protected]>",
32+
"BccFull": [
33+
{
34+
"Email": "[email protected]",
35+
"Name": "First Bcc",
36+
"MailboxHash": ""
37+
}
38+
],
39+
"OriginalRecipient": "[email protected]",
40+
"Subject": "Test subject",
41+
"MessageID": "00000000-0000-0000-0000-000000000000",
42+
"ReplyTo": "[email protected]",
43+
"MailboxHash": "SampleHash",
44+
"Date": "Fri, 5 May 2023 17:41:16 -0400",
45+
"TextBody": "This is a test text body.",
46+
"HtmlBody": "<html><body><p>This is a test html body.<\/p><\/body><\/html>",
47+
"StrippedTextReply": "This is the reply text",
48+
"RawEmail": "From: Postmarkapp Support <[email protected]>\r\nTo: Firstname Lastname <[email protected]>\r\nSubject: Test subject\r\n\r\nThis is a test text body.\r\n",
49+
"Tag": "TestTag",
50+
"Headers": [
51+
{
52+
"Name": "X-Header-Test",
53+
"Value": ""
54+
}
55+
],
56+
"Attachments": [
57+
{
58+
"Name": "test.txt",
59+
"ContentType": "text/plain",
60+
"Data": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu",
61+
"ContentLength": 45
62+
}
63+
]
64+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"FromName": "Postmarkapp Support",
3+
"MessageStream": "inbound",
4+
"From": "[email protected]",
5+
"FromFull": {
6+
"Email": "[email protected]",
7+
"Name": "Postmarkapp Support",
8+
"MailboxHash": ""
9+
},
10+
"To": "\"Firstname Lastname\" <[email protected]>",
11+
"ToFull": [
12+
{
13+
"Email": "[email protected]",
14+
"Name": "Firstname Lastname",
15+
"MailboxHash": "SampleHash"
16+
}
17+
],
18+
"Cc": "\"First Cc\" <[email protected]>, [email protected]",
19+
"CcFull": [
20+
{
21+
"Email": "[email protected]",
22+
"Name": "First Cc",
23+
"MailboxHash": ""
24+
},
25+
{
26+
"Email": "[email protected]",
27+
"Name": "",
28+
"MailboxHash": ""
29+
}
30+
],
31+
"Bcc": "\"First Bcc\" <[email protected]>",
32+
"BccFull": [
33+
{
34+
"Email": "[email protected]",
35+
"Name": "First Bcc",
36+
"MailboxHash": ""
37+
}
38+
],
39+
"OriginalRecipient": "[email protected]",
40+
"Subject": "Test subject",
41+
"MessageID": "00000000-0000-0000-0000-000000000000",
42+
"ReplyTo": "[email protected]",
43+
"MailboxHash": "SampleHash",
44+
"Date": "Fri, 5 May 2023 17:44:33 -0400",
45+
"TextBody": "This is a test text body.",
46+
"HtmlBody": "<html><body><p>This is a test html body.<\/p><\/body><\/html>",
47+
"StrippedTextReply": "This is the reply text",
48+
"Tag": "TestTag",
49+
"Headers": [
50+
{
51+
"Name": "X-Header-Test",
52+
"Value": ""
53+
}
54+
],
55+
"Attachments": [
56+
{
57+
"Name": "test.txt",
58+
"ContentType": "text/plain",
59+
"Data": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu",
60+
"ContentLength": 45
61+
}
62+
]
63+
}

0 commit comments

Comments
 (0)