diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3b9be0e..c925457 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -33,6 +33,10 @@ vNext Fixes ~~~~~ +* Fix misleading error messages when sending with ``fail_silently=True`` + and session creation fails (e.g., with Amazon SES backend and missing + credentials). (Thanks to `@technolingo`_.) + * **Postmark:** Fix spurious AnymailInvalidAddress in ``message.cc`` when inbound message has no Cc recipients. (Thanks to `@Ecno92`_.) @@ -1455,6 +1459,7 @@ Features .. _@slinkymanbyday: https://github.com/slinkymanbyday .. _@swrobel: https://github.com/swrobel .. _@tcourtqtm: https://github.com/tcourtqtm +.. _@technolingo: https://github.com/technolingo .. _@Thorbenl: https://github.com/Thorbenl .. _@tiltec: https://github.com/tiltec .. _@tim-schilling: https://github.com/tim-schilling diff --git a/anymail/backends/amazon_ses.py b/anymail/backends/amazon_ses.py index d6eeec2..8c97969 100644 --- a/anymail/backends/amazon_ses.py +++ b/anymail/backends/amazon_ses.py @@ -59,7 +59,7 @@ class EmailBackend(AnymailBaseBackend): self.client = boto3.session.Session(**self.session_params).client( "ses", **self.client_params ) - except BOTO_BASE_ERRORS: + except Exception: if not self.fail_silently: raise else: @@ -71,6 +71,22 @@ class EmailBackend(AnymailBaseBackend): # self.client.close() # boto3 doesn't support (or require) client shutdown self.client = None + def _send(self, message): + if self.client: + return super()._send(message) + elif self.fail_silently: + # (Probably missing boto3 credentials in open().) + return False + else: + class_name = self.__class__.__name__ + raise RuntimeError( + "boto3 Session has not been opened in {class_name}._send. " + "(This is either an implementation error in {class_name}, " + "or you are incorrectly calling _send directly.)".format( + class_name=class_name + ) + ) + def build_message_payload(self, message, defaults): # The SES SendRawEmail and SendBulkTemplatedEmail calls have # very different signatures, so use a custom payload for each diff --git a/anymail/backends/amazon_sesv2.py b/anymail/backends/amazon_sesv2.py index 553a218..0399e4c 100644 --- a/anymail/backends/amazon_sesv2.py +++ b/anymail/backends/amazon_sesv2.py @@ -63,7 +63,7 @@ class EmailBackend(AnymailBaseBackend): self.client = boto3.session.Session(**self.session_params).client( "sesv2", **self.client_params ) - except BOTO_BASE_ERRORS: + except Exception: if not self.fail_silently: raise else: @@ -75,6 +75,22 @@ class EmailBackend(AnymailBaseBackend): self.client.close() self.client = None + def _send(self, message): + if self.client: + return super()._send(message) + elif self.fail_silently: + # (Probably missing boto3 credentials in open().) + return False + else: + class_name = self.__class__.__name__ + raise RuntimeError( + "boto3 Session has not been opened in {class_name}._send. " + "(This is either an implementation error in {class_name}, " + "or you are incorrectly calling _send directly.)".format( + class_name=class_name + ) + ) + def build_message_payload(self, message, defaults): if getattr(message, "template_id", UNSET) is not UNSET: # For simplicity, use SESv2 SendBulkEmail for all templated messages diff --git a/anymail/backends/base_requests.py b/anymail/backends/base_requests.py index 3bc7ab1..c075856 100644 --- a/anymail/backends/base_requests.py +++ b/anymail/backends/base_requests.py @@ -47,7 +47,12 @@ class AnymailRequestsBackend(AnymailBaseBackend): self.session = None def _send(self, message): - if self.session is None: + if self.session: + return super()._send(message) + elif self.fail_silently: + # create_session failed + return False + else: class_name = self.__class__.__name__ raise RuntimeError( "Session has not been opened in {class_name}._send. " @@ -56,7 +61,6 @@ class AnymailRequestsBackend(AnymailBaseBackend): class_name=class_name ) ) - return super()._send(message) def create_session(self): """Create the requests.Session object used by this instance of the backend. diff --git a/tests/test_amazon_ses_backend.py b/tests/test_amazon_ses_backend.py index 897a14f..5a4ec00 100644 --- a/tests/test_amazon_ses_backend.py +++ b/tests/test_amazon_ses_backend.py @@ -393,6 +393,16 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) + def test_session_failure_fail_silently(self): + # Make sure fail_silently is respected if boto3.Session creation fails + # (e.g., due to invalid or missing credentials) + from botocore.exceptions import NoCredentialsError + + self.mock_session.side_effect = NoCredentialsError() + + sent = self.message.send(fail_silently=True) + self.assertEqual(sent, 0) + def test_prevents_header_injection(self): # Since we build the raw MIME message, we're responsible for preventing header # injection. django.core.mail.EmailMessage.message() implements most of that diff --git a/tests/test_amazon_sesv2_backend.py b/tests/test_amazon_sesv2_backend.py index 4084ceb..12b589f 100644 --- a/tests/test_amazon_sesv2_backend.py +++ b/tests/test_amazon_sesv2_backend.py @@ -403,6 +403,16 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) + def test_session_failure_fail_silently(self): + # Make sure fail_silently is respected if boto3.Session creation fails + # (e.g., due to invalid or missing credentials) + from botocore.exceptions import NoCredentialsError + + self.mock_session.side_effect = NoCredentialsError() + + sent = self.message.send(fail_silently=True) + self.assertEqual(sent, 0) + def test_prevents_header_injection(self): # Since we build the raw MIME message, we're responsible for preventing header # injection. django.core.mail.EmailMessage.message() implements most of that diff --git a/tests/test_base_backends.py b/tests/test_base_backends.py index bb73a06..8a6a645 100644 --- a/tests/test_base_backends.py +++ b/tests/test_base_backends.py @@ -1,3 +1,5 @@ +from unittest import mock + from django.test import SimpleTestCase, override_settings, tag from anymail.backends.base_requests import AnymailRequestsBackend, RequestsPayload @@ -69,6 +71,14 @@ class RequestsBackendBaseTestCase(RequestsBackendMockAPITestCase): timeout = self.get_api_call_arg("timeout") self.assertEqual(timeout, 5) + @mock.patch(f"{__name__}.MinimalRequestsBackend.create_session") + def test_create_session_error_fail_silently(self, mock_create_session): + # If create_session fails and fail_silently is True, + # make sure _send doesn't raise a misleading error. + mock_create_session.side_effect = ValueError("couldn't create session") + sent = self.message.send(fail_silently=True) + self.assertEqual(sent, 0) + @tag("live") @override_settings(EMAIL_BACKEND="tests.test_base_backends.MinimalRequestsBackend")