1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353
|
import json
from datetime import datetime, timezone
from textwrap import dedent
from unittest.mock import ANY
from django.core.exceptions import ImproperlyConfigured
from django.test import override_settings, tag
from anymail.exceptions import AnymailConfigurationError
from anymail.inbound import AnymailInboundMessage
from anymail.signals import AnymailInboundEvent
from anymail.webhooks.mailersend import MailerSendInboundWebhookView
from .test_mailersend_webhooks import (
TEST_WEBHOOK_SIGNING_SECRET,
MailerSendWebhookTestCase,
)
from .utils import sample_image_content
from .webhook_cases import WebhookBasicAuthTestCase
@tag("mailersend")
@override_settings(ANYMAIL_MAILERSEND_INBOUND_SECRET=TEST_WEBHOOK_SIGNING_SECRET)
class MailerSendInboundSecurityTestCase(
MailerSendWebhookTestCase, WebhookBasicAuthTestCase
):
should_warn_if_no_auth = False # because we check webhook signature
def call_webhook(self):
return self.client_post_signed(
"/anymail/mailersend/inbound/",
{"type": "inbound.message", "data": {"raw": "..."}},
secret=TEST_WEBHOOK_SIGNING_SECRET,
)
# Additional tests are in WebhookBasicAuthTestCase
def test_verifies_correct_signature(self):
response = self.client_post_signed(
"/anymail/mailersend/inbound/",
{"type": "inbound.message", "data": {"raw": "..."}},
secret=TEST_WEBHOOK_SIGNING_SECRET,
)
self.assertEqual(response.status_code, 200)
def test_verifies_missing_signature(self):
response = self.client.post(
"/anymail/mailersend/inbound/",
content_type="application/json",
data=json.dumps({"type": "inbound.message", "data": {"raw": "..."}}),
)
self.assertEqual(response.status_code, 400)
def test_verifies_bad_signature(self):
# This also verifies that the error log references the correct setting to check.
with self.assertLogs() as logs:
response = self.client_post_signed(
"/anymail/mailersend/inbound/",
{"type": "inbound.message", "data": {"raw": "..."}},
secret="wrong signing key",
)
# SuspiciousOperation causes 400 response (even in test client):
self.assertEqual(response.status_code, 400)
self.assertIn("check Anymail MAILERSEND_INBOUND_SECRET", logs.output[0])
@tag("mailersend")
class MailerSendInboundSettingsTestCase(MailerSendWebhookTestCase):
def test_requires_inbound_secret(self):
with self.assertRaisesMessage(
ImproperlyConfigured, "MAILERSEND_INBOUND_SECRET"
):
self.client_post_signed(
"/anymail/mailersend/inbound/",
{
"type": "inbound.message",
"data": {"object": "message", "raw": "..."},
},
)
@override_settings(
ANYMAIL={
"MAILERSEND_INBOUND_SECRET": "inbound secret",
"MAILERSEND_WEBHOOK_SIGNING_SECRET": "webhook secret",
}
)
def test_webhook_signing_secret_is_different(self):
response = self.client_post_signed(
"/anymail/mailersend/inbound/",
{
"type": "inbound.message",
"data": {"object": "message", "raw": "..."},
},
secret="inbound secret",
)
self.assertEqual(response.status_code, 200)
@override_settings(ANYMAIL_MAILERSEND_INBOUND_SECRET="settings secret")
def test_inbound_secret_view_params(self):
"""Webhook signing secret can be provided as a view param"""
view = MailerSendInboundWebhookView.as_view(inbound_secret="view-level secret")
view_instance = view.view_class(**view.view_initkwargs)
self.assertEqual(view_instance.signing_secret, b"view-level secret")
@tag("mailersend")
@override_settings(ANYMAIL_MAILERSEND_INBOUND_SECRET=TEST_WEBHOOK_SIGNING_SECRET)
class MailerSendInboundTestCase(MailerSendWebhookTestCase):
# Since Anymail just parses the raw MIME message through the Python email
# package, there aren't really a lot of different cases to test here.
# (We don't need to re-test the whole email.parser.)
def test_inbound(self):
# This is an actual (sanitized) inbound payload received from MailerSend:
raw_event = {
"type": "inbound.message",
"inbound_id": "[inbound-route-id-redacted]",
"url": "https://test.anymail.dev/anymail/mailersend/inbound/",
"created_at": "2023-03-04T02:22:16.417935Z",
"data": {
"object": "message",
"id": "6402ab57f79d39d7e10f2523",
"recipients": {
"rcptTo": [{"email": "envelope-recipient@example.com"}],
"to": {
"raw": "Recipient <to@example.com>",
"data": [{"email": "to@example.com", "name": "Recipient"}],
},
},
"from": {
"email": "sender@example.org",
"name": "Sender Name",
"raw": "Sender Name <sender@example.org>",
},
"sender": {"email": "envelope-sender@example.org"},
"subject": "Testing inbound \ud83c\udf0e",
"date": "Fri, 3 Mar 2023 18:22:03 -0800",
"headers": {
"X-Envelope-From": "<envelope-sender@example.org>",
# Multiple-instance headers appear as arrays:
"Received": [
"from example.org (mail.example.org [10.10.10.10])\r\n"
" by inbound.mailersend.net with ESMTPS id ...\r\n"
" Sat, 04 Mar 2023 02:22:15 +0000 (UTC)",
"by mail.example.org with SMTP id ...\r\n"
" for <envelope-recipient@example.com>;\r\n"
" Fri, 03 Mar 2023 18:22:15 -0800 (PST)",
],
"DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed; ...",
"MIME-Version": "1.0",
"From": "Sender Name <sender@example.org>",
"Date": "Fri, 3 Mar 2023 18:22:03 -0800",
"Message-ID": "<AzjSdSHsmvXUeZGTPQ@mail.example.org>",
"Subject": "=?UTF-8?Q?Testing_inbound_=F0=9F=8C=8E?=",
"To": "Recipient <to@example.com>",
"Content-Type": 'multipart/mixed; boundary="000000000000e5575c05f609bab6"',
},
"text": "This is a *test*!\r\n\r\n[image: sample_image.png]\r\n",
"html": (
"<p>This is a <b>test</b>!</p>"
'<img src="cid:ii_letc8ro50" alt="sample_image.png">'
),
"raw": dedent(
"""\
X-Envelope-From: <envelope-sender@example.org>
Received: from example.org (mail.example.org [10.10.10.10])
by inbound.mailersend.net with ESMTPS id ...
Sat, 04 Mar 2023 02:22:15 +0000 (UTC)
Received: by mail.example.org with SMTP id ...
for <envelope-recipient@example.com>;
Fri, 03 Mar 2023 18:22:15 -0800 (PST)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; ...
MIME-Version: 1.0
From: Sender Name <sender@example.org>
Date: Fri, 3 Mar 2023 18:22:03 -0800
Message-ID: <AzjSdSHsmvXUeZGTPQ@mail.example.org>
Subject: =?UTF-8?Q?Testing_inbound_=F0=9F=8C=8E?=
To: Recipient <to@example.com>
Content-Type: multipart/mixed; boundary="000000000000e5575c05f609bab6"
--000000000000e5575c05f609bab6
Content-Type: multipart/related; boundary="000000000000e5575b05f609bab5"
--000000000000e5575b05f609bab5
Content-Type: multipart/alternative; boundary="000000000000e5575a05f609bab4"
--000000000000e5575a05f609bab4
Content-Type: text/plain; charset="UTF-8"
This is a *test*!
[image: sample_image.png]
--000000000000e5575a05f609bab4
Content-Type: text/html; charset="UTF-8"
<p>This is a <b>test</b>!</p>
<img src="cid:ii_letc8ro50" alt="sample_image.png">
--000000000000e5575a05f609bab4--
--000000000000e5575b05f609bab5
Content-Type: image/png; name="sample_image.png"
Content-Disposition: inline; filename="sample_image.png"
Content-Transfer-Encoding: base64
Content-ID: <ii_letc8ro50>
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz
AAALEgAACxIB0t1+/AAAABR0RVh0Q3JlYXRpb24gVGltZQAzLzEvMTNoZNRjAAAAHHRFWHRTb2Z0
d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M1cbXjNgAAAZ1JREFUWIXtl7FKA0EQhr+TgIFgo5BXyBUp
fIGksLawUNAXWFFfwCJgBAtfIJFMLXgQn8BSwdpCiPcKAdOIoI2x2Dmyd7kYwXhp9odluX/uZv6d
nZu7DXowxiKZi0IAUHKCvxcsoAIEpST4IawVGb0Hb0BlpcigefACvAAvwAsoTTGGlwwzBAyivLUP
EZrOM10AhGOH2wWugVVlHoAdhJHrPC8DNR0JGsAAQ9mxNzBOMNjS4Qrq69U5EKmf12ywWVsQI4QI
IbCn3Gnmnk7uk1bokfooI7QRDlQIGCdzPwiYh0idtXNs2zq3UqwVEiDcu/R0DVjUnFpItuPSscfA
FXCGSfEAdZ2fVeQ68OjYWwi3ycVvMhABGwgfKXZScHeZ+4c6VzN8FbuYukvOykCs+z8PJ0xqIXYE
d4ALoKlVH2IIgUHWwd/6gNAFPjPcCPvKNTDcYAj1lXzKc7GIRrSZI6yJzcQ+dtV9bD+IkHThBj34
4j9/yYxupaQbXPJLNqsGFgeZ6qwpLP1b4AV4AV5AoKfjpR5OwR6VKwULCAC+AQV4W9Ps4uZQAAAA
AElFTkSuQmCC
--000000000000e5575b05f609bab5--
--000000000000e5575c05f609bab6
Content-Type: text/csv; charset="US-ASCII"; name="sample_data.csv"
Content-Disposition: attachment; filename="sample_data.csv"
Content-Transfer-Encoding: quoted-printable
Product,Price
Widget,33.20
--000000000000e5575c05f609bab6--"""
).replace("\n", "\r\n"),
"attachments": [
{
"file_name": "sample_image.png",
"content_type": "image/png",
"content_disposition": "inline",
"content_id": "ii_letc8ro50",
"size": 579,
"content": (
"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhki"
"AAAAAlwSFlzAAALEgAACxIB0t1+/AAAABR0RVh0Q3JlYXRpb24gVGltZQAzLzEvMT"
"NoZNRjAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M1cbXjNgAAAZ1"
"JREFUWIXtl7FKA0EQhr+TgIFgo5BXyBUpfIGksLawUNAXWFFfwCJgBAtfIJFMLXgQ"
"n8BSwdpCiPcKAdOIoI2x2Dmyd7kYwXhp9odluX/uZv6dnZu7DXowxiKZi0IAUHKCv"
"xcsoAIEpST4IawVGb0Hb0BlpcigefACvAAvwAsoTTGGlwwzBAyivLUPEZrOM10AhG"
"OH2wWugVVlHoAdhJHrPC8DNR0JGsAAQ9mxNzBOMNjS4Qrq69U5EKmf12ywWVsQI4Q"
"IIbCn3Gnmnk7uk1bokfooI7QRDlQIGCdzPwiYh0idtXNs2zq3UqwVEiDcu/R0DVjU"
"nFpItuPSscfAFXCGSfEAdZ2fVeQ68OjYWwi3ycVvMhABGwgfKXZScHeZ+4c6VzN8F"
"buYukvOykCs+z8PJ0xqIXYEd4ALoKlVH2IIgUHWwd/6gNAFPjPcCPvKNTDcYAj1lX"
"zKc7GIRrSZI6yJzcQ+dtV9bD+IkHThBj344j9/yYxupaQbXPJLNqsGFgeZ6qwpLP1"
"b4AV4AV5AoKfjpR5OwR6VKwULCAC+AQV4W9Ps4uZQAAAAAElFTkSuQmCC"
),
},
{
"file_name": "sample_data.csv",
"content_type": "text/csv",
"content_disposition": "attachment",
"size": 26,
"content": "UHJvZHVjdCxQcmljZQpXaWRnZXQsMzMuMjA=",
},
],
"spf_check": {"code": "+", "value": None},
"dkim_check": False,
"created_at": "2023-03-04T02:22:15.525000Z",
},
}
response = self.client_post_signed("/anymail/mailersend/inbound/", raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.inbound_handler,
sender=MailerSendInboundWebhookView,
event=ANY,
esp_name="MailerSend",
)
# AnymailInboundEvent
event = kwargs["event"]
self.assertIsInstance(event, AnymailInboundEvent)
self.assertEqual(event.event_type, "inbound")
self.assertEqual(
event.timestamp,
# "2023-03-04T02:22:15.525000Z"
datetime(2023, 3, 4, 2, 22, 15, microsecond=525000, tzinfo=timezone.utc),
)
self.assertEqual(event.event_id, "6402ab57f79d39d7e10f2523")
self.assertIsInstance(event.message, AnymailInboundMessage)
# (The raw_event subject contains a "\N{EARTH GLOBE AMERICAS}" (🌎)
# character in the escaped form "\ud83c\udf0e", which won't compare equal
# until unescaped. Passing through json dumps/loads resolves the escapes.)
self.assertEqual(event.esp_event, json.loads(json.dumps(raw_event)))
# AnymailInboundMessage - convenience properties
message = event.message
self.assertEqual(message.from_email.display_name, "Sender Name")
self.assertEqual(message.from_email.addr_spec, "sender@example.org")
self.assertEqual(str(message.to[0]), "Recipient <to@example.com>")
self.assertEqual(message.subject, "Testing inbound 🌎")
self.assertEqual(message.date.isoformat(" "), "2023-03-03 18:22:03-08:00")
self.assertEqual(
message.text, "This is a *test*!\r\n\r\n[image: sample_image.png]\r\n"
)
self.assertHTMLEqual(
message.html,
"<p>This is a <b>test</b>!</p>"
'<img src="cid:ii_letc8ro50" alt="sample_image.png">',
)
self.assertEqual(message.envelope_sender, "envelope-sender@example.org")
self.assertEqual(message.envelope_recipient, "envelope-recipient@example.com")
# MailerSend inbound doesn't provide these:
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"], "<AzjSdSHsmvXUeZGTPQ@mail.example.org>")
self.assertEqual(
message.get_all("Received"),
[
"from example.org (mail.example.org [10.10.10.10]) by inbound.mailersend.net"
" with ESMTPS id ... Sat, 04 Mar 2023 02:22:15 +0000 (UTC)",
"by mail.example.org with SMTP id ... for <envelope-recipient@example.com>;"
" Fri, 03 Mar 2023 18:22:15 -0800 (PST)",
],
)
inlines = message.content_id_map
self.assertEqual(len(inlines), 1)
inline = inlines["ii_letc8ro50"]
self.assertEqual(inline.get_filename(), "sample_image.png")
self.assertEqual(inline.get_content_type(), "image/png")
self.assertEqual(inline.get_content_bytes(), sample_image_content())
attachments = message.attachments
self.assertEqual(len(attachments), 1)
self.assertEqual(attachments[0].get_filename(), "sample_data.csv")
self.assertEqual(attachments[0].get_content_type(), "text/csv")
self.assertEqual(
attachments[0].get_content_text(), "Product,Price\r\nWidget,33.20"
)
def test_misconfigured_inbound(self):
errmsg = (
"You seem to have set MailerSend's *activity.sent* webhook"
" to Anymail's MailerSend *inbound* webhook URL."
)
with self.assertRaisesMessage(AnymailConfigurationError, errmsg):
self.client_post_signed(
"/anymail/mailersend/inbound/",
{
"type": "activity.sent",
"data": {"object": "activity", "type": "sent"},
},
)
|