mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-21 12:21:06 -05:00
Add inbound mail handling
Add normalized event, signal, and webhooks for inbound mail. Closes #43 Closes #86
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
Received: by luna.mailgun.net with SMTP mgrt 8734663311733; Fri, 03 May 2013
|
||||
18:26:27 +0000
|
||||
Content-Type: multipart/alternative; boundary="eb663d73ae0a4d6c9153cc0aec8b7520"
|
||||
Content-Type: multipart/alternative;
|
||||
boundary="eb663d73ae0a4d6c9153cc0aec8b7520"
|
||||
Mime-Version: 1.0
|
||||
Subject: Test email
|
||||
From: Someone <someone@example.com>
|
||||
To: someoneelse@example.com
|
||||
Reply-To: reply.to@example.com
|
||||
Message-Id: <20130503182626.18666.16540@example.com>
|
||||
List-Unsubscribe: <mailto:u+na6tmy3ege4tgnldmyytqojqmfsdembyme3tmy3cha4wcndbgaydqyrgoi6wszdpovrhi5dinfzw63tfmv4gs43uomstimdhnvqws3bomnxw2jtuhusteqjgmq6tm@example.com>
|
||||
List-Unsubscribe: <mailto:u+na6tmy3ege4tgnldmyytqojqmfsdembyme3tmy3cha4wcnd
|
||||
bgaydqyrgoi6wszdpovrhi5dinfzw63tfmv4gs43uomstimdhnvqws3bomnxw2jtuhusteqjgm
|
||||
q6tm@example.com>
|
||||
X-Mailgun-Sid: WyIwNzI5MCIsICJhbGljZUBleGFtcGxlLmNvbSIsICI2Il0=
|
||||
X-Mailgun-Variables: {"my_var_1": "Mailgun Variable #1", "my-var-2": "awesome"}
|
||||
Date: Fri, 03 May 2013 18:26:27 +0000
|
||||
|
||||
389
tests/test_inbound.py
Normal file
389
tests/test_inbound.py
Normal file
@@ -0,0 +1,389 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from base64 import b64encode
|
||||
from textwrap import dedent
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from anymail.inbound import AnymailInboundMessage
|
||||
from .utils import SAMPLE_IMAGE_FILENAME, sample_image_content
|
||||
|
||||
SAMPLE_IMAGE_CONTENT = sample_image_content()
|
||||
|
||||
|
||||
class AnymailInboundMessageConstructionTests(SimpleTestCase):
|
||||
def test_construct_params(self):
|
||||
msg = AnymailInboundMessage.construct(
|
||||
from_email="from@example.com", to="to@example.com", cc="cc@example.com",
|
||||
subject="test subject")
|
||||
self.assertEqual(msg['From'], "from@example.com")
|
||||
self.assertEqual(msg['To'], "to@example.com")
|
||||
self.assertEqual(msg['Cc'], "cc@example.com")
|
||||
self.assertEqual(msg['Subject'], "test subject")
|
||||
|
||||
self.assertEqual(msg.defects, []) # ensures email.message.Message.__init__ ran
|
||||
self.assertIsNone(msg.envelope_recipient) # ensures AnymailInboundMessage.__init__ ran
|
||||
|
||||
def test_construct_headers_from_mapping(self):
|
||||
msg = AnymailInboundMessage.construct(
|
||||
headers={'Reply-To': "reply@example.com", 'X-Test': "anything"})
|
||||
self.assertEqual(msg['reply-to'], "reply@example.com") # headers are case-insensitive
|
||||
self.assertEqual(msg['X-TEST'], "anything")
|
||||
|
||||
def test_construct_headers_from_pairs(self):
|
||||
# allows multiple instances of a header
|
||||
msg = AnymailInboundMessage.construct(
|
||||
headers=[['Reply-To', "reply@example.com"],
|
||||
['Received', "by 10.1.1.4 with SMTP id q4csp; Sun, 22 Oct 2017 00:23:22 -0700 (PDT)"],
|
||||
['Received', "from mail.example.com (mail.example.com. [10.10.1.9])"
|
||||
" by mx.example.com with SMTPS id 93s8iok for <to@example.com>;"
|
||||
" Sun, 22 Oct 2017 00:23:21 -0700 (PDT)"],
|
||||
])
|
||||
self.assertEqual(msg['Reply-To'], "reply@example.com")
|
||||
self.assertEqual(msg.get_all('Received'), [
|
||||
"by 10.1.1.4 with SMTP id q4csp; Sun, 22 Oct 2017 00:23:22 -0700 (PDT)",
|
||||
"from mail.example.com (mail.example.com. [10.10.1.9])"
|
||||
" by mx.example.com with SMTPS id 93s8iok for <to@example.com>;"
|
||||
" Sun, 22 Oct 2017 00:23:21 -0700 (PDT)"])
|
||||
|
||||
def test_construct_headers_from_raw(self):
|
||||
# (note header "folding" in second Received header)
|
||||
msg = AnymailInboundMessage.construct(
|
||||
raw_headers=dedent("""\
|
||||
Reply-To: reply@example.com
|
||||
Subject: raw subject
|
||||
Content-Type: x-custom/custom
|
||||
Received: by 10.1.1.4 with SMTP id q4csp; Sun, 22 Oct 2017 00:23:22 -0700 (PDT)
|
||||
Received: from mail.example.com (mail.example.com. [10.10.1.9])
|
||||
by mx.example.com with SMTPS id 93s8iok for <to@example.com>;
|
||||
Sun, 22 Oct 2017 00:23:21 -0700 (PDT)
|
||||
"""),
|
||||
subject="Explicit subject overrides raw")
|
||||
self.assertEqual(msg['Reply-To'], "reply@example.com")
|
||||
self.assertEqual(msg.get_all('Received'), [
|
||||
"by 10.1.1.4 with SMTP id q4csp; Sun, 22 Oct 2017 00:23:22 -0700 (PDT)",
|
||||
"from mail.example.com (mail.example.com. [10.10.1.9])" # unfolding should have stripped newlines
|
||||
" by mx.example.com with SMTPS id 93s8iok for <to@example.com>;"
|
||||
" Sun, 22 Oct 2017 00:23:21 -0700 (PDT)"])
|
||||
self.assertEqual(msg.get_all('Subject'), ["Explicit subject overrides raw"])
|
||||
self.assertEqual(msg.get_all('Content-Type'), ["multipart/mixed"]) # Content-Type in raw header ignored
|
||||
|
||||
def test_construct_bodies(self):
|
||||
# this verifies we construct the expected MIME structure;
|
||||
# see the `text` and `html` props (in the ConveniencePropTests below)
|
||||
# for an easier way to get to these fields (that works however constructed)
|
||||
msg = AnymailInboundMessage.construct(text="Plaintext body", html="HTML body")
|
||||
self.assertEqual(msg['Content-Type'], "multipart/mixed")
|
||||
self.assertEqual(len(msg.get_payload()), 1)
|
||||
|
||||
related = msg.get_payload(0)
|
||||
self.assertEqual(related['Content-Type'], "multipart/related")
|
||||
self.assertEqual(len(related.get_payload()), 1)
|
||||
|
||||
alternative = related.get_payload(0)
|
||||
self.assertEqual(alternative['Content-Type'], "multipart/alternative")
|
||||
self.assertEqual(len(alternative.get_payload()), 2)
|
||||
|
||||
plaintext = alternative.get_payload(0)
|
||||
self.assertEqual(plaintext['Content-Type'], 'text/plain; charset="utf-8"')
|
||||
self.assertEqual(plaintext.get_content_text(), "Plaintext body")
|
||||
|
||||
html = alternative.get_payload(1)
|
||||
self.assertEqual(html['Content-Type'], 'text/html; charset="utf-8"')
|
||||
self.assertEqual(html.get_content_text(), "HTML body")
|
||||
|
||||
def test_construct_attachments(self):
|
||||
att1 = AnymailInboundMessage.construct_attachment(
|
||||
'text/csv', "One,Two\n1,2".encode('iso-8859-1'), charset="iso-8859-1", filename="test.csv")
|
||||
|
||||
att2 = AnymailInboundMessage.construct_attachment(
|
||||
'image/png', SAMPLE_IMAGE_CONTENT, filename=SAMPLE_IMAGE_FILENAME, content_id="abc123")
|
||||
|
||||
msg = AnymailInboundMessage.construct(attachments=[att1, att2])
|
||||
self.assertEqual(msg['Content-Type'], "multipart/mixed")
|
||||
self.assertEqual(len(msg.get_payload()), 2) # bodies (related), att1
|
||||
|
||||
att1_part = msg.get_payload(1)
|
||||
self.assertEqual(att1_part['Content-Type'], 'text/csv; name="test.csv"; charset="iso-8859-1"')
|
||||
self.assertEqual(att1_part['Content-Disposition'], 'attachment; filename="test.csv"')
|
||||
self.assertNotIn('Content-ID', att1_part)
|
||||
self.assertEqual(att1_part.get_content_text(), "One,Two\n1,2")
|
||||
|
||||
related = msg.get_payload(0)
|
||||
self.assertEqual(len(related.get_payload()), 2) # alternatives (with no bodies in this test); att2
|
||||
att2_part = related.get_payload(1)
|
||||
self.assertEqual(att2_part['Content-Type'], 'image/png; name="sample_image.png"')
|
||||
self.assertEqual(att2_part['Content-Disposition'], 'inline; filename="sample_image.png"')
|
||||
self.assertEqual(att2_part['Content-ID'], '<abc123>')
|
||||
self.assertEqual(att2_part.get_content_bytes(), SAMPLE_IMAGE_CONTENT)
|
||||
|
||||
def test_construct_attachments_from_uploaded_files(self):
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
file = SimpleUploadedFile(SAMPLE_IMAGE_FILENAME, SAMPLE_IMAGE_CONTENT, 'image/png')
|
||||
att = AnymailInboundMessage.construct_attachment_from_uploaded_file(file, content_id="abc123")
|
||||
self.assertEqual(att['Content-Type'], 'image/png; name="sample_image.png"')
|
||||
self.assertEqual(att['Content-Disposition'], 'inline; filename="sample_image.png"')
|
||||
self.assertEqual(att['Content-ID'], '<abc123>')
|
||||
self.assertEqual(att.get_content_bytes(), SAMPLE_IMAGE_CONTENT)
|
||||
|
||||
def test_construct_attachments_from_base64_data(self):
|
||||
# This is a fairly common way for ESPs to provide attachment content to webhooks
|
||||
from base64 import b64encode
|
||||
content = b64encode(SAMPLE_IMAGE_CONTENT)
|
||||
att = AnymailInboundMessage.construct_attachment(content_type="image/png", content=content, base64=True)
|
||||
self.assertEqual(att.get_content_bytes(), SAMPLE_IMAGE_CONTENT)
|
||||
|
||||
def test_parse_raw_mime(self):
|
||||
# (we're not trying to exhaustively test email.parser MIME handling here;
|
||||
# just that AnymailInboundMessage.parse_raw_mime calls it correctly)
|
||||
raw = dedent("""\
|
||||
Content-Type: text/plain
|
||||
Subject: This is a test message
|
||||
|
||||
This is a test body.
|
||||
""")
|
||||
msg = AnymailInboundMessage.parse_raw_mime(raw)
|
||||
self.assertEqual(msg['Subject'], "This is a test message")
|
||||
self.assertEqual(msg.get_content_text(), "This is a test body.\n")
|
||||
self.assertEqual(msg.defects, [])
|
||||
|
||||
# (see test_attachment_as_uploaded_file below for parsing basic attachment from raw mime)
|
||||
|
||||
|
||||
class AnymailInboundMessageConveniencePropTests(SimpleTestCase):
|
||||
# AnymailInboundMessage defines several properties to simplify reading
|
||||
# commonly-used items in an email.message.Message
|
||||
|
||||
def test_address_props(self):
|
||||
msg = AnymailInboundMessage.construct(
|
||||
from_email='"Sender, Inc." <sender@example.com>',
|
||||
to='First To <to1@example.com>, to2@example.com',
|
||||
cc='First Cc <cc1@example.com>, cc2@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.display_name, 'Sender, Inc.')
|
||||
self.assertEqual(msg.from_email.username, 'sender')
|
||||
self.assertEqual(msg.from_email.domain, 'example.com')
|
||||
|
||||
self.assertEqual(len(msg.to), 2)
|
||||
self.assertEqual(msg.to[0].addr_spec, 'to1@example.com')
|
||||
self.assertEqual(msg.to[0].display_name, 'First To')
|
||||
self.assertEqual(msg.to[1].addr_spec, 'to2@example.com')
|
||||
self.assertEqual(msg.to[1].display_name, '')
|
||||
|
||||
self.assertEqual(len(msg.cc), 2)
|
||||
self.assertEqual(msg.cc[0].address, 'First Cc <cc1@example.com>')
|
||||
self.assertEqual(msg.cc[1].address, 'cc2@example.com')
|
||||
|
||||
# Default None/empty lists
|
||||
msg = AnymailInboundMessage()
|
||||
self.assertIsNone(msg.from_email)
|
||||
self.assertEqual(msg.to, [])
|
||||
self.assertEqual(msg.cc, [])
|
||||
|
||||
def test_body_props(self):
|
||||
msg = AnymailInboundMessage.construct(text="Test plaintext", html="Test HTML")
|
||||
self.assertEqual(msg.text, "Test plaintext")
|
||||
self.assertEqual(msg.html, "Test HTML")
|
||||
|
||||
# Make sure attachments don't confuse it
|
||||
att_text = AnymailInboundMessage.construct_attachment('text/plain', "text attachment")
|
||||
att_html = AnymailInboundMessage.construct_attachment('text/html', "html attachment")
|
||||
|
||||
msg = AnymailInboundMessage.construct(text="Test plaintext", attachments=[att_text, att_html])
|
||||
self.assertEqual(msg.text, "Test plaintext")
|
||||
self.assertIsNone(msg.html) # no html body (the html attachment doesn't count)
|
||||
|
||||
msg = AnymailInboundMessage.construct(html="Test HTML", attachments=[att_text, att_html])
|
||||
self.assertIsNone(msg.text) # no plaintext body (the text attachment doesn't count)
|
||||
self.assertEqual(msg.html, "Test HTML")
|
||||
|
||||
# Default None
|
||||
msg = AnymailInboundMessage()
|
||||
self.assertIsNone(msg.text)
|
||||
self.assertIsNone(msg.html)
|
||||
|
||||
def test_date_props(self):
|
||||
msg = AnymailInboundMessage.construct(headers={
|
||||
'Date': "Mon, 23 Oct 2017 17:50:55 -0700"
|
||||
})
|
||||
self.assertEqual(msg.date.isoformat(), "2017-10-23T17:50:55-07:00")
|
||||
|
||||
# Default None
|
||||
self.assertIsNone(AnymailInboundMessage().date)
|
||||
|
||||
def test_attachments_prop(self):
|
||||
att = AnymailInboundMessage.construct_attachment(
|
||||
'image/png', SAMPLE_IMAGE_CONTENT, filename=SAMPLE_IMAGE_FILENAME)
|
||||
|
||||
msg = AnymailInboundMessage.construct(attachments=[att])
|
||||
self.assertEqual(msg.attachments, [att])
|
||||
|
||||
# Default empty list
|
||||
self.assertEqual(AnymailInboundMessage().attachments, [])
|
||||
|
||||
def test_inline_attachments_prop(self):
|
||||
att = AnymailInboundMessage.construct_attachment(
|
||||
'image/png', SAMPLE_IMAGE_CONTENT, filename=SAMPLE_IMAGE_FILENAME, content_id="abc123")
|
||||
|
||||
msg = AnymailInboundMessage.construct(attachments=[att])
|
||||
self.assertEqual(msg.inline_attachments, {'abc123': att})
|
||||
|
||||
# Default empty dict
|
||||
self.assertEqual(AnymailInboundMessage().inline_attachments, {})
|
||||
|
||||
def test_attachment_as_uploaded_file(self):
|
||||
raw = dedent("""\
|
||||
MIME-Version: 1.0
|
||||
Subject: Attachment test
|
||||
Content-Type: multipart/mixed; boundary="this_is_a_boundary"
|
||||
|
||||
--this_is_a_boundary
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
|
||||
The test sample image is attached below.
|
||||
|
||||
--this_is_a_boundary
|
||||
Content-Type: image/png; name="sample_image.png"
|
||||
Content-Disposition: attachment; filename="sample_image.png"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz
|
||||
AAALEgAACxIB0t1+/AAAABR0RVh0Q3JlYXRpb24gVGltZQAzLzEvMTNoZNRjAAAAHHRFWHRTb2Z0
|
||||
d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M1cbXjNgAAAZ1JREFUWIXtl7FKA0EQhr+TgIFgo5BXyBUp
|
||||
fIGksLawUNAXWFFfwCJgBAtfIJFMLXgQn8BSwdpCiPcKAdOIoI2x2Dmyd7kYwXhp9odluX/uZv6d
|
||||
nZu7DXowxiKZi0IAUHKCvxcsoAIEpST4IawVGb0Hb0BlpcigefACvAAvwAsoTTGGlwwzBAyivLUP
|
||||
EZrOM10AhGOH2wWugVVlHoAdhJHrPC8DNR0JGsAAQ9mxNzBOMNjS4Qrq69U5EKmf12ywWVsQI4QI
|
||||
IbCn3Gnmnk7uk1bokfooI7QRDlQIGCdzPwiYh0idtXNs2zq3UqwVEiDcu/R0DVjUnFpItuPSscfA
|
||||
FXCGSfEAdZ2fVeQ68OjYWwi3ycVvMhABGwgfKXZScHeZ+4c6VzN8FbuYukvOykCs+z8PJ0xqIXYE
|
||||
d4ALoKlVH2IIgUHWwd/6gNAFPjPcCPvKNTDcYAj1lXzKc7GIRrSZI6yJzcQ+dtV9bD+IkHThBj34
|
||||
4j9/yYxupaQbXPJLNqsGFgeZ6qwpLP1b4AV4AV5AoKfjpR5OwR6VKwULCAC+AQV4W9Ps4uZQAAAA
|
||||
AElFTkSuQmCC
|
||||
--this_is_a_boundary--
|
||||
""")
|
||||
|
||||
msg = AnymailInboundMessage.parse_raw_mime(raw)
|
||||
attachment = msg.attachments[0]
|
||||
attachment_file = attachment.as_uploaded_file()
|
||||
|
||||
self.assertEqual(attachment_file.name, "sample_image.png")
|
||||
self.assertEqual(attachment_file.content_type, "image/png")
|
||||
self.assertEqual(attachment_file.read(), SAMPLE_IMAGE_CONTENT)
|
||||
|
||||
def test_attachment_as_uploaded_file_security(self):
|
||||
# Raw attachment filenames can be malicious; we want to make sure that
|
||||
# our Django file converter sanitizes them (as much as any uploaded filename)
|
||||
raw = dedent("""\
|
||||
MIME-Version: 1.0
|
||||
Subject: Attachment test
|
||||
Content-Type: multipart/mixed; boundary="this_is_a_boundary"
|
||||
|
||||
--this_is_a_boundary
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
|
||||
The malicious attachment filenames below need to get sanitized
|
||||
|
||||
--this_is_a_boundary
|
||||
Content-Type: text/plain; name="report.txt"
|
||||
Content-Disposition: attachment; filename="/etc/passwd"
|
||||
|
||||
# (not that overwriting /etc/passwd is actually a thing
|
||||
# anymore, but you get the point)
|
||||
--this_is_a_boundary
|
||||
Content-Type: text/html
|
||||
Content-Disposition: attachment; filename="../static/index.html"
|
||||
|
||||
<body>Hey, did I overwrite your site?</body>
|
||||
--this_is_a_boundary--
|
||||
""")
|
||||
msg = AnymailInboundMessage.parse_raw_mime(raw)
|
||||
attachments = msg.attachments
|
||||
|
||||
self.assertEqual(attachments[0].get_filename(), "/etc/passwd") # you wouldn't want to actually write here
|
||||
self.assertEqual(attachments[0].as_uploaded_file().name, "passwd") # path removed - good!
|
||||
|
||||
self.assertEqual(attachments[1].get_filename(), "../static/index.html")
|
||||
self.assertEqual(attachments[1].as_uploaded_file().name, "index.html") # ditto for relative paths
|
||||
|
||||
|
||||
class AnymailInboundMessageAttachedMessageTests(SimpleTestCase):
|
||||
# message/rfc822 attachments should get parsed recursively
|
||||
|
||||
original_raw_message = dedent("""\
|
||||
MIME-Version: 1.0
|
||||
From: sender@example.com
|
||||
Subject: Original message
|
||||
Return-Path: bounces@inbound.example.com
|
||||
Content-Type: multipart/related; boundary="boundary-orig"
|
||||
|
||||
--boundary-orig
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
|
||||
<img src="cid:abc123"> Here is your message!
|
||||
|
||||
--boundary-orig
|
||||
Content-Type: image/png; name="sample_image.png"
|
||||
Content-Disposition: inline
|
||||
Content-ID: <abc123>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
{image_content_base64}
|
||||
--boundary-orig--
|
||||
""").format(image_content_base64=b64encode(SAMPLE_IMAGE_CONTENT).decode('ascii'))
|
||||
|
||||
def test_parse_rfc822_attachment_from_raw_mime(self):
|
||||
# message/rfc822 attachments should be parsed recursively
|
||||
raw = dedent("""\
|
||||
MIME-Version: 1.0
|
||||
From: mailer-demon@example.org
|
||||
Subject: Undeliverable
|
||||
To: bounces@inbound.example.com
|
||||
Content-Type: multipart/mixed; boundary="boundary-bounce"
|
||||
|
||||
--boundary-bounce
|
||||
Content-Type: text/plain
|
||||
|
||||
Your message was undeliverable due to carrier pigeon strike.
|
||||
The original message is attached.
|
||||
|
||||
--boundary-bounce
|
||||
Content-Type: message/rfc822
|
||||
Content-Disposition: attachment
|
||||
|
||||
{original_raw_message}
|
||||
--boundary-bounce--
|
||||
""").format(original_raw_message=self.original_raw_message)
|
||||
|
||||
msg = AnymailInboundMessage.parse_raw_mime(raw)
|
||||
self.assertIsInstance(msg, AnymailInboundMessage)
|
||||
|
||||
att = msg.get_payload(1)
|
||||
self.assertIsInstance(att, AnymailInboundMessage)
|
||||
self.assertEqual(att.get_content_type(), "message/rfc822")
|
||||
self.assertTrue(att.is_attachment())
|
||||
|
||||
orig_msg = att.get_payload(0)
|
||||
self.assertIsInstance(orig_msg, AnymailInboundMessage)
|
||||
self.assertEqual(orig_msg['Subject'], "Original message")
|
||||
self.assertEqual(orig_msg.get_content_type(), "multipart/related")
|
||||
self.assertEqual(att.get_content_text(), self.original_raw_message)
|
||||
|
||||
orig_inline_att = orig_msg.get_payload(1)
|
||||
self.assertEqual(orig_inline_att.get_content_type(), "image/png")
|
||||
self.assertTrue(orig_inline_att.is_inline_attachment())
|
||||
self.assertEqual(orig_inline_att.get_payload(decode=True), SAMPLE_IMAGE_CONTENT)
|
||||
|
||||
def test_construct_rfc822_attachment_from_data(self):
|
||||
# constructed message/rfc822 attachment should end up as parsed message
|
||||
# (same as if attachment was parsed from raw mime, as in previous test)
|
||||
att = AnymailInboundMessage.construct_attachment('message/rfc822', self.original_raw_message)
|
||||
self.assertIsInstance(att, AnymailInboundMessage)
|
||||
self.assertEqual(att.get_content_type(), "message/rfc822")
|
||||
self.assertTrue(att.is_attachment())
|
||||
self.assertEqual(att.get_content_text(), self.original_raw_message)
|
||||
|
||||
orig_msg = att.get_payload(0)
|
||||
self.assertIsInstance(orig_msg, AnymailInboundMessage)
|
||||
self.assertEqual(orig_msg['Subject'], "Original message")
|
||||
self.assertEqual(orig_msg.get_content_type(), "multipart/related")
|
||||
@@ -159,7 +159,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
|
||||
# Email messages can get a bit changed with respect to whitespace characters
|
||||
# in headers, without breaking the message, so we tolerate that:
|
||||
self.assertEqual(attachments[3][0], None)
|
||||
self.assertEqualIgnoringWhitespace(
|
||||
self.assertEqualIgnoringHeaderFolding(
|
||||
attachments[3][1],
|
||||
b'Content-Type: message/rfc822\nMIME-Version: 1.0\n\n' + forwarded_email_content)
|
||||
self.assertEqual(attachments[3][2], 'message/rfc822')
|
||||
|
||||
176
tests/test_mailgun_inbound.py
Normal file
176
tests/test_mailgun_inbound.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from textwrap import dedent
|
||||
|
||||
import six
|
||||
from django.test import override_settings
|
||||
from django.utils.timezone import utc
|
||||
from mock import ANY
|
||||
|
||||
from anymail.inbound import AnymailInboundMessage
|
||||
from anymail.signals import AnymailInboundEvent
|
||||
from anymail.webhooks.mailgun import MailgunInboundWebhookView
|
||||
|
||||
from .test_mailgun_webhooks import TEST_API_KEY, mailgun_sign, querydict_to_postdict
|
||||
from .utils import sample_image_content, sample_email_content
|
||||
from .webhook_cases import WebhookTestCase
|
||||
|
||||
|
||||
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
|
||||
class MailgunInboundTestCase(WebhookTestCase):
|
||||
def test_inbound_basics(self):
|
||||
raw_event = mailgun_sign({
|
||||
'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0',
|
||||
'timestamp': '1461261330',
|
||||
'recipient': 'test@inbound.example.com',
|
||||
'sender': 'envelope-from@example.org',
|
||||
'message-headers': json.dumps([
|
||||
["X-Mailgun-Spam-Rules", "DKIM_SIGNED, DKIM_VALID, DKIM_VALID_AU, ..."],
|
||||
["X-Mailgun-Dkim-Check-Result", "Pass"],
|
||||
["X-Mailgun-Spf", "Pass"],
|
||||
["X-Mailgun-Sscore", "1.7"],
|
||||
["X-Mailgun-Sflag", "No"],
|
||||
["X-Mailgun-Incoming", "Yes"],
|
||||
["X-Envelope-From", "<envelope-from@example.org>"],
|
||||
["Received", "from mail.example.org by mxa.mailgun.org ..."],
|
||||
["Received", "by mail.example.org for <test@inbound.example.com> ..."],
|
||||
["Dkim-Signature", "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.org; ..."],
|
||||
["Mime-Version", "1.0"],
|
||||
["Received", "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)"],
|
||||
["From", "\"Displayed From\" <from+test@example.org>"],
|
||||
["Date", "Wed, 11 Oct 2017 18:31:04 -0700"],
|
||||
["Message-Id", "<CAEPk3R+4Zr@mail.example.org>"],
|
||||
["Subject", "Test subject"],
|
||||
["To", "\"Test Inbound\" <test@inbound.example.com>, other@example.com"],
|
||||
["Cc", "cc@example.com"],
|
||||
["Content-Type", "multipart/mixed; boundary=\"089e0825ccf874a0bb055b4f7e23\""],
|
||||
]),
|
||||
'body-plain': 'Test body plain',
|
||||
'body-html': '<div>Test body html</div>',
|
||||
'stripped-html': 'stripped html body',
|
||||
'stripped-text': 'stripped plaintext body',
|
||||
})
|
||||
response = self.client.post('/anymail/mailgun/inbound/', data=raw_event)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MailgunInboundWebhookView,
|
||||
event=ANY, esp_name='Mailgun')
|
||||
# AnymailInboundEvent
|
||||
event = kwargs['event']
|
||||
self.assertIsInstance(event, AnymailInboundEvent)
|
||||
self.assertEqual(event.event_type, 'inbound')
|
||||
self.assertEqual(event.timestamp, datetime(2016, 4, 21, 17, 55, 30, tzinfo=utc))
|
||||
self.assertEqual(event.event_id, "06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0")
|
||||
self.assertIsInstance(event.message, AnymailInboundMessage)
|
||||
self.assertEqual(querydict_to_postdict(event.esp_event.POST), 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([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(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>')
|
||||
|
||||
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')
|
||||
self.assertEqual(message.stripped_html, 'stripped html body')
|
||||
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.get_all('Received'), [
|
||||
"from mail.example.org by mxa.mailgun.org ...",
|
||||
"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)",
|
||||
])
|
||||
|
||||
def test_attachments(self):
|
||||
att1 = six.BytesIO('test attachment'.encode('utf-8'))
|
||||
att1.name = 'test.txt'
|
||||
image_content = sample_image_content()
|
||||
att2 = six.BytesIO(image_content)
|
||||
att2.name = 'image.png'
|
||||
email_content = sample_email_content()
|
||||
att3 = six.BytesIO(email_content)
|
||||
att3.content_type = 'message/rfc822; charset="us-ascii"'
|
||||
raw_event = mailgun_sign({
|
||||
'message-headers': '[]',
|
||||
'attachment-count': '3',
|
||||
'content-id-map': """{"<abc123>": "attachment-2"}""",
|
||||
'attachment-1': att1,
|
||||
'attachment-2': att2, # inline
|
||||
'attachment-3': att3,
|
||||
})
|
||||
|
||||
response = self.client.post('/anymail/mailgun/inbound/', data=raw_event)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MailgunInboundWebhookView,
|
||||
event=ANY, esp_name='Mailgun')
|
||||
event = kwargs['event']
|
||||
message = event.message
|
||||
attachments = message.attachments # AnymailInboundMessage convenience accessor
|
||||
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(), u'test attachment')
|
||||
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
|
||||
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
|
||||
|
||||
inlines = message.inline_attachments
|
||||
self.assertEqual(len(inlines), 1)
|
||||
inline = inlines['abc123']
|
||||
self.assertEqual(inline.get_filename(), 'image.png')
|
||||
self.assertEqual(inline.get_content_type(), 'image/png')
|
||||
self.assertEqual(inline.get_content_bytes(), image_content)
|
||||
|
||||
def test_inbound_mime(self):
|
||||
# Mailgun provides the full, raw MIME message if the webhook url ends in 'mime'
|
||||
raw_event = mailgun_sign({
|
||||
'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0',
|
||||
'timestamp': '1461261330',
|
||||
'recipient': 'test@inbound.example.com',
|
||||
'sender': 'envelope-from@example.org',
|
||||
'body-mime': dedent("""\
|
||||
From: A tester <test@example.org>
|
||||
Date: Thu, 12 Oct 2017 18:03:30 -0700
|
||||
Message-ID: <CAEPk3RKEx@mail.example.org>
|
||||
Subject: Raw MIME test
|
||||
To: test@inbound.example.com
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="94eb2c05e174adb140055b6339c5"
|
||||
|
||||
--94eb2c05e174adb140055b6339c5
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
It's a body=E2=80=A6
|
||||
|
||||
--94eb2c05e174adb140055b6339c5
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr">It's a body=E2=80=A6</div>
|
||||
|
||||
--94eb2c05e174adb140055b6339c5--
|
||||
"""),
|
||||
})
|
||||
|
||||
response = self.client.post('/anymail/mailgun/inbound_mime/', data=raw_event)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MailgunInboundWebhookView,
|
||||
event=ANY, esp_name='Mailgun')
|
||||
event = kwargs['event']
|
||||
message = event.message
|
||||
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
|
||||
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
|
||||
self.assertEqual(message.subject, 'Raw MIME test')
|
||||
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
170
tests/test_mailjet_inbound.py
Normal file
170
tests/test_mailjet_inbound.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import json
|
||||
from base64 import b64encode
|
||||
|
||||
from mock import ANY
|
||||
|
||||
from anymail.inbound import AnymailInboundMessage
|
||||
from anymail.signals import AnymailInboundEvent
|
||||
from anymail.webhooks.mailjet import MailjetInboundWebhookView
|
||||
|
||||
from .utils import sample_image_content, sample_email_content
|
||||
from .webhook_cases import WebhookTestCase
|
||||
|
||||
|
||||
class MailjetInboundTestCase(WebhookTestCase):
|
||||
def test_inbound_basics(self):
|
||||
raw_event = {
|
||||
"Sender": "envelope-from@example.org",
|
||||
"Recipient": "test@inbound.example.com",
|
||||
"Date": "20171012T013104", # this is just the Date header from the sender, parsed to UTC
|
||||
"From": '"Displayed From" <from+test@example.org>',
|
||||
"Subject": "Test subject",
|
||||
"Headers": {
|
||||
"Return-Path": ["<bounce-handler=from+test%example.org@mail.example.org>"],
|
||||
"Received": [
|
||||
"from mail.example.org by parse.mailjet.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)",
|
||||
],
|
||||
"MIME-Version": ["1.0"],
|
||||
"From": '"Displayed From" <from+test@example.org>',
|
||||
"Date": "Wed, 11 Oct 2017 18:31:04 -0700",
|
||||
"Message-ID": "<CAEPk3R+4Zr@mail.example.org>",
|
||||
"Subject": "Test subject",
|
||||
"To": "Test Inbound <test@inbound.example.com>, other@example.com",
|
||||
"Cc": "cc@example.com",
|
||||
"Reply-To": "from+test@milter.example.org",
|
||||
"Content-Type": ["multipart/alternative; boundary=\"boundary0\""],
|
||||
},
|
||||
"Parts": [{
|
||||
"Headers": {
|
||||
"Content-Type": ['text/plain; charset="UTF-8"']
|
||||
},
|
||||
"ContentRef": "Text-part"
|
||||
}, {
|
||||
"Headers": {
|
||||
"Content-Type": ['text/html; charset="UTF-8"'],
|
||||
"Content-Transfer-Encoding": ["quoted-printable"]
|
||||
},
|
||||
"ContentRef": "Html-part"
|
||||
}],
|
||||
"Text-part": "Test body plain",
|
||||
"Html-part": "<div>Test body html</div>",
|
||||
"SpamAssassinScore": "1.7"
|
||||
}
|
||||
|
||||
response = self.client.post('/anymail/mailjet/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=MailjetInboundWebhookView,
|
||||
event=ANY, esp_name='Mailjet')
|
||||
# AnymailInboundEvent
|
||||
event = kwargs['event']
|
||||
self.assertIsInstance(event, AnymailInboundEvent)
|
||||
self.assertEqual(event.event_type, 'inbound')
|
||||
self.assertIsNone(event.timestamp) # Mailjet doesn't provide inbound event timestamp
|
||||
self.assertIsNone(event.event_id) # Mailjet doesn't provide inbound event id
|
||||
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([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(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>')
|
||||
|
||||
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
|
||||
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
|
||||
self.assertIsNone(message.stripped_text) # Mailjet doesn't provide stripped plaintext body
|
||||
self.assertIsNone(message.stripped_html) # Mailjet doesn't provide stripped html
|
||||
self.assertIsNone(message.spam_detected) # Mailjet doesn't provide spam boolean
|
||||
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(message.get_all('Received'), [
|
||||
"from mail.example.org by parse.mailjet.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)",
|
||||
])
|
||||
|
||||
def test_attachments(self):
|
||||
image_content = sample_image_content()
|
||||
email_content = sample_email_content()
|
||||
raw_event = {
|
||||
"Headers": {
|
||||
"MIME-Version": ["1.0"],
|
||||
"Content-Type": ["multipart/mixed; boundary=\"boundary0\""],
|
||||
},
|
||||
"Parts": [{
|
||||
"Headers": {"Content-Type": ['multipart/related; boundary="boundary1"']}
|
||||
}, {
|
||||
"Headers": {"Content-Type": ['multipart/alternative; boundary="boundary2"']}
|
||||
}, {
|
||||
"Headers": {"Content-Type": ['text/plain; charset="UTF-8"']},
|
||||
"ContentRef": "Text-part"
|
||||
}, {
|
||||
"Headers": {
|
||||
"Content-Type": ['text/html; charset="UTF-8"'],
|
||||
"Content-Transfer-Encoding": ["quoted-printable"]
|
||||
},
|
||||
"ContentRef": "Html-part"
|
||||
}, {
|
||||
"Headers": {
|
||||
"Content-Type": ['text/plain'],
|
||||
"Content-Disposition": ['attachment; filename="test.txt"'],
|
||||
"Content-Transfer-Encoding": ["quoted-printable"],
|
||||
},
|
||||
"ContentRef": "Attachment1"
|
||||
}, {
|
||||
"Headers": {
|
||||
"Content-Type": ['image/png; name="image.png"'],
|
||||
"Content-Disposition": ['inline; filename="image.png"'],
|
||||
"Content-Transfer-Encoding": ["base64"],
|
||||
"Content-ID": ["<abc123>"],
|
||||
},
|
||||
"ContentRef": "InlineAttachment1"
|
||||
}, {
|
||||
"Headers": {
|
||||
"Content-Type": ['message/rfc822; charset="US-ASCII"'],
|
||||
"Content-Disposition": ['attachment'],
|
||||
},
|
||||
"ContentRef": "Attachment2"
|
||||
}],
|
||||
"Text-part": "Test body plain",
|
||||
"Html-part": "<div>Test body html <img src='cid:abc123'></div>",
|
||||
"InlineAttachment1": b64encode(image_content).decode('ascii'),
|
||||
"Attachment1": b64encode('test attachment'.encode('utf-8')).decode('ascii'),
|
||||
"Attachment2": b64encode(email_content).decode('ascii'),
|
||||
}
|
||||
|
||||
response = self.client.post('/anymail/mailjet/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=MailjetInboundWebhookView,
|
||||
event=ANY, esp_name='Mailjet')
|
||||
event = kwargs['event']
|
||||
message = event.message
|
||||
attachments = message.attachments # AnymailInboundMessage convenience accessor
|
||||
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(), u'test attachment')
|
||||
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
|
||||
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
|
||||
|
||||
inlines = message.inline_attachments
|
||||
self.assertEqual(len(inlines), 1)
|
||||
inline = inlines['abc123']
|
||||
self.assertEqual(inline.get_filename(), 'image.png')
|
||||
self.assertEqual(inline.get_content_type(), 'image/png')
|
||||
self.assertEqual(inline.get_content_bytes(), image_content)
|
||||
88
tests/test_mandrill_inbound.py
Normal file
88
tests/test_mandrill_inbound.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from textwrap import dedent
|
||||
|
||||
from django.test import override_settings
|
||||
from mock import ANY
|
||||
|
||||
from anymail.inbound import AnymailInboundMessage
|
||||
from anymail.signals import AnymailInboundEvent
|
||||
from anymail.webhooks.mandrill import MandrillCombinedWebhookView
|
||||
|
||||
from .test_mandrill_webhooks import TEST_WEBHOOK_KEY, mandrill_args
|
||||
from .webhook_cases import WebhookTestCase
|
||||
|
||||
|
||||
@override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY)
|
||||
class MandrillInboundTestCase(WebhookTestCase):
|
||||
def test_inbound_basics(self):
|
||||
raw_event = {
|
||||
"event": "inbound",
|
||||
"ts": 1507856722,
|
||||
"msg": {
|
||||
"raw_msg": dedent("""\
|
||||
From: A tester <test@example.org>
|
||||
Date: Thu, 12 Oct 2017 18:03:30 -0700
|
||||
Message-ID: <CAEPk3RKEx@mail.example.org>
|
||||
Subject: Test subject
|
||||
To: "Test, Inbound" <test@inbound.example.com>, other@example.com
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="94eb2c05e174adb140055b6339c5"
|
||||
|
||||
--94eb2c05e174adb140055b6339c5
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
It's a body=E2=80=A6
|
||||
|
||||
--94eb2c05e174adb140055b6339c5
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr">It's a body=E2=80=A6</div>
|
||||
|
||||
--94eb2c05e174adb140055b6339c5--
|
||||
"""),
|
||||
"email": "delivered-to@example.com",
|
||||
"sender": None, # Mandrill populates "sender" only for outbound message events
|
||||
"spam_report": {
|
||||
"score": 1.7,
|
||||
},
|
||||
# Anymail ignores Mandrill's other inbound event fields
|
||||
# (which are all redundant with raw_msg)
|
||||
},
|
||||
}
|
||||
|
||||
response = self.client.post(**mandrill_args(events=[raw_event], path='/anymail/mandrill/'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MandrillCombinedWebhookView,
|
||||
event=ANY, esp_name='Mandrill')
|
||||
self.assertEqual(self.tracking_handler.call_count, 0) # Inbound should not dispatch tracking signal
|
||||
|
||||
event = kwargs['event']
|
||||
self.assertIsInstance(event, AnymailInboundEvent)
|
||||
self.assertEqual(event.event_type, "inbound")
|
||||
self.assertEqual(event.timestamp.isoformat(), "2017-10-13T01:05:22+00:00")
|
||||
self.assertIsNone(event.event_id) # Mandrill doesn't provide inbound event id
|
||||
self.assertIsInstance(event.message, AnymailInboundMessage)
|
||||
self.assertEqual(event.esp_event, raw_event)
|
||||
|
||||
message = event.message
|
||||
self.assertEqual(message.from_email.display_name, 'A tester')
|
||||
self.assertEqual(message.from_email.addr_spec, 'test@example.org')
|
||||
self.assertEqual(len(message.to), 2)
|
||||
self.assertEqual(message.to[0].display_name, 'Test, Inbound')
|
||||
self.assertEqual(message.to[0].addr_spec, 'test@inbound.example.com')
|
||||
self.assertEqual(message.to[1].addr_spec, 'other@example.com')
|
||||
self.assertEqual(message.subject, 'Test subject')
|
||||
self.assertEqual(message.date.isoformat(" "), "2017-10-12 18:03:30-07:00")
|
||||
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
|
||||
self.assertIsNone(message.envelope_sender) # Mandrill doesn't provide sender
|
||||
self.assertEqual(message.envelope_recipient, 'delivered-to@example.com')
|
||||
self.assertIsNone(message.stripped_text) # Mandrill doesn't provide stripped plaintext body
|
||||
self.assertIsNone(message.stripped_html) # Mandrill doesn't provide stripped html
|
||||
self.assertIsNone(message.spam_detected) # Mandrill doesn't provide spam boolean
|
||||
self.assertEqual(message.spam_score, 1.7)
|
||||
|
||||
# Anymail will also parse attachments (if any) from the raw mime.
|
||||
# We don't bother testing that here; see test_inbound for examples.
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
# noinspection PyUnresolvedReferences
|
||||
from six.moves.urllib.parse import urljoin
|
||||
|
||||
import hashlib
|
||||
@@ -12,7 +11,7 @@ from django.utils.timezone import utc
|
||||
from mock import ANY
|
||||
|
||||
from anymail.signals import AnymailTrackingEvent
|
||||
from anymail.webhooks.mandrill import MandrillTrackingWebhookView
|
||||
from anymail.webhooks.mandrill import MandrillCombinedWebhookView, MandrillTrackingWebhookView
|
||||
|
||||
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
|
||||
|
||||
@@ -21,7 +20,7 @@ TEST_WEBHOOK_KEY = 'TEST_WEBHOOK_KEY'
|
||||
|
||||
def mandrill_args(events=None,
|
||||
host="http://testserver/", # Django test-client default
|
||||
path='/anymail/mandrill/tracking/', # Anymail urlconf default
|
||||
path='/anymail/mandrill/', # Anymail urlconf default
|
||||
auth="username:password", # WebhookTestCase default
|
||||
key=TEST_WEBHOOK_KEY):
|
||||
"""Returns TestClient.post kwargs for Mandrill webhook call with events
|
||||
@@ -30,7 +29,7 @@ def mandrill_args(events=None,
|
||||
"""
|
||||
if events is None:
|
||||
events = []
|
||||
test_client_path = urljoin(host, path) # https://testserver/anymail/mandrill/tracking/
|
||||
test_client_path = urljoin(host, path) # https://testserver/anymail/mandrill/
|
||||
if auth:
|
||||
# we can get away with this simplification in these controlled tests,
|
||||
# but don't ever construct urls like this in production code -- it's not safe!
|
||||
@@ -52,14 +51,14 @@ def mandrill_args(events=None,
|
||||
class MandrillWebhookSettingsTestCase(WebhookTestCase):
|
||||
def test_requires_webhook_key(self):
|
||||
with self.assertRaisesRegex(ImproperlyConfigured, r'MANDRILL_WEBHOOK_KEY'):
|
||||
self.client.post('/anymail/mandrill/tracking/',
|
||||
self.client.post('/anymail/mandrill/',
|
||||
data={'mandrill_events': '[]'})
|
||||
|
||||
def test_head_does_not_require_webhook_key(self):
|
||||
# Mandrill issues an unsigned HEAD request to verify the wehbook url.
|
||||
# Only *after* that succeeds will Mandrill will tell you the webhook key.
|
||||
# So make sure that HEAD request will go through without any key set:
|
||||
response = self.client.head('/anymail/mandrill/tracking/')
|
||||
response = self.client.head('/anymail/mandrill/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
@@ -79,7 +78,7 @@ class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixi
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_verifies_missing_signature(self):
|
||||
response = self.client.post('/anymail/mandrill/tracking/',
|
||||
response = self.client.post('/anymail/mandrill/',
|
||||
data={'mandrill_events': '[{"event":"send"}]'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@@ -99,7 +98,7 @@ class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixi
|
||||
@override_settings(
|
||||
ALLOWED_HOSTS=['127.0.0.1', '.example.com'],
|
||||
ANYMAIL={
|
||||
"MANDRILL_WEBHOOK_URL": "https://abcde:12345@example.com/anymail/mandrill/tracking/",
|
||||
"MANDRILL_WEBHOOK_URL": "https://abcde:12345@example.com/anymail/mandrill/",
|
||||
"WEBHOOK_AUTHORIZATION": "abcde:12345",
|
||||
})
|
||||
def test_webhook_url_setting(self):
|
||||
@@ -133,6 +132,7 @@ class MandrillTrackingTestCase(WebhookTestCase):
|
||||
|
||||
def test_head_request(self):
|
||||
# Mandrill verifies webhooks at config time with a HEAD request
|
||||
# (See MandrillWebhookSettingsTestCase above for equivalent without the key yet set)
|
||||
response = self.client.head('/anymail/mandrill/tracking/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@@ -159,7 +159,7 @@ class MandrillTrackingTestCase(WebhookTestCase):
|
||||
}]
|
||||
response = self.client.post(**mandrill_args(events=raw_events))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
|
||||
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillCombinedWebhookView,
|
||||
event=ANY, esp_name='Mandrill')
|
||||
event = kwargs['event']
|
||||
self.assertIsInstance(event, AnymailTrackingEvent)
|
||||
@@ -189,7 +189,7 @@ class MandrillTrackingTestCase(WebhookTestCase):
|
||||
}]
|
||||
response = self.client.post(**mandrill_args(events=raw_events))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
|
||||
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillCombinedWebhookView,
|
||||
event=ANY, esp_name='Mandrill')
|
||||
event = kwargs['event']
|
||||
self.assertIsInstance(event, AnymailTrackingEvent)
|
||||
@@ -219,7 +219,7 @@ class MandrillTrackingTestCase(WebhookTestCase):
|
||||
}]
|
||||
response = self.client.post(**mandrill_args(events=raw_events))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
|
||||
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillCombinedWebhookView,
|
||||
event=ANY, esp_name='Mandrill')
|
||||
event = kwargs['event']
|
||||
self.assertIsInstance(event, AnymailTrackingEvent)
|
||||
@@ -241,9 +241,31 @@ class MandrillTrackingTestCase(WebhookTestCase):
|
||||
}]
|
||||
response = self.client.post(**mandrill_args(events=raw_events))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
|
||||
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillCombinedWebhookView,
|
||||
event=ANY, esp_name='Mandrill')
|
||||
event = kwargs['event']
|
||||
self.assertEqual(event.event_type, "unknown")
|
||||
self.assertEqual(event.recipient, "recipient@example.com")
|
||||
self.assertEqual(event.description, "manual edit")
|
||||
|
||||
def test_old_tracking_url(self):
|
||||
# Earlier versions of Anymail used /mandrill/tracking/ (and didn't support inbound);
|
||||
# make sure that URL continues to work.
|
||||
raw_events = [{
|
||||
"event": "send",
|
||||
"msg": {
|
||||
"ts": 1461095211, # time send called
|
||||
"subject": "Webhook Test",
|
||||
"email": "recipient@example.com",
|
||||
"sender": "sender@example.com",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"metadata": {"custom1": "value1", "custom2": "value2"},
|
||||
"_id": "abcdef012345789abcdef012345789"
|
||||
},
|
||||
"_id": "abcdef012345789abcdef012345789",
|
||||
"ts": 1461095246 # time of event
|
||||
}]
|
||||
response = self.client.post(**mandrill_args(events=raw_events, path='/anymail/mandrill/tracking/'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
|
||||
event=ANY, esp_name='Mandrill')
|
||||
|
||||
224
tests/test_postmark_inbound.py
Normal file
224
tests/test_postmark_inbound.py
Normal file
@@ -0,0 +1,224 @@
|
||||
import json
|
||||
from base64 import b64encode
|
||||
|
||||
from mock import ANY
|
||||
|
||||
from anymail.inbound import AnymailInboundMessage
|
||||
from anymail.signals import AnymailInboundEvent
|
||||
from anymail.webhooks.postmark import PostmarkInboundWebhookView
|
||||
|
||||
from .utils import sample_image_content, sample_email_content
|
||||
from .webhook_cases import WebhookTestCase
|
||||
|
||||
|
||||
class PostmarkInboundTestCase(WebhookTestCase):
|
||||
def test_inbound_basics(self):
|
||||
raw_event = {
|
||||
"FromFull": {
|
||||
"Email": "from+test@example.org",
|
||||
"Name": "Displayed From",
|
||||
"MailboxHash": "test"
|
||||
},
|
||||
"ToFull": [{
|
||||
"Email": "test@inbound.example.com",
|
||||
"Name": "Test Inbound",
|
||||
"MailboxHash": ""
|
||||
}, {
|
||||
"Email": "other@example.com",
|
||||
"Name": "",
|
||||
"MailboxHash": ""
|
||||
}],
|
||||
"CcFull": [{
|
||||
"Email": "cc@example.com",
|
||||
"Name": "",
|
||||
"MailboxHash": ""
|
||||
}],
|
||||
"BccFull": [{
|
||||
"Email": "bcc@example.com",
|
||||
"Name": "Postmark documents blind cc on inbound email (?)",
|
||||
"MailboxHash": ""
|
||||
}],
|
||||
"OriginalRecipient": "test@inbound.example.com",
|
||||
"ReplyTo": "from+test@milter.example.org",
|
||||
"Subject": "Test subject",
|
||||
"MessageID": "22c74902-a0c1-4511-804f2-341342852c90",
|
||||
"Date": "Wed, 11 Oct 2017 18:31:04 -0700",
|
||||
"TextBody": "Test body plain",
|
||||
"HtmlBody": "<div>Test body html</div>",
|
||||
"StrippedTextReply": "stripped plaintext body",
|
||||
"Tag": "",
|
||||
"Headers": [{
|
||||
"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"
|
||||
}, {
|
||||
"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)"
|
||||
}, {
|
||||
"Name": "MIME-Version",
|
||||
"Value": "1.0"
|
||||
}, {
|
||||
"Name": "Message-ID",
|
||||
"Value": "<CAEPk3R+4Zr@mail.example.org>"
|
||||
}],
|
||||
}
|
||||
|
||||
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')
|
||||
self.assertIsNone(event.timestamp) # Postmark doesn't provide inbound 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([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(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>')
|
||||
|
||||
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')
|
||||
self.assertIsNone(message.stripped_html) # Postmark doesn't provide 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(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)",
|
||||
])
|
||||
|
||||
def test_attachments(self):
|
||||
image_content = sample_image_content()
|
||||
email_content = sample_email_content()
|
||||
raw_event = {
|
||||
"Attachments": [{
|
||||
"Name": "test.txt",
|
||||
"Content": b64encode('test attachment'.encode('utf-8')).decode('ascii'),
|
||||
"ContentType": "text/plain",
|
||||
"ContentLength": len('test attachment')
|
||||
}, {
|
||||
"Name": "image.png",
|
||||
"Content": b64encode(image_content).decode('ascii'),
|
||||
"ContentType": "image/png",
|
||||
"ContentID": "abc123",
|
||||
"ContentLength": len(image_content)
|
||||
}, {
|
||||
"Name": "bounce.txt",
|
||||
"Content": b64encode(email_content).decode('ascii'),
|
||||
"ContentType": 'message/rfc822; charset="us-ascii"',
|
||||
"ContentLength": len(email_content)
|
||||
}]
|
||||
}
|
||||
|
||||
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')
|
||||
event = kwargs['event']
|
||||
message = event.message
|
||||
attachments = message.attachments # AnymailInboundMessage convenience accessor
|
||||
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(), u'test attachment')
|
||||
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
|
||||
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
|
||||
|
||||
inlines = message.inline_attachments
|
||||
self.assertEqual(len(inlines), 1)
|
||||
inline = inlines['abc123']
|
||||
self.assertEqual(inline.get_filename(), 'image.png')
|
||||
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
|
||||
raw_event = {
|
||||
"Headers": [{
|
||||
"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"
|
||||
}],
|
||||
}
|
||||
response = self.client.post('/anymail/postmark/inbound/', content_type='application/json',
|
||||
data=json.dumps(raw_event))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.get_kwargs(self.inbound_handler)['event'].message.envelope_sender,
|
||||
"envelope-from@example.org")
|
||||
|
||||
# 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(self.get_kwargs(self.inbound_handler)['event'].message.envelope_sender,
|
||||
"envelope-from@example.org")
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
183
tests/test_sendgrid_inbound.py
Normal file
183
tests/test_sendgrid_inbound.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import json
|
||||
from textwrap import dedent
|
||||
|
||||
import six
|
||||
from mock import ANY
|
||||
|
||||
from anymail.inbound import AnymailInboundMessage
|
||||
from anymail.signals import AnymailInboundEvent
|
||||
from anymail.webhooks.sendgrid import SendGridInboundWebhookView
|
||||
|
||||
from .utils import sample_image_content, sample_email_content
|
||||
from .webhook_cases import WebhookTestCase
|
||||
|
||||
|
||||
class SendgridInboundTestCase(WebhookTestCase):
|
||||
def test_inbound_basics(self):
|
||||
raw_event = {
|
||||
'headers': dedent("""\
|
||||
Received: from mail.example.org by mx987654321.sendgrid.net ...
|
||||
Received: by mail.example.org for <test@inbound.example.com> ...
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.org; ...
|
||||
MIME-Version: 1.0
|
||||
Received: by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)
|
||||
From: "Displayed From" <from+test@example.org>
|
||||
Date: Wed, 11 Oct 2017 18:31:04 -0700
|
||||
Message-ID: <CAEPk3R+4Zr@mail.example.org>
|
||||
Subject: Test subject
|
||||
To: "Test Inbound" <test@inbound.example.com>, other@example.com
|
||||
Cc: cc@example.com
|
||||
Content-Type: multipart/mixed; boundary="94eb2c115edcf35387055b61f849"
|
||||
"""),
|
||||
'from': 'Displayed From <from+test@example.org>',
|
||||
'to': 'Test Inbound <test@inbound.example.com>, other@example.com',
|
||||
'subject': "Test subject",
|
||||
'text': "Test body plain",
|
||||
'html': "<div>Test body html</div>",
|
||||
'attachments': "0",
|
||||
'charsets': '{"to":"UTF-8","html":"UTF-8","subject":"UTF-8","from":"UTF-8","text":"UTF-8"}',
|
||||
'envelope': '{"to":["test@inbound.example.com"],"from":"envelope-from@example.org"}',
|
||||
'sender_ip': "10.10.1.71",
|
||||
'dkim': "{@example.org : pass}", # yep, SendGrid uses not-exactly-json for this field
|
||||
'SPF': "pass",
|
||||
'spam_score': "1.7",
|
||||
'spam_report': 'Spam detection software, running on the system "mx987654321.sendgrid.net", '
|
||||
'has identified this incoming email as possible spam...',
|
||||
}
|
||||
response = self.client.post('/anymail/sendgrid/inbound/', data=raw_event)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=SendGridInboundWebhookView,
|
||||
event=ANY, esp_name='SendGrid')
|
||||
# AnymailInboundEvent
|
||||
event = kwargs['event']
|
||||
self.assertIsInstance(event, AnymailInboundEvent)
|
||||
self.assertEqual(event.event_type, 'inbound')
|
||||
self.assertIsNone(event.timestamp)
|
||||
self.assertIsNone(event.event_id)
|
||||
self.assertIsInstance(event.message, AnymailInboundMessage)
|
||||
self.assertEqual(event.esp_event.POST.dict(), raw_event) # esp_event is a Django HttpRequest
|
||||
|
||||
# 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([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(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>')
|
||||
|
||||
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
|
||||
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
|
||||
self.assertIsNone(message.stripped_text)
|
||||
self.assertIsNone(message.stripped_html)
|
||||
self.assertIsNone(message.spam_detected) # SendGrid doesn't give a simple yes/no; check the score yourself
|
||||
self.assertEqual(message.spam_score, 1.7)
|
||||
|
||||
# AnymailInboundMessage - other headers
|
||||
self.assertEqual(message['Message-ID'], "<CAEPk3R+4Zr@mail.example.org>")
|
||||
self.assertEqual(message.get_all('Received'), [
|
||||
"from mail.example.org by mx987654321.sendgrid.net ...",
|
||||
"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)",
|
||||
])
|
||||
|
||||
def test_attachments(self):
|
||||
att1 = six.BytesIO('test attachment'.encode('utf-8'))
|
||||
att1.name = 'test.txt'
|
||||
image_content = sample_image_content()
|
||||
att2 = six.BytesIO(image_content)
|
||||
att2.name = 'image.png'
|
||||
email_content = sample_email_content()
|
||||
att3 = six.BytesIO(email_content)
|
||||
att3.content_type = 'message/rfc822; charset="us-ascii"'
|
||||
raw_event = {
|
||||
'headers': '',
|
||||
'attachments': '3',
|
||||
'attachment-info': json.dumps({
|
||||
"attachment3": {"filename": "", "name": "", "charset": "US-ASCII", "type": "message/rfc822"},
|
||||
"attachment2": {"filename": "image.png", "name": "image.png", "type": "image/png",
|
||||
"content-id": "abc123"},
|
||||
"attachment1": {"filename": "test.txt", "name": "test.txt", "type": "text/plain"},
|
||||
}),
|
||||
'content-ids': '{"abc123": "attachment2"}',
|
||||
'attachment1': att1,
|
||||
'attachment2': att2, # inline
|
||||
'attachment3': att3,
|
||||
}
|
||||
|
||||
response = self.client.post('/anymail/sendgrid/inbound/', data=raw_event)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=SendGridInboundWebhookView,
|
||||
event=ANY, esp_name='SendGrid')
|
||||
event = kwargs['event']
|
||||
message = event.message
|
||||
attachments = message.attachments # AnymailInboundMessage convenience accessor
|
||||
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(), u'test attachment')
|
||||
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
|
||||
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
|
||||
|
||||
inlines = message.inline_attachments
|
||||
self.assertEqual(len(inlines), 1)
|
||||
inline = inlines['abc123']
|
||||
self.assertEqual(inline.get_filename(), 'image.png')
|
||||
self.assertEqual(inline.get_content_type(), 'image/png')
|
||||
self.assertEqual(inline.get_content_bytes(), image_content)
|
||||
|
||||
def test_inbound_mime(self):
|
||||
# SendGrid has an option to send the full, raw MIME message
|
||||
raw_event = {
|
||||
'email': dedent("""\
|
||||
From: A tester <test@example.org>
|
||||
Date: Thu, 12 Oct 2017 18:03:30 -0700
|
||||
Message-ID: <CAEPk3RKEx@mail.example.org>
|
||||
Subject: Raw MIME test
|
||||
To: test@inbound.example.com
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="94eb2c05e174adb140055b6339c5"
|
||||
|
||||
--94eb2c05e174adb140055b6339c5
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
It's a body=E2=80=A6
|
||||
|
||||
--94eb2c05e174adb140055b6339c5
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr">It's a body=E2=80=A6</div>
|
||||
|
||||
--94eb2c05e174adb140055b6339c5--
|
||||
"""),
|
||||
'from': 'A tester <test@example.org>',
|
||||
'to': 'test@inbound.example.com',
|
||||
'subject': "Raw MIME test",
|
||||
'charsets': '{"to":"UTF-8","subject":"UTF-8","from":"UTF-8"}',
|
||||
'envelope': '{"to":["test@inbound.example.com"],"from":"envelope-from@example.org"}',
|
||||
'sender_ip': "10.10.1.71",
|
||||
'dkim': "{@example.org : pass}", # yep, SendGrid uses not-exactly-json for this field
|
||||
'SPF': "pass",
|
||||
'spam_score': "1.7",
|
||||
'spam_report': 'Spam detection software, running on the system "mx987654321.sendgrid.net", '
|
||||
'has identified this incoming email as possible spam...',
|
||||
}
|
||||
|
||||
response = self.client.post('/anymail/sendgrid/inbound/', data=raw_event)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=SendGridInboundWebhookView,
|
||||
event=ANY, esp_name='SendGrid')
|
||||
event = kwargs['event']
|
||||
message = event.message
|
||||
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
|
||||
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
|
||||
self.assertEqual(message.subject, 'Raw MIME test')
|
||||
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
175
tests/test_sparkpost_inbound.py
Normal file
175
tests/test_sparkpost_inbound.py
Normal file
@@ -0,0 +1,175 @@
|
||||
import json
|
||||
from base64 import b64encode
|
||||
from textwrap import dedent
|
||||
|
||||
from mock import ANY
|
||||
|
||||
from anymail.inbound import AnymailInboundMessage
|
||||
from anymail.signals import AnymailInboundEvent
|
||||
from anymail.webhooks.sparkpost import SparkPostInboundWebhookView
|
||||
|
||||
from .utils import sample_image_content, sample_email_content
|
||||
from .webhook_cases import WebhookTestCase
|
||||
|
||||
|
||||
class SparkpostInboundTestCase(WebhookTestCase):
|
||||
def test_inbound_basics(self):
|
||||
event = {
|
||||
'protocol': "smtp",
|
||||
'rcpt_to': "test@inbound.example.com",
|
||||
'msg_from': "envelope-from@example.org",
|
||||
'content': {
|
||||
# Anymail just parses the raw rfc822 email. SparkPost's other content fields are ignored.
|
||||
'email_rfc822_is_base64': False,
|
||||
'email_rfc822': dedent("""\
|
||||
Received: from mail.example.org by c.mta1vsmtp.cc.prd.sparkpost ...
|
||||
Received: by mail.example.org for <test@inbound.example.com> ...
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.org; ...
|
||||
MIME-Version: 1.0
|
||||
Received: by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)
|
||||
From: "Displayed From" <from+test@example.org>
|
||||
Date: Wed, 11 Oct 2017 18:31:04 -0700
|
||||
Message-ID: <CAEPk3R+4Zr@mail.example.org>
|
||||
Subject: Test subject
|
||||
To: "Test Inbound" <test@inbound.example.com>, other@example.com
|
||||
Cc: cc@example.com
|
||||
Content-Type: multipart/alternative; boundary="94eb2c05e174adb140055b6339c5"
|
||||
|
||||
--94eb2c05e174adb140055b6339c5
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
It's a body=E2=80=A6
|
||||
|
||||
--94eb2c05e174adb140055b6339c5
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr">It's a body=E2=80=A6</div>
|
||||
|
||||
--94eb2c05e174adb140055b6339c5--
|
||||
"""),
|
||||
},
|
||||
}
|
||||
raw_event = {'msys': {'relay_message': event}}
|
||||
|
||||
response = self.client.post('/anymail/sparkpost/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=SparkPostInboundWebhookView,
|
||||
event=ANY, esp_name='SparkPost')
|
||||
# AnymailInboundEvent
|
||||
event = kwargs['event']
|
||||
self.assertIsInstance(event, AnymailInboundEvent)
|
||||
self.assertEqual(event.event_type, 'inbound')
|
||||
self.assertIsNone(event.timestamp)
|
||||
self.assertIsNone(event.event_id)
|
||||
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([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(message.subject, 'Test subject')
|
||||
self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00")
|
||||
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
|
||||
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
|
||||
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
|
||||
self.assertIsNone(message.stripped_text)
|
||||
self.assertIsNone(message.stripped_html)
|
||||
self.assertIsNone(message.spam_detected)
|
||||
self.assertIsNone(message.spam_score)
|
||||
|
||||
# AnymailInboundMessage - other headers
|
||||
self.assertEqual(message['Message-ID'], "<CAEPk3R+4Zr@mail.example.org>")
|
||||
self.assertEqual(message.get_all('Received'), [
|
||||
"from mail.example.org by c.mta1vsmtp.cc.prd.sparkpost ...",
|
||||
"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)",
|
||||
])
|
||||
|
||||
def test_attachments(self):
|
||||
image_content = sample_image_content()
|
||||
email_content = sample_email_content()
|
||||
raw_mime = dedent("""\
|
||||
MIME-Version: 1.0
|
||||
From: from@example.org
|
||||
Subject: Attachments
|
||||
To: test@inbound.example.com
|
||||
Content-Type: multipart/mixed; boundary="boundary0"
|
||||
|
||||
--boundary0
|
||||
Content-Type: multipart/related; boundary="boundary1"
|
||||
|
||||
--boundary1
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
|
||||
<div>This is the HTML body. It has an inline image: <img src="cid:abc123">.</div>
|
||||
|
||||
--boundary1
|
||||
Content-Type: image/png
|
||||
Content-Disposition: inline; filename="image.png"
|
||||
Content-ID: <abc123>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
{image_content_base64}
|
||||
--boundary1--
|
||||
--boundary0
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
Content-Disposition: attachment; filename="test.txt"
|
||||
|
||||
test attachment
|
||||
--boundary0
|
||||
Content-Type: message/rfc822; charset="US-ASCII"
|
||||
Content-Disposition: attachment
|
||||
X-Comment: (the only valid transfer encodings for message/* are 7bit, 8bit, and binary)
|
||||
|
||||
{email_content}
|
||||
--boundary0--
|
||||
""").format(image_content_base64=b64encode(image_content).decode('ascii'),
|
||||
email_content=email_content.decode('ascii'))
|
||||
|
||||
raw_event = {'msys': {'relay_message': {
|
||||
'protocol': "smtp",
|
||||
'content': {
|
||||
'email_rfc822_is_base64': True,
|
||||
'email_rfc822': b64encode(raw_mime.encode('utf-8')).decode('ascii'),
|
||||
},
|
||||
}}}
|
||||
|
||||
response = self.client.post('/anymail/sparkpost/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=SparkPostInboundWebhookView,
|
||||
event=ANY, esp_name='SparkPost')
|
||||
event = kwargs['event']
|
||||
message = event.message
|
||||
attachments = message.attachments # AnymailInboundMessage convenience accessor
|
||||
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(), u'test attachment')
|
||||
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
|
||||
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
|
||||
|
||||
# the message attachment (its payload) is fully parsed
|
||||
# (see the original in test_files/sample_email.txt)
|
||||
att_message = attachments[1].get_payload(0)
|
||||
self.assertEqual(att_message.get_content_type(), "multipart/alternative")
|
||||
self.assertEqual(att_message['Subject'], "Test email")
|
||||
self.assertEqual(att_message.text, "Hi Bob, This is a message. Thanks!\n")
|
||||
|
||||
inlines = message.inline_attachments
|
||||
self.assertEqual(len(inlines), 1)
|
||||
inline = inlines['abc123']
|
||||
self.assertEqual(inline.get_filename(), 'image.png')
|
||||
self.assertEqual(inline.get_content_type(), 'image/png')
|
||||
self.assertEqual(inline.get_content_bytes(), image_content)
|
||||
@@ -8,6 +8,7 @@ import warnings
|
||||
from base64 import b64decode
|
||||
from contextlib import contextmanager
|
||||
|
||||
import six
|
||||
from django.test import Client
|
||||
|
||||
|
||||
@@ -35,6 +36,12 @@ def decode_att(att):
|
||||
return b64decode(att.encode('ascii'))
|
||||
|
||||
|
||||
def rfc822_unfold(text):
|
||||
# "Unfolding is accomplished by simply removing any CRLF that is immediately followed by WSP"
|
||||
# (WSP is space or tab, and per email.parser semantics, we allow CRLF, CR, or LF endings)
|
||||
return re.sub(r'(\r\n|\r|\n)(?=[ \t])', "", text)
|
||||
|
||||
|
||||
#
|
||||
# Sample files for testing (in ./test_files subdir)
|
||||
#
|
||||
@@ -133,11 +140,18 @@ class AnymailTestMixin:
|
||||
except TypeError:
|
||||
return self.assertRegexpMatches(*args, **kwargs) # Python 2
|
||||
|
||||
def assertEqualIgnoringWhitespace(self, first, second, msg=None):
|
||||
# Useful for message/rfc822 attachment tests
|
||||
self.assertEqual(first.replace(b'\n', b'').replace(b' ', b''),
|
||||
second.replace(b'\n', b'').replace(b' ', b''),
|
||||
msg)
|
||||
def assertEqualIgnoringHeaderFolding(self, first, second, msg=None):
|
||||
# Unfold (per RFC-8222) all text first and second, then compare result.
|
||||
# Useful for message/rfc822 attachment tests, where various Python email
|
||||
# versions handled folding slightly differently.
|
||||
# (Technically, this is unfolding both headers and (incorrectly) bodies,
|
||||
# but that doesn't really affect the tests.)
|
||||
if isinstance(first, six.binary_type) and isinstance(second, six.binary_type):
|
||||
first = first.decode('utf-8')
|
||||
second = second.decode('utf-8')
|
||||
first = rfc822_unfold(first)
|
||||
second = rfc822_unfold(second)
|
||||
self.assertEqual(first, second, msg)
|
||||
|
||||
|
||||
# Backported from python 3.5
|
||||
|
||||
@@ -64,6 +64,12 @@ class WebhookTestCase(AnymailTestMixin, SimpleTestCase):
|
||||
self.assertEqual(actual_kwargs[key], expected_value)
|
||||
return actual_kwargs
|
||||
|
||||
def get_kwargs(self, mockfn):
|
||||
"""Return the kwargs passed to the most recent call to mockfn"""
|
||||
self.assertIsNotNone(mockfn.call_args) # mockfn hasn't been called yet
|
||||
actual_args, actual_kwargs = mockfn.call_args
|
||||
return actual_kwargs
|
||||
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
class WebhookBasicAuthTestsMixin(object):
|
||||
|
||||
Reference in New Issue
Block a user