Skip to content

Commit d33c9ea

Browse files
committed
Mailgun: improve API error messages
1 parent 2196ab8 commit d33c9ea

File tree

6 files changed

+95
-2
lines changed

6 files changed

+95
-2
lines changed

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ Fixes
4040
Other
4141
~~~~~
4242

43+
* **Mailgun:** Improve error messages for some common configuration issues.
44+
4345
* Test against Django 3.2 prerelease (including support for Python 3.9)
4446

4547
* Move CI testing to GitHub Actions (and stop using Travis-CI).

anymail/backends/mailgun.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,30 @@ def __init__(self, **kwargs):
4747
def build_message_payload(self, message, defaults):
4848
return MailgunPayload(message, defaults, self)
4949

50+
def raise_for_status(self, response, payload, message):
51+
# Mailgun issues a terse 404 for unrecognized sender domains.
52+
# Add some context:
53+
if response.status_code == 404 and "Domain not found" in response.text:
54+
raise AnymailRequestsAPIError(
55+
"Unknown sender domain {sender_domain!r}.\n"
56+
"Check the domain is verified with Mailgun, and that the ANYMAIL"
57+
" MAILGUN_API_URL setting {api_url!r} is the correct region.".format(
58+
sender_domain=payload.sender_domain, api_url=self.api_url),
59+
email_message=message, payload=payload,
60+
response=response, backend=self)
61+
62+
super().raise_for_status(response, payload, message)
63+
64+
# Mailgun issues a cryptic "Mailgun Magnificent API" success response
65+
# for invalid API endpoints. Convert that to a useful error:
66+
if response.status_code == 200 and "Mailgun Magnificent API" in response.text:
67+
raise AnymailRequestsAPIError(
68+
"Invalid Mailgun API endpoint %r.\n"
69+
"Check your ANYMAIL MAILGUN_SENDER_DOMAIN"
70+
" and MAILGUN_API_URL settings." % response.url,
71+
email_message=message, payload=payload,
72+
response=response, backend=self)
73+
5074
def parse_recipient_status(self, response, payload, message):
5175
# The *only* 200 response from Mailgun seems to be:
5276
# {

anymail/webhooks/mailgun.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,15 @@ def esp_to_anymail_event(self, request):
352352
"You seem to have set Mailgun's *%s tracking* webhook "
353353
"to Anymail's Mailgun *inbound* webhook URL." % request.POST['event'])
354354

355+
if 'attachments' in request.POST:
356+
# Inbound route used store() rather than forward().
357+
# ("attachments" seems to be the only POST param that differs between
358+
# store and forward; Anymail could support store by handling the JSON
359+
# attachments param in message_from_mailgun_parsed.)
360+
raise AnymailConfigurationError(
361+
"You seem to have configured Mailgun's receiving route using the store()"
362+
" action. Anymail's inbound webhook requires the forward() action.")
363+
355364
if 'body-mime' in request.POST:
356365
# Raw-MIME
357366
message = AnymailInboundMessage.parse_raw_mime(request.POST['body-mime'])

tests/mock_requests_backend.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,22 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
2121

2222
class MockResponse(requests.Response):
2323
"""requests.request return value mock sufficient for testing"""
24-
def __init__(self, status_code=200, raw=b"RESPONSE", encoding='utf-8', reason=None):
24+
def __init__(self, status_code=200, raw=b"RESPONSE", encoding='utf-8', reason=None, test_case=None):
2525
super().__init__()
2626
self.status_code = status_code
2727
self.encoding = encoding
2828
self.reason = reason or ("OK" if 200 <= status_code < 300 else "ERROR")
2929
self.raw = BytesIO(raw)
30+
self.test_case = test_case
31+
32+
@property
33+
def url(self):
34+
return self.test_case.get_api_call_arg('url', required=False)
35+
36+
@url.setter
37+
def url(self, url):
38+
if url is not None:
39+
raise ValueError("MockResponse can't handle url assignment")
3040

3141
def setUp(self):
3242
super().setUp()
@@ -38,7 +48,7 @@ def setUp(self):
3848
def set_mock_response(self, status_code=DEFAULT_STATUS_CODE, raw=UNSET, encoding='utf-8', reason=None):
3949
if raw is UNSET:
4050
raw = self.DEFAULT_RAW_RESPONSE
41-
mock_response = self.MockResponse(status_code, raw=raw, encoding=encoding, reason=reason)
51+
mock_response = self.MockResponse(status_code, raw=raw, encoding=encoding, reason=reason, test_case=self)
4252
self.mock_request.return_value = mock_response
4353
return mock_response
4454

tests/test_mailgun_backend.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,31 @@ def test_encode_sender_domain(self):
667667
self.message.send()
668668
self.assert_esp_called('/example.com%20%23%20oops/messages')
669669

670+
def test_unknown_sender_domain(self):
671+
self.set_mock_response(raw=b"""{
672+
"message": "Domain not found: example.com"
673+
}""", status_code=404)
674+
with self.assertRaisesMessage(
675+
AnymailAPIError,
676+
"Unknown sender domain 'example.com'.\n"
677+
"Check the domain is verified with Mailgun, and that the ANYMAIL MAILGUN_API_URL"
678+
" setting 'https://api.mailgun.net/v3/' is the correct region."
679+
):
680+
self.message.send()
681+
682+
@override_settings(
683+
# This is *not* a valid MAILGUN_API_URL setting (it should end at "...v3/"):
684+
ANYMAIL_MAILGUN_API_URL='https://api.mailgun.net/v3/example.com/messages')
685+
def test_magnificent_api(self):
686+
# (Wouldn't a truly "magnificent API" just provide a helpful error message?)
687+
self.set_mock_response(raw=b"Mailgun Magnificent API", status_code=200)
688+
with self.assertRaisesMessage(
689+
AnymailAPIError,
690+
"Invalid Mailgun API endpoint 'https://api.mailgun.net/v3/example.com/messages/example.com/messages'.\n"
691+
"Check your ANYMAIL MAILGUN_SENDER_DOMAIN and MAILGUN_API_URL settings."
692+
):
693+
self.message.send()
694+
670695
def test_default_omits_options(self):
671696
"""Make sure by default we don't send any ESP-specific options.
672697

tests/test_mailgun_inbound.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,29 @@ def test_misconfigured_tracking(self):
196196
self.client.post('/anymail/mailgun/inbound/',
197197
data=json.dumps(raw_event), content_type='application/json')
198198

199+
def test_misconfigured_store_action(self):
200+
# store() notification includes "attachments" json; forward() includes "attachment-count"
201+
raw_event = mailgun_sign_legacy_payload({
202+
'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0',
203+
'timestamp': '1461261330',
204+
'recipient': '[email protected]',
205+
'sender': '[email protected]',
206+
'body-plain': 'Test body plain',
207+
'body-html': '<div>Test body html</div>',
208+
'attachments': json.dumps([{
209+
"url": "https://storage.mailgun.net/v3/domains/example.com/messages/MESSAGE_KEY/attachments/0",
210+
"content-type": "application/pdf",
211+
"name": "attachment.pdf",
212+
"size": 20202
213+
}]),
214+
})
215+
with self.assertRaisesMessage(
216+
AnymailConfigurationError,
217+
"You seem to have configured Mailgun's receiving route using the store() action."
218+
" Anymail's inbound webhook requires the forward() action."
219+
):
220+
self.client.post('/anymail/mailgun/inbound/', data=raw_event)
221+
199222
def test_misconfigured_tracking_legacy(self):
200223
raw_event = mailgun_sign_legacy_payload({
201224
'domain': 'example.com',

0 commit comments

Comments
 (0)