diff --git a/anymail/backends/base.py b/anymail/backends/base.py index a4bf413..e05bb0c 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -183,7 +183,8 @@ class AnymailBaseBackend(BaseEmailBackend): # Error if *all* recipients are invalid or refused # (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend) if anymail_status.status.issubset({"invalid", "rejected"}): - raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response) + raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response, + backend=self) @property def esp_name(self): diff --git a/anymail/backends/base_requests.py b/anymail/backends/base_requests.py index 294ad5f..2530244 100644 --- a/anymail/backends/base_requests.py +++ b/anymail/backends/base_requests.py @@ -85,7 +85,8 @@ class AnymailRequestsBackend(AnymailBaseBackend): parse_recipient_status) """ if response.status_code != 200: - raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response) + raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, + backend=self) def deserialize_json_response(self, response, payload, message): """Deserialize an ESP API response that's in json. @@ -96,7 +97,8 @@ class AnymailRequestsBackend(AnymailBaseBackend): return response.json() except ValueError: raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name, - email_message=message, payload=payload, response=response) + email_message=message, payload=payload, response=response, + backend=self) class RequestsPayload(BasePayload): diff --git a/anymail/backends/mailgun.py b/anymail/backends/mailgun.py index 34a5c9e..a5492f3 100644 --- a/anymail/backends/mailgun.py +++ b/anymail/backends/mailgun.py @@ -46,10 +46,12 @@ class EmailBackend(AnymailRequestsBackend): mailgun_message = parsed_response["message"] except (KeyError, TypeError): raise AnymailRequestsAPIError("Invalid Mailgun API response format", - email_message=message, payload=payload, response=response) + email_message=message, payload=payload, response=response, + backend=self) if not mailgun_message.startswith("Queued"): raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message, - email_message=message, payload=payload, response=response) + email_message=message, payload=payload, response=response, + backend=self) # Simulate a per-recipient status of "queued": status = AnymailRecipientStatus(message_id=message_id, status="queued") return {recipient.email: status for recipient in payload.all_recipients} diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index c61f7ff..14449f4 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -42,7 +42,8 @@ class EmailBackend(AnymailRequestsBackend): recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status) except (KeyError, TypeError): raise AnymailRequestsAPIError("Invalid Mandrill API response format", - email_message=message, payload=payload, response=response) + email_message=message, payload=payload, response=response, + backend=self) return recipient_status diff --git a/anymail/backends/postmark.py b/anymail/backends/postmark.py index 48e523d..86f6d22 100644 --- a/anymail/backends/postmark.py +++ b/anymail/backends/postmark.py @@ -42,7 +42,8 @@ class EmailBackend(AnymailRequestsBackend): msg = parsed_response["Message"] except (KeyError, TypeError): raise AnymailRequestsAPIError("Invalid Postmark API response format", - email_message=message, payload=payload, response=response) + email_message=message, payload=payload, response=response, + backend=self) message_id = parsed_response.get("MessageID", None) rejected_emails = [] @@ -51,7 +52,8 @@ class EmailBackend(AnymailRequestsBackend): # Either the From address or at least one recipient was invalid. Email not sent. if "'From' address" in msg: # Normal error - raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response) + raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, + backend=self) else: # Use AnymailRecipientsRefused logic default_status = 'invalid' @@ -64,7 +66,8 @@ class EmailBackend(AnymailRequestsBackend): default_status = 'sent' rejected_emails = self.parse_inactive_recipients(msg) else: - raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response) + raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, + backend=self) return { recipient.email: AnymailRecipientStatus( diff --git a/anymail/backends/sendgrid.py b/anymail/backends/sendgrid.py index 6f8f605..3d2c5ea 100644 --- a/anymail/backends/sendgrid.py +++ b/anymail/backends/sendgrid.py @@ -55,7 +55,8 @@ class EmailBackend(AnymailRequestsBackend): def raise_for_status(self, response, payload, message): if response.status_code < 200 or response.status_code >= 300: - raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response) + raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, + backend=self) def parse_recipient_status(self, response, payload, message): # If we get here, the send call was successful. diff --git a/anymail/backends/sendgrid_v2.py b/anymail/backends/sendgrid_v2.py index c0e2c8d..c045a6f 100644 --- a/anymail/backends/sendgrid_v2.py +++ b/anymail/backends/sendgrid_v2.py @@ -54,11 +54,13 @@ class EmailBackend(AnymailRequestsBackend): sendgrid_message = parsed_response["message"] except (KeyError, TypeError): raise AnymailRequestsAPIError("Invalid SendGrid API response format", - email_message=message, payload=payload, response=response) + email_message=message, payload=payload, response=response, + backend=self) if sendgrid_message != "success": errors = parsed_response.get("errors", []) raise AnymailRequestsAPIError("SendGrid send failed: '%s'" % "; ".join(errors), - email_message=message, payload=payload, response=response) + email_message=message, payload=payload, response=response, + backend=self) # Simulate a per-recipient status of "queued": status = AnymailRecipientStatus(message_id=payload.message_id, status="queued") return {recipient.email: status for recipient in payload.all_recipients} diff --git a/anymail/exceptions.py b/anymail/exceptions.py index d49a9df..d448fa8 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -17,15 +17,19 @@ class AnymailError(Exception): Optional kwargs: email_message: the original EmailMessage being sent status_code: HTTP status code of response to ESP send call + backend: the backend instance involved payload: data arg (*not* json-stringified) for the ESP send call response: requests.Response from the send call raised_from: original/wrapped Exception + esp_name: what to call the ESP (read from backend if provided) """ self.backend = kwargs.pop('backend', None) self.email_message = kwargs.pop('email_message', None) self.payload = kwargs.pop('payload', None) self.status_code = kwargs.pop('status_code', None) self.raised_from = kwargs.pop('raised_from', None) + self.esp_name = kwargs.pop('esp_name', + self.backend.esp_name if self.backend else None) if isinstance(self, HTTPError): # must leave response in kwargs for HTTPError self.response = kwargs.get('response', None) @@ -61,7 +65,7 @@ class AnymailError(Exception): """Return a formatted string of self.status_code and response, or None""" if self.status_code is None: return None - description = "ESP API response %d:" % self.status_code + description = "%s API response %d:" % (self.esp_name or "ESP", self.status_code) try: json_response = self.response.json() description += "\n" + json.dumps(json_response, indent=2) @@ -131,7 +135,9 @@ class AnymailSerializationError(AnymailError, TypeError): def __init__(self, message=None, orig_err=None, *args, **kwargs): if message is None: - esp_name = kwargs["backend"].esp_name if "backend" in kwargs else "the ESP" + # self.esp_name not set until super init, so duplicate logic to get esp_name + backend = kwargs.get('backend', None) + esp_name = kwargs.get('esp_name', backend.esp_name if backend else "the ESP") message = "Don't know how to send this data to %s. " \ "Try converting it to a string or number first." % esp_name if orig_err is not None: diff --git a/tests/test_mailgun_backend.py b/tests/test_mailgun_backend.py index 0672263..7c6da1f 100644 --- a/tests/test_mailgun_backend.py +++ b/tests/test_mailgun_backend.py @@ -227,9 +227,8 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): def test_api_failure(self): self.set_mock_response(status_code=400) - with self.assertRaises(AnymailAPIError): - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) - self.assertEqual(sent, 0) + with self.assertRaisesMessage(AnymailAPIError, "Mailgun API response 400"): + mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) # Make sure fail_silently is respected self.set_mock_response(status_code=400) diff --git a/tests/test_mandrill_backend.py b/tests/test_mandrill_backend.py index 9cdaacc..1f4cc97 100644 --- a/tests/test_mandrill_backend.py +++ b/tests/test_mandrill_backend.py @@ -236,9 +236,8 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase): def test_api_failure(self): self.set_mock_response(status_code=400) - with self.assertRaises(AnymailAPIError): - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) - self.assertEqual(sent, 0) + with self.assertRaisesMessage(AnymailAPIError, "Mandrill API response 400"): + mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) # Make sure fail_silently is respected self.set_mock_response(status_code=400) diff --git a/tests/test_postmark_backend.py b/tests/test_postmark_backend.py index c593645..2390d98 100644 --- a/tests/test_postmark_backend.py +++ b/tests/test_postmark_backend.py @@ -273,9 +273,8 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase): def test_api_failure(self): self.set_mock_response(status_code=500) - with self.assertRaises(AnymailAPIError): - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) - self.assertEqual(sent, 0) + with self.assertRaisesMessage(AnymailAPIError, "Postmark API response 500"): + mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) # Make sure fail_silently is respected self.set_mock_response(status_code=500) diff --git a/tests/test_sendgrid_backend.py b/tests/test_sendgrid_backend.py index 1760073..bca1057 100644 --- a/tests/test_sendgrid_backend.py +++ b/tests/test_sendgrid_backend.py @@ -294,9 +294,8 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): def test_api_failure(self): self.set_mock_response(status_code=400) - with self.assertRaises(AnymailAPIError): - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) - self.assertEqual(sent, 0) + with self.assertRaisesMessage(AnymailAPIError, "SendGrid API response 400"): + mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) # Make sure fail_silently is respected self.set_mock_response(status_code=400) diff --git a/tests/test_sendgrid_v2_backend.py b/tests/test_sendgrid_v2_backend.py index 6f813ce..a85ba4a 100644 --- a/tests/test_sendgrid_v2_backend.py +++ b/tests/test_sendgrid_v2_backend.py @@ -301,9 +301,8 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): def test_api_failure(self): self.set_mock_response(status_code=400) - with self.assertRaises(AnymailAPIError): - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) - self.assertEqual(sent, 0) + with self.assertRaisesMessage(AnymailAPIError, "SendGrid API response 400"): + mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) # Make sure fail_silently is respected self.set_mock_response(status_code=400) diff --git a/tests/test_sparkpost_backend.py b/tests/test_sparkpost_backend.py index b988dc0..26bc487 100644 --- a/tests/test_sparkpost_backend.py +++ b/tests/test_sparkpost_backend.py @@ -289,12 +289,8 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): self.assertNotIn('recipients', params) def test_api_failure(self): - failure_response = b"""{ "errors": [ { - "message": "Something went wrong", - "description": "Helpful explanation from your ESP" - } ] }""" - self.set_mock_failure(raw=failure_response) - with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from your ESP"): + self.set_mock_failure(status_code=400) + with self.assertRaisesMessage(AnymailAPIError, "SparkPost API response 400"): self.message.send() def test_api_failure_fail_silently(self): @@ -303,6 +299,16 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) + def test_api_error_includes_details(self): + """AnymailAPIError should include ESP's error message""" + failure_response = b"""{ "errors": [ { + "message": "Something went wrong", + "description": "Helpful explanation from your ESP" + } ] }""" + self.set_mock_failure(raw=failure_response) + with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from your ESP"): + self.message.send() + class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): """Test backend support for Anymail added features"""