diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 091ef7a6..8c2643cc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,23 @@ Release history ^^^^^^^^^^^^^^^ .. This extra heading level keeps the ToC from becoming unmanageably long + +vNext +----- + +*Unreleased changes* + +Breaking changes +~~~~~~~~~~~~~~~~ + +* **Amazon SES:** Drop support for the Amazon SES v1 API. + If your ``EMAIL_BACKEND`` setting uses ``amazon_sesv1``, + or if you are upgrading from Anymail 9.x or earlier directly to 11.0 or later, see + `Migrating to the SES v2 API `__. + (Anymail 10.0 switched to the SES v2 API by default. If your ``EMAIL_BACKEND`` + setting has ``amazon_sesv2``, change that to just ``amazon_ses``.) + + v10.3 ----- diff --git a/anymail/backends/amazon_sesv1.py b/anymail/backends/amazon_sesv1.py deleted file mode 100644 index e9a9e4e6..00000000 --- a/anymail/backends/amazon_sesv1.py +++ /dev/null @@ -1,449 +0,0 @@ -import warnings -from email.charset import QP, Charset -from email.mime.text import MIMEText - -from ..exceptions import ( - AnymailAPIError, - AnymailDeprecationWarning, - AnymailImproperlyInstalled, -) -from ..message import AnymailRecipientStatus -from ..utils import UNSET, get_anymail_setting -from .base import AnymailBaseBackend, BasePayload - -try: - import boto3 - from botocore.exceptions import BotoCoreError, ClientError, ConnectionError -except ImportError as err: - raise AnymailImproperlyInstalled( - missing_package="boto3", install_extra="amazon-ses" - ) from err - - -# boto3 has several root exception classes; this is meant to cover all of them -BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError) - - -class EmailBackend(AnymailBaseBackend): - """ - Amazon SES v1 Email Backend (using boto3) - """ - - esp_name = "Amazon SES" - - def __init__(self, **kwargs): - """Init options from Django settings""" - from .amazon_ses import _get_anymail_boto3_params - - warnings.warn( - "anymail.backends.amazon_sesv1.EmailBackend is deprecated" - " and will be removed in the near future. Please migrate" - " to anymail.backends.amazon_ses.EmailBackend using Amazon SES v2.", - AnymailDeprecationWarning, - ) - super().__init__(**kwargs) - - # AMAZON_SES_CLIENT_PARAMS is optional - # (boto3 can find credentials several other ways) - self.session_params, self.client_params = _get_anymail_boto3_params( - kwargs=kwargs - ) - self.configuration_set_name = get_anymail_setting( - "configuration_set_name", - esp_name=self.esp_name, - kwargs=kwargs, - allow_bare=False, - default=None, - ) - self.message_tag_name = get_anymail_setting( - "message_tag_name", - esp_name=self.esp_name, - kwargs=kwargs, - allow_bare=False, - default=None, - ) - self.client = None - - def open(self): - if self.client: - return False # already exists - try: - self.client = boto3.session.Session(**self.session_params).client( - "ses", **self.client_params - ) - except Exception: - if not self.fail_silently: - raise - else: - return True # created client - - def close(self): - if self.client is None: - return - # 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 - if getattr(message, "template_id", UNSET) is not UNSET: - return AmazonSESSendBulkTemplatedEmailPayload(message, defaults, self) - else: - return AmazonSESSendRawEmailPayload(message, defaults, self) - - def post_to_esp(self, payload, message): - try: - response = payload.call_send_api(self.client) - except BOTO_BASE_ERRORS as err: - # ClientError has a response attr with parsed json error response - # (other errors don't) - raise AnymailAPIError( - str(err), - backend=self, - email_message=message, - payload=payload, - response=getattr(err, "response", None), - ) from err - return response - - def parse_recipient_status(self, response, payload, message): - return payload.parse_recipient_status(response) - - -class AmazonSESBasePayload(BasePayload): - def init_payload(self): - self.params = {} - if self.backend.configuration_set_name is not None: - self.params["ConfigurationSetName"] = self.backend.configuration_set_name - - def call_send_api(self, ses_client): - raise NotImplementedError() - - def parse_recipient_status(self, response): - # response is the parsed (dict) JSON returned from the API call - raise NotImplementedError() - - def set_esp_extra(self, extra): - # e.g., ConfigurationSetName, FromArn, SourceArn, ReturnPathArn - self.params.update(extra) - - -class AmazonSESSendRawEmailPayload(AmazonSESBasePayload): - def init_payload(self): - super().init_payload() - self.all_recipients = [] - self.mime_message = self.message.message() - - # Work around an Amazon SES bug where, if all of: - # - the message body (text or html) contains non-ASCII characters - # - the body is sent with `Content-Transfer-Encoding: 8bit` - # (which is Django email's default for most non-ASCII bodies) - # - you are using an SES ConfigurationSet with open or click tracking enabled - # then SES replaces the non-ASCII characters with question marks as it rewrites - # the message to add tracking. Forcing `CTE: quoted-printable` avoids the - # problem. (https://forums.aws.amazon.com/thread.jspa?threadID=287048) - for part in self.mime_message.walk(): - if ( - part.get_content_maintype() == "text" - and part["Content-Transfer-Encoding"] == "8bit" - ): - content = part.get_payload() - del part["Content-Transfer-Encoding"] - qp_charset = Charset(part.get_content_charset("us-ascii")) - qp_charset.body_encoding = QP - # (can't use part.set_payload, because SafeMIMEText can undo - # this workaround) - MIMEText.set_payload(part, content, charset=qp_charset) - - def call_send_api(self, ses_client): - # Set Destinations to make sure we pick up all recipients (including bcc). - # Any non-ASCII characters in recipient domains must be encoded with Punycode. - # (Amazon SES doesn't support non-ASCII recipient usernames.) - self.params["Destinations"] = [email.address for email in self.all_recipients] - self.params["RawMessage"] = {"Data": self.mime_message.as_bytes()} - return ses_client.send_raw_email(**self.params) - - def parse_recipient_status(self, response): - try: - message_id = response["MessageId"] - except (KeyError, TypeError) as err: - raise AnymailAPIError( - "%s parsing Amazon SES send result %r" % (str(err), response), - backend=self.backend, - email_message=self.message, - payload=self, - ) from None - - recipient_status = AnymailRecipientStatus( - message_id=message_id, status="queued" - ) - return { - recipient.addr_spec: recipient_status for recipient in self.all_recipients - } - - # Standard EmailMessage attrs... - # These all get rolled into the RFC-5322 raw mime directly via - # EmailMessage.message() - - def _no_send_defaults(self, attr): - # Anymail global send defaults don't work for standard attrs, because the - # merged/computed value isn't forced back into the EmailMessage. - if attr in self.defaults: - self.unsupported_feature( - "Anymail send defaults for '%s' with Amazon SES" % attr - ) - - def set_from_email_list(self, emails): - # Although Amazon SES will send messages with any From header, it can only parse - # Source if the From header is a single email. Explicit Source avoids an - # "Illegal address" error: - if len(emails) > 1: - self.params["Source"] = emails[0].addr_spec - # (else SES will look at the (single) address in the From header) - - def set_recipients(self, recipient_type, emails): - self.all_recipients += emails - # included in mime_message - assert recipient_type in ("to", "cc", "bcc") - self._no_send_defaults(recipient_type) - - def set_subject(self, subject): - # included in mime_message - self._no_send_defaults("subject") - - def set_reply_to(self, emails): - # included in mime_message - self._no_send_defaults("reply_to") - - def set_extra_headers(self, headers): - # included in mime_message - self._no_send_defaults("extra_headers") - - def set_text_body(self, body): - # included in mime_message - self._no_send_defaults("body") - - def set_html_body(self, body): - # included in mime_message - self._no_send_defaults("body") - - def set_alternatives(self, alternatives): - # included in mime_message - self._no_send_defaults("alternatives") - - def set_attachments(self, attachments): - # included in mime_message - self._no_send_defaults("attachments") - - # Anymail-specific payload construction - def set_envelope_sender(self, email): - self.params["Source"] = email.addr_spec - - def set_spoofed_to_header(self, header_to): - # django.core.mail.EmailMessage.message() has already set - # self.mime_message["To"] = header_to - # and performed any necessary header sanitization. - # - # The actual "to" is already in self.all_recipients, - # which is used as the SendRawEmail Destinations later. - # - # So, nothing to do here, except prevent the default - # "unsupported feature" error. - pass - - def set_metadata(self, metadata): - # Amazon SES has two mechanisms for adding custom data to a message: - # * Custom message headers are available to webhooks (SNS notifications), - # but not in CloudWatch metrics/dashboards or Kinesis Firehose streams. - # Custom headers can be sent only with SendRawEmail. - # * "Message Tags" are available to CloudWatch and Firehose, and to SNS - # notifications for SES *events* but not SES *notifications*. (Got that?) - # Message Tags also allow *very* limited characters in both name and value. - # Message Tags can be sent with any SES send call. - # (See "How do message tags work?" in - # https://aws.amazon.com/blogs/ses/introducing-sending-metrics/ - # and https://forums.aws.amazon.com/thread.jspa?messageID=782922.) - # To support reliable retrieval in webhooks, just use custom headers for - # metadata. - self.mime_message["X-Metadata"] = self.serialize_json(metadata) - - def set_tags(self, tags): - # See note about Amazon SES Message Tags and custom headers in set_metadata - # above. To support reliable retrieval in webhooks, use custom headers for tags. - # (There are no restrictions on number or content for custom header tags.) - for tag in tags: - # creates multiple X-Tag headers, one per tag: - self.mime_message.add_header("X-Tag", tag) - - # Also *optionally* pass a single Message Tag if the AMAZON_SES_MESSAGE_TAG_NAME - # Anymail setting is set (default no). The AWS API restricts tag content in this - # case. (This is useful for dashboard segmentation; use esp_extra["Tags"] for - # anything more complex.) - if tags and self.backend.message_tag_name is not None: - if len(tags) > 1: - self.unsupported_feature( - "multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting" - ) - self.params.setdefault("Tags", []).append( - {"Name": self.backend.message_tag_name, "Value": tags[0]} - ) - - def set_template_id(self, template_id): - raise NotImplementedError( - "AmazonSESSendRawEmailPayload should not have been used with template_id" - ) - - def set_merge_data(self, merge_data): - self.unsupported_feature("merge_data without template_id") - - def set_merge_global_data(self, merge_global_data): - self.unsupported_feature("global_merge_data without template_id") - - -class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload): - def init_payload(self): - super().init_payload() - # late-bind recipients and merge_data in call_send_api - self.recipients = {"to": [], "cc": [], "bcc": []} - self.merge_data = {} - - def call_send_api(self, ses_client): - # include any 'cc' or 'bcc' in every destination - cc_and_bcc_addresses = {} - if self.recipients["cc"]: - cc_and_bcc_addresses["CcAddresses"] = [ - cc.address for cc in self.recipients["cc"] - ] - if self.recipients["bcc"]: - cc_and_bcc_addresses["BccAddresses"] = [ - bcc.address for bcc in self.recipients["bcc"] - ] - - # set up destination and data for each 'to' - self.params["Destinations"] = [ - { - "Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses), - "ReplacementTemplateData": self.serialize_json( - self.merge_data.get(to.addr_spec, {}) - ), - } - for to in self.recipients["to"] - ] - - return ses_client.send_bulk_templated_email(**self.params) - - def parse_recipient_status(self, response): - try: - # response["Status"] should be a list in Destinations (to) order - anymail_statuses = [ - AnymailRecipientStatus( - message_id=status.get("MessageId", None), - status="queued" if status.get("Status") == "Success" else "failed", - ) - for status in response["Status"] - ] - except (KeyError, TypeError) as err: - raise AnymailAPIError( - "%s parsing Amazon SES send result %r" % (str(err), response), - backend=self.backend, - email_message=self.message, - payload=self, - ) from None - - to_addrs = [to.addr_spec for to in self.recipients["to"]] - if len(anymail_statuses) != len(to_addrs): - raise AnymailAPIError( - "Sent to %d destinations, but only %d statuses in Amazon SES" - " send result %r" % (len(to_addrs), len(anymail_statuses), response), - backend=self.backend, - email_message=self.message, - payload=self, - ) - - return dict(zip(to_addrs, anymail_statuses)) - - def set_from_email(self, email): - # this will RFC2047-encode display_name if needed: - self.params["Source"] = email.address - - def set_recipients(self, recipient_type, emails): - # late-bound in call_send_api - assert recipient_type in ("to", "cc", "bcc") - self.recipients[recipient_type] = emails - - def set_subject(self, subject): - # (subject can only come from template; you can use substitution vars in that) - if subject: - self.unsupported_feature("overriding template subject") - - def set_reply_to(self, emails): - if emails: - self.params["ReplyToAddresses"] = [email.address for email in emails] - - def set_extra_headers(self, headers): - self.unsupported_feature("extra_headers with template") - - def set_text_body(self, body): - if body: - self.unsupported_feature("overriding template body content") - - def set_html_body(self, body): - if body: - self.unsupported_feature("overriding template body content") - - def set_attachments(self, attachments): - if attachments: - self.unsupported_feature("attachments with template") - - # Anymail-specific payload construction - def set_envelope_sender(self, email): - self.params["ReturnPath"] = email.addr_spec - - def set_metadata(self, metadata): - # no custom headers with SendBulkTemplatedEmail - self.unsupported_feature("metadata with template") - - def set_tags(self, tags): - # no custom headers with SendBulkTemplatedEmail, but support - # AMAZON_SES_MESSAGE_TAG_NAME if used (see tags/metadata in - # AmazonSESSendRawEmailPayload for more info) - if tags: - if self.backend.message_tag_name is not None: - if len(tags) > 1: - self.unsupported_feature( - "multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting" - ) - self.params["DefaultTags"] = [ - {"Name": self.backend.message_tag_name, "Value": tags[0]} - ] - else: - self.unsupported_feature( - "tags with template (unless using the" - " AMAZON_SES_MESSAGE_TAG_NAME setting)" - ) - - def set_template_id(self, template_id): - self.params["Template"] = template_id - - def set_merge_data(self, merge_data): - # late-bound in call_send_api - self.merge_data = merge_data - - def set_merge_global_data(self, merge_global_data): - self.params["DefaultTemplateData"] = self.serialize_json(merge_global_data) diff --git a/anymail/backends/amazon_sesv2.py b/anymail/backends/amazon_sesv2.py deleted file mode 100644 index e257a4da..00000000 --- a/anymail/backends/amazon_sesv2.py +++ /dev/null @@ -1,14 +0,0 @@ -import warnings - -from ..exceptions import AnymailDeprecationWarning -from .amazon_ses import EmailBackend as AmazonSESV2EmailBackend - - -class EmailBackend(AmazonSESV2EmailBackend): - def __init__(self, **kwargs): - warnings.warn( - "Anymail now uses Amazon SES v2 by default. Please change" - " 'amazon_sesv2' to 'amazon_ses' in your EMAIL_BACKEND setting.", - AnymailDeprecationWarning, - ) - super().__init__(**kwargs) diff --git a/docs/esps/amazon_ses.rst b/docs/esps/amazon_ses.rst index d1cc33e2..73639a69 100644 --- a/docs/esps/amazon_ses.rst +++ b/docs/esps/amazon_ses.rst @@ -6,16 +6,11 @@ Amazon SES Anymail integrates with the `Amazon Simple Email Service`_ (SES) using the `Boto 3`_ AWS SDK for Python, and supports sending, tracking, and inbound receiving capabilities. -.. versionchanged:: 10.0 - -.. note:: - - AWS has two versions of the SES API available for sending email. Anymail 10.0 - uses the newer SES v2 API by default, and this is recommended for new projects. +.. versionchanged:: 11.0 - If you integrated Amazon SES using an earlier Anymail release, you may need to - update your IAM permissions. See :ref:`amazon-ses-v2` below. Or if you are not - ready to switch, see :ref:`amazon-ses-v1` below. + Anymail supports only the newer Amazon SES v2 API. (Anymail 10.x supported both + SES v1 and v2, and used v2 by default. Anymail 9.x and earlier used SES v1.) + See :ref:`amazon-ses-v2` below if you are upgrading from an earlier Anymail version. .. sidebar:: Alternatives @@ -67,72 +62,6 @@ setting to customize the Boto session. .. _Credentials: https://boto3.readthedocs.io/en/stable/guide/configuration.html#configuring-credentials -.. _amazon-ses-v2: - -Migrating to the SES v2 API ---------------------------- - -.. versionchanged:: 10.0 - -Anymail 10.0 uses Amazon's updated SES v2 API to send email. Earlier Anymail releases -used the original Amazon SES API (v1) by default. Although the capabilities of the two -SES versions are virtually identical, Amazon is implementing improvements (such as -increased maximum message size) only in the v2 API. - -(The upgrade for SES v2 affects only sending email. There are no changes required -for status tracking webhooks or receiving inbound email.) - -Migrating to SES v2 requires minimal code changes: - -1. Update your :ref:`IAM permissions ` to grant Anymail - access to the SES v2 sending actions: ``ses:SendEmail`` for ordinary sends, and/or - ``ses:SendBulkEmail`` to send using SES templates. (The IAM action - prefix is just ``ses`` for both the v1 and v2 APIs.) - - If you run into unexpected IAM authorization failures, see the note about - :ref:`misleading IAM permissions errors ` below. - -2. If your code uses Anymail's :attr:`~anymail.message.AnymailMessage.esp_extra` - to pass additional SES API parameters, or examines the raw - :attr:`~anymail.message.AnymailStatus.esp_response` after sending a message, - you may need to update it for the v2 API. Many parameters have different names - in the v2 API compared to the equivalent v1 calls, and the response formats are - slightly different. - - Among v1 parameters commonly used, ``ConfigurationSetName`` is unchanged in v2, - but v1's ``Tags`` and most ``*Arn`` parameters have been renamed in v2. - See AWS's docs for SES v1 `SendRawEmail`_ vs. v2 `SendEmail`_, or if you are sending - with SES templates, compare v1 `SendBulkTemplatedEmail`_ to v2 `SendBulkEmail`_. - -.. _SendRawEmail: - https://docs.aws.amazon.com/ses/latest/APIReference/API_SendRawEmail.html -.. _SendBulkTemplatedEmail: - https://docs.aws.amazon.com/ses/latest/APIReference/API_SendBulkTemplatedEmail.html - - -.. _amazon-ses-v1: - -Using SES v1 (deprecated) -~~~~~~~~~~~~~~~~~~~~~~~~~ - -New projects should use Anymail's default Amazon SES v2 integration. If you have an -existing project that is not ready to switch to v2, Anymail's original SES v1 support -is still available. In your settings.py, change the :setting:`!EMAIL_BACKEND` from: - - .. code-block:: python - - EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend" # default SES v2 - - to this: - - .. code-block:: python - - EMAIL_BACKEND = "anymail.backends.amazon_sesv1.EmailBackend" # SES v1 - # ^^ - -Note that SES v1 support is deprecated and will be removed in a future Anymail release -(likely in late 2023). - .. _amazon-ses-quirks: @@ -793,10 +722,8 @@ This IAM policy covers all of those: }] } -(To send using the deprecated ``amazon_sesv1`` EmailBackend, -you will also need to allow ``ses:SendRawEmail`` for ordinary, -non-templated sends, and/or ``ses:SendBulkTemplatedEmail`` for -templated/merge sends.) +(Anymail does not need access to ``ses:SendRawEmail`` +or ``ses:SendBulkTemplatedEmail``. Those are SES v1 actions.) .. _amazon-ses-iam-errors: @@ -854,3 +781,60 @@ for any features you aren't using, and you may want to add additional restrictio https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-permissions.html .. _IAM condition context keys: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html + + +.. _amazon-ses-v1: +.. _amazon-ses-v2: + +Migrating to the SES v2 API +--------------------------- + +.. versionchanged:: 10.0 + +Anymail 10.0 and later use Amazon's updated SES v2 API to send email. Earlier Anymail releases +used the original Amazon SES API (v1) by default. Although the capabilities of the two +SES versions are virtually identical, Amazon is implementing improvements (such as +increased maximum message size) only in the v2 API. + +(The upgrade for SES v2 affects only sending email. There are no changes required +for status tracking webhooks or receiving inbound email.) + +Migrating to SES v2 requires minimal code changes: + +1. Update your :ref:`IAM permissions ` to grant Anymail + access to the SES v2 sending actions: ``ses:SendEmail`` for ordinary sends, and/or + ``ses:SendBulkEmail`` to send using SES templates. (The IAM action + prefix is just ``ses`` for both the v1 and v2 APIs.) + + Access to ``ses:SendRawEmail`` or ``ses:SendBulkTemplatedEmail`` can be removed. + (Those actions are only needed for SES v1.) + + If you run into unexpected IAM authorization failures, see the note about + :ref:`misleading IAM permissions errors ` above. + +2. If your code uses Anymail's :attr:`~anymail.message.AnymailMessage.esp_extra` + to pass additional SES API parameters, or examines the raw + :attr:`~anymail.message.AnymailStatus.esp_response` after sending a message, + you may need to update it for the v2 API. Many parameters have different names + in the v2 API compared to the equivalent v1 calls, and the response formats are + slightly different. + + Among v1 parameters commonly used, ``ConfigurationSetName`` is unchanged in v2, + but v1's ``Tags`` and most ``*Arn`` parameters have been renamed in v2. + See AWS's docs for SES v1 `SendRawEmail`_ vs. v2 `SendEmail`_, or if you are sending + with SES templates, compare v1 `SendBulkTemplatedEmail`_ to v2 `SendBulkEmail`_. + + (If you do not use :attr:`!esp_extra` or :attr:`!esp_response`, you can + safely ignore this.) + +3. If your settings.py :setting:`!EMAIL_BACKEND` setting refers to ``amazon_sesv1`` + or ``amazon_sesv2``, change that to just ``amazon_ses``: + + .. code-block:: python + + EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend" + +.. _SendRawEmail: + https://docs.aws.amazon.com/ses/latest/APIReference/API_SendRawEmail.html +.. _SendBulkTemplatedEmail: + https://docs.aws.amazon.com/ses/latest/APIReference/API_SendBulkTemplatedEmail.html diff --git a/tests/test_amazon_ses_backend.py b/tests/test_amazon_ses_backend.py index f85b0fe6..b8c2c19e 100644 --- a/tests/test_amazon_ses_backend.py +++ b/tests/test_amazon_ses_backend.py @@ -9,11 +9,7 @@ from django.test import SimpleTestCase, override_settings, tag from anymail import __version__ as ANYMAIL_VERSION -from anymail.exceptions import ( - AnymailAPIError, - AnymailDeprecationWarning, - AnymailUnsupportedFeature, -) +from anymail.exceptions import AnymailAPIError, AnymailUnsupportedFeature from anymail.inbound import AnymailInboundMessage from anymail.message import AnymailMessage, attach_inline_image_file @@ -973,18 +969,3 @@ def test_config_set_setting(self): self.message.send() params = self.get_send_params() self.assertEqual(params["ConfigurationSetName"], "CustomConfigurationSet") - - @override_settings(EMAIL_BACKEND="anymail.backends.amazon_sesv2.EmailBackend") - def test_sesv2_warning(self): - # Default SES v2 backend is still available as "amazon_sesv2", - # but using that should warn to switch to just "amazon_ses". - with self.assertWarnsMessage( - AnymailDeprecationWarning, - "Please change 'amazon_sesv2' to 'amazon_ses' in your EMAIL_BACKEND setting.", - ): - self.message.send() - - def test_no_warning_default(self): - # Default SES backend does not have "amazon_sesv2" warning. - with self.assertDoesNotWarn(AnymailDeprecationWarning): - self.message.send() diff --git a/tests/test_amazon_ses_backendv1.py b/tests/test_amazon_ses_backendv1.py deleted file mode 100644 index 46644c3f..00000000 --- a/tests/test_amazon_ses_backendv1.py +++ /dev/null @@ -1,928 +0,0 @@ -import json -import warnings -from datetime import datetime -from email.mime.application import MIMEApplication -from unittest.mock import ANY, patch - -from django.core import mail -from django.core.mail import BadHeaderError -from django.test import SimpleTestCase, override_settings, tag - -from anymail import __version__ as ANYMAIL_VERSION -from anymail.exceptions import ( - AnymailAPIError, - AnymailDeprecationWarning, - AnymailUnsupportedFeature, -) -from anymail.inbound import AnymailInboundMessage -from anymail.message import AnymailMessage, attach_inline_image_file - -from .utils import ( - SAMPLE_IMAGE_FILENAME, - AnymailTestMixin, - sample_image_content, - sample_image_path, -) - - -@tag("amazon_ses") -@override_settings(EMAIL_BACKEND="anymail.backends.amazon_sesv1.EmailBackend") -class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): - """TestCase that uses the Amazon SES EmailBackend with a mocked boto3 client""" - - def setUp(self): - super().setUp() - - # Silence the "amazon_sesv1.EmailBackend is deprecated" warning for these tests. - # (Tests can still verify the warning with assertWarns.) - warnings.simplefilter("ignore", category=AnymailDeprecationWarning) - - # Mock boto3.session.Session().client('ses').send_raw_email (and any other - # client operations). (We could also use botocore.stub.Stubber, but mock works - # well with our test structure.) - self.patch_boto3_session = patch( - "anymail.backends.amazon_sesv1.boto3.session.Session", autospec=True - ) - self.mock_session = self.patch_boto3_session.start() # boto3.session.Session - self.addCleanup(self.patch_boto3_session.stop) - #: boto3.session.Session().client - self.mock_client = self.mock_session.return_value.client - #: boto3.session.Session().client('ses', ...) - self.mock_client_instance = self.mock_client.return_value - self.set_mock_response() - - # Simple message useful for many tests - self.message = mail.EmailMultiAlternatives( - "Subject", "Text Body", "from@example.com", ["to@example.com"] - ) - - DEFAULT_SEND_RESPONSE = { - "MessageId": "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000", - "ResponseMetadata": { - "RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb", - "HTTPStatusCode": 200, - "HTTPHeaders": { - "x-amzn-requestid": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb", - "content-type": "text/xml", - "content-length": "338", - "date": "Sat, 17 Mar 2018 03:33:33 GMT", - }, - "RetryAttempts": 0, - }, - } - - def set_mock_response(self, response=None, operation_name="send_raw_email"): - mock_operation = getattr(self.mock_client_instance, operation_name) - mock_operation.return_value = response or self.DEFAULT_SEND_RESPONSE - return mock_operation.return_value - - def set_mock_failure(self, response, operation_name="send_raw_email"): - from botocore.exceptions import ClientError - - mock_operation = getattr(self.mock_client_instance, operation_name) - mock_operation.side_effect = ClientError( - response, operation_name=operation_name - ) - - def get_session_params(self): - if self.mock_session.call_args is None: - raise AssertionError("boto3 Session was not created") - (args, kwargs) = self.mock_session.call_args - if args: - raise AssertionError( - "boto3 Session created with unexpected positional args %r" % args - ) - return kwargs - - def get_client_params(self, service="ses"): - """Returns kwargs params passed to mock boto3 client constructor - - Fails test if boto3 client wasn't constructed with named service - """ - if self.mock_client.call_args is None: - raise AssertionError("boto3 client was not created") - (args, kwargs) = self.mock_client.call_args - if len(args) != 1: - raise AssertionError( - "boto3 client created with unexpected positional args %r" % args - ) - if args[0] != service: - raise AssertionError( - "boto3 client created with service %r, not %r" % (args[0], service) - ) - return kwargs - - def get_send_params(self, operation_name="send_raw_email"): - """Returns kwargs params passed to the mock send API. - - Fails test if API wasn't called. - """ - self.mock_client.assert_called_with("ses", config=ANY) - mock_operation = getattr(self.mock_client_instance, operation_name) - if mock_operation.call_args is None: - raise AssertionError("API was not called") - (args, kwargs) = mock_operation.call_args - return kwargs - - def get_sent_message(self): - """Returns a parsed version of the send_raw_email RawMessage.Data param""" - - params = self.get_send_params( - operation_name="send_raw_email" - # (other operations don't have raw mime param) - ) - raw_mime = params["RawMessage"]["Data"] - parsed = AnymailInboundMessage.parse_raw_mime_bytes(raw_mime) - return parsed - - def assert_esp_not_called(self, msg=None, operation_name="send_raw_email"): - mock_operation = getattr(self.mock_client_instance, operation_name) - if mock_operation.called: - raise AssertionError(msg or "ESP API was called and shouldn't have been") - - -@tag("amazon_ses") -class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): - """Test backend support for Django standard email features""" - - def test_send_mail(self): - """Test basic API for simple send""" - mail.send_mail( - "Subject here", - "Here is the message.", - "from@example.com", - ["to@example.com"], - fail_silently=False, - ) - params = self.get_send_params() - # send_raw_email takes a fully-formatted MIME message. - # This is a simple (if inexact) way to check for expected headers and body: - raw_mime = params["RawMessage"]["Data"] - self.assertIsInstance(raw_mime, bytes) # SendRawEmail expects Data as bytes - self.assertIn(b"\nFrom: from@example.com\n", raw_mime) - self.assertIn(b"\nTo: to@example.com\n", raw_mime) - self.assertIn(b"\nSubject: Subject here\n", raw_mime) - self.assertIn(b"\n\nHere is the message", raw_mime) - # Destinations must include all recipients: - self.assertEqual(params["Destinations"], ["to@example.com"]) - - # Since the SES backend generates the MIME message using Django's - # EmailMessage.message().to_string(), there's not really a need - # to exhaustively test all the various standard email features. - # (EmailMessage.message() is well tested in the Django codebase.) - # Instead, just spot-check a few things... - - def test_destinations(self): - self.message.to = ["to1@example.com", '"Recipient, second" '] - self.message.cc = ["cc1@example.com", "Also cc "] - self.message.bcc = ["bcc1@example.com", "BCC 2 "] - self.message.send() - params = self.get_send_params() - self.assertEqual( - params["Destinations"], - [ - "to1@example.com", - '"Recipient, second" ', - "cc1@example.com", - "Also cc ", - "bcc1@example.com", - "BCC 2 ", - ], - ) - # Bcc's shouldn't appear in the message itself: - self.assertNotIn(b"bcc", params["RawMessage"]["Data"]) - - def test_non_ascii_headers(self): - self.message.subject = "Thử tin nhắn" # utf-8 in subject header - self.message.to = ['"Người nhận" '] # utf-8 in display name - self.message.cc = ["cc@thư.example.com"] # utf-8 in domain - self.message.send() - params = self.get_send_params() - raw_mime = params["RawMessage"]["Data"] - # Non-ASCII headers must use MIME encoded-word syntax: - self.assertIn(b"\nSubject: =?utf-8?b?VGjhu60gdGluIG5o4bqvbg==?=\n", raw_mime) - # Non-ASCII display names as well: - self.assertIn( - b"\nTo: =?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= \n", raw_mime - ) - # Non-ASCII address domains must use Punycode: - self.assertIn(b"\nCc: cc@xn--th-e0a.example.com\n", raw_mime) - # SES doesn't support non-ASCII in the username@ part - # (RFC 6531 "SMTPUTF8" extension) - - # Destinations must include all recipients: - self.assertEqual( - params["Destinations"], - [ - "=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= ", - "cc@xn--th-e0a.example.com", - ], - ) - - def test_attachments(self): - # These are \u2022 bullets ("\N{BULLET}") below: - text_content = "• Item one\n• Item two\n• Item three" - self.message.attach( - filename="Une pièce jointe.txt", # utf-8 chars in filename - content=text_content, - mimetype="text/plain", - ) - - # Should guess mimetype if not provided... - png_content = b"PNG\xb4 pretend this is the contents of a png file" - self.message.attach(filename="test.png", content=png_content) - - # Should work with a MIMEBase object (also tests no filename)... - pdf_content = b"PDF\xb4 pretend this is valid pdf params" - mimeattachment = MIMEApplication(pdf_content, "pdf") # application/pdf - mimeattachment["Content-Disposition"] = "attachment" - self.message.attach(mimeattachment) - - self.message.send() - sent_message = self.get_sent_message() - attachments = sent_message.attachments - self.assertEqual(len(attachments), 3) - - self.assertEqual(attachments[0].get_content_type(), "text/plain") - self.assertEqual(attachments[0].get_filename(), "Une pièce jointe.txt") - self.assertEqual(attachments[0].get_param("charset"), "utf-8") - self.assertEqual(attachments[0].get_content_text(), text_content) - - self.assertEqual(attachments[1].get_content_type(), "image/png") - # not inline: - self.assertEqual(attachments[1].get_content_disposition(), "attachment") - self.assertEqual(attachments[1].get_filename(), "test.png") - self.assertEqual(attachments[1].get_content_bytes(), png_content) - - self.assertEqual(attachments[2].get_content_type(), "application/pdf") - self.assertIsNone(attachments[2].get_filename()) # no filename specified - self.assertEqual(attachments[2].get_content_bytes(), pdf_content) - - def test_embedded_images(self): - image_filename = SAMPLE_IMAGE_FILENAME - image_path = sample_image_path(image_filename) - image_data = sample_image_content(image_filename) - - cid = attach_inline_image_file(self.message, image_path, domain="example.com") - html_content = ( - '

This has an inline image.

' % cid - ) - self.message.attach_alternative(html_content, "text/html") - - self.message.send() - sent_message = self.get_sent_message() - - self.assertEqual(sent_message.html, html_content) - - inlines = sent_message.content_id_map - self.assertEqual(len(inlines), 1) - self.assertEqual(inlines[cid].get_content_type(), "image/png") - self.assertEqual(inlines[cid].get_filename(), image_filename) - self.assertEqual(inlines[cid].get_content_bytes(), image_data) - - # Make sure neither the html nor the inline image is treated as an attachment: - params = self.get_send_params() - raw_mime = params["RawMessage"]["Data"] - self.assertNotIn(b"\nContent-Disposition: attachment", raw_mime) - - def test_multiple_html_alternatives(self): - # Multiple alternatives *are* allowed - self.message.attach_alternative("

First html is OK

", "text/html") - self.message.attach_alternative("

And so is second

", "text/html") - self.message.send() - params = self.get_send_params() - raw_mime = params["RawMessage"]["Data"] - # just check the alternative smade it into the message - # (assume that Django knows how to format them properly) - self.assertIn(b"\n\n

First html is OK

\n", raw_mime) - self.assertIn(b"\n\n

And so is second

\n", raw_mime) - - def test_alternative(self): - # Non-HTML alternatives *are* allowed - self.message.attach_alternative('{"is": "allowed"}', "application/json") - self.message.send() - params = self.get_send_params() - raw_mime = params["RawMessage"]["Data"] - # just check the alternative made it into the message - # (assume that Django knows how to format it properly) - self.assertIn(b"\nContent-Type: application/json\n", raw_mime) - - def test_multiple_from(self): - # Amazon allows multiple addresses in the From header, - # but must specify which is Source - self.message.from_email = "from1@example.com, from2@example.com" - self.message.send() - params = self.get_send_params() - raw_mime = params["RawMessage"]["Data"] - self.assertIn(b"\nFrom: from1@example.com, from2@example.com\n", raw_mime) - self.assertEqual(params["Source"], "from1@example.com") - - def test_commas_in_subject(self): - """ - There used to be a Python email header bug that added unwanted spaces - after commas in long subjects - """ - self.message.subject = ( - "100,000,000 isn't a number you'd really want" - " to break up in this email subject, right?" - ) - self.message.send() - sent_message = self.get_sent_message() - self.assertEqual(sent_message["Subject"], self.message.subject) - - def test_body_avoids_cte_8bit(self): - """Anymail works around an Amazon SES bug that can corrupt non-ASCII bodies.""" - # (see detailed comments in the backend code) - self.message.body = "Это text body" - self.message.attach_alternative("

Это html body

", "text/html") - self.message.send() - sent_message = self.get_sent_message() - - # Make sure none of the text parts use `Content-Transfer-Encoding: 8bit`. - # (Technically, either quoted-printable or base64 would be OK, but base64 text - # parts have a reputation for triggering spam filters, so just require - # quoted-printable.) - text_part_encodings = [ - (part.get_content_type(), part["Content-Transfer-Encoding"]) - for part in sent_message.walk() - if part.get_content_maintype() == "text" - ] - self.assertEqual( - text_part_encodings, - [ - ("text/plain", "quoted-printable"), - ("text/html", "quoted-printable"), - ], - ) - - def test_api_failure(self): - error_response = { - "Error": { - "Type": "Sender", - "Code": "MessageRejected", - "Message": "Email address is not verified. The following identities" - " failed the check in region US-EAST-1: to@example.com", - }, - "ResponseMetadata": { - "RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb", - "HTTPStatusCode": 400, - "HTTPHeaders": { - "x-amzn-requestid": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb", - "content-type": "text/xml", - "content-length": "277", - "date": "Sat, 17 Mar 2018 04:44:44 GMT", - }, - "RetryAttempts": 0, - }, - } - - self.set_mock_failure(error_response) - with self.assertRaises(AnymailAPIError) as cm: - self.message.send() - err = cm.exception - # AWS error is included in Anymail message: - self.assertIn( - "Email address is not verified. The following identities failed " - "the check in region US-EAST-1: to@example.com", - str(err), - ) - # Raw AWS response is available on the exception: - self.assertEqual(err.response, error_response) - - def test_api_failure_fail_silently(self): - # Make sure fail_silently is respected - self.set_mock_failure( - { - "Error": { - "Type": "Sender", - "Code": "InvalidParameterValue", - "Message": "That is not allowed", - } - } - ) - 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 - # (for the SMTP backend); spot check some likely cases just to be sure... - with self.assertRaises(BadHeaderError): - mail.send_mail( - "Subject\r\ninjected", "Body", "from@example.com", ["to@example.com"] - ) - with self.assertRaises(BadHeaderError): - mail.send_mail( - "Subject", - "Body", - '"Display-Name\nInjected" ', - ["to@example.com"], - ) - with self.assertRaises(BadHeaderError): - mail.send_mail( - "Subject", - "Body", - "from@example.com", - ['"Display-Name\rInjected" '], - ) - with self.assertRaises(BadHeaderError): - mail.EmailMessage( - "Subject", - "Body", - "from@example.com", - ["to@example.com"], - headers={"X-Header": "custom header value\r\ninjected"}, - ).send() - - -@tag("amazon_ses") -class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): - """Test backend support for Anymail added features""" - - def test_envelope_sender(self): - self.message.envelope_sender = "bounce-handler@bounces.example.com" - self.message.send() - params = self.get_send_params() - self.assertEqual(params["Source"], "bounce-handler@bounces.example.com") - - def test_spoofed_to(self): - # Amazon SES is one of the few ESPs that actually permits the To header - # to differ from the envelope recipient... - self.message.to = ["Envelope "] - self.message.extra_headers["To"] = "Spoofed " - self.message.send() - params = self.get_send_params() - raw_mime = params["RawMessage"]["Data"] - self.assertEqual(params["Destinations"], ["Envelope "]) - self.assertIn(b"\nTo: Spoofed \n", raw_mime) - self.assertNotIn(b"envelope-to@example.com", raw_mime) - - def test_metadata(self): - # (that \n is a header-injection test) - self.message.metadata = { - "User ID": 12345, - "items": "Correct horse,Battery,\nStaple", - "Cart-Total": "22.70", - } - self.message.send() - - # Metadata is passed as JSON in a message header field: - sent_message = self.get_sent_message() - self.assertJSONEqual( - sent_message["X-Metadata"], - '{"User ID": 12345,' - ' "items": "Correct horse,Battery,\\nStaple",' - ' "Cart-Total": "22.70"}', - ) - - def test_send_at(self): - # Amazon SES does not support delayed sending - self.message.send_at = datetime(2016, 3, 4, 5, 6, 7) - with self.assertRaisesMessage(AnymailUnsupportedFeature, "send_at"): - self.message.send() - - def test_tags(self): - self.message.tags = ["Transactional", "Cohort 12/2017"] - self.message.send() - - # Tags are added as multiple X-Tag message headers: - sent_message = self.get_sent_message() - self.assertCountEqual( - sent_message.get_all("X-Tag"), ["Transactional", "Cohort 12/2017"] - ) - - # Tags are *not* by default used as Amazon SES "Message Tags": - params = self.get_send_params() - self.assertNotIn("Tags", params) - - @override_settings(ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign") - def test_amazon_message_tags(self): - """ - The Anymail AMAZON_SES_MESSAGE_TAG_NAME setting enables a single Message Tag - """ - self.message.tags = ["Welcome"] - self.message.send() - params = self.get_send_params() - self.assertEqual(params["Tags"], [{"Name": "Campaign", "Value": "Welcome"}]) - - # Multiple Anymail tags are not supported when using this feature - self.message.tags = ["Welcome", "Variation_A"] - with self.assertRaisesMessage( - AnymailUnsupportedFeature, - "multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting", - ): - self.message.send() - - def test_tracking(self): - # Amazon SES doesn't support overriding click/open-tracking settings - # on individual messages through any standard API params. - # (You _can_ use a ConfigurationSet to control this; see esp_extra below.) - self.message.track_clicks = True - with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_clicks"): - self.message.send() - delattr(self.message, "track_clicks") - - self.message.track_opens = True - with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"): - self.message.send() - - def test_merge_data(self): - # Amazon SES only supports merging when using templates (see below) - self.message.merge_data = {} - with self.assertRaisesMessage( - AnymailUnsupportedFeature, "merge_data without template_id" - ): - self.message.send() - delattr(self.message, "merge_data") - - self.message.merge_global_data = {"group": "Users", "site": "ExampleCo"} - with self.assertRaisesMessage( - AnymailUnsupportedFeature, "global_merge_data without template_id" - ): - self.message.send() - - @override_settings( - # only way to use tags with template_id: - ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign" - ) - def test_template(self): - """With template_id, Anymail switches to SES SendBulkTemplatedEmail""" - # SendBulkTemplatedEmail uses a completely different API call and payload - # structure, so this re-tests a bunch of Anymail features that were handled - # differently above. (See test_amazon_ses_integration for a more realistic - # template example.) - raw_response = { - "Status": [ - { - "Status": "Success", - "MessageId": "1111111111111111-bbbbbbbb-3333-7777", - }, - {"Status": "AccountThrottled"}, - ], - "ResponseMetadata": self.DEFAULT_SEND_RESPONSE["ResponseMetadata"], - } - self.set_mock_response(raw_response, operation_name="send_bulk_templated_email") - message = AnymailMessage( - template_id="welcome_template", - from_email='"Example, Inc." ', - to=["alice@example.com", "罗伯特 "], - cc=["cc@example.com"], - reply_to=["reply1@example.com", "Reply 2 "], - merge_data={ - "alice@example.com": {"name": "Alice", "group": "Developers"}, - "bob@example.com": {"name": "Bob"}, # and leave group undefined - "nobody@example.com": {"name": "Not a recipient for this message"}, - }, - merge_global_data={"group": "Users", "site": "ExampleCo"}, - # (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template): - tags=["WelcomeVariantA"], - envelope_sender="bounces@example.com", - esp_extra={ - "SourceArn": "arn:aws:ses:us-east-1:123456789012:identity/example.com" - }, - ) - message.send() - - # templates use a different API call... - self.assert_esp_not_called(operation_name="send_raw_email") - params = self.get_send_params(operation_name="send_bulk_templated_email") - self.assertEqual(params["Template"], "welcome_template") - self.assertEqual(params["Source"], '"Example, Inc." ') - destinations = params["Destinations"] - self.assertEqual(len(destinations), 2) - self.assertEqual( - destinations[0]["Destination"], - {"ToAddresses": ["alice@example.com"], "CcAddresses": ["cc@example.com"]}, - ) - self.assertEqual( - json.loads(destinations[0]["ReplacementTemplateData"]), - {"name": "Alice", "group": "Developers"}, - ) - self.assertEqual( - destinations[1]["Destination"], - { - # SES requires RFC2047: - "ToAddresses": ["=?utf-8?b?572X5Lyv54m5?= "], - "CcAddresses": ["cc@example.com"], - }, - ) - self.assertEqual( - json.loads(destinations[1]["ReplacementTemplateData"]), {"name": "Bob"} - ) - self.assertEqual( - json.loads(params["DefaultTemplateData"]), - {"group": "Users", "site": "ExampleCo"}, - ) - self.assertEqual( - params["ReplyToAddresses"], - ["reply1@example.com", "Reply 2 "], - ) - self.assertEqual( - params["DefaultTags"], [{"Name": "Campaign", "Value": "WelcomeVariantA"}] - ) - self.assertEqual(params["ReturnPath"], "bounces@example.com") - self.assertEqual( - params["SourceArn"], - "arn:aws:ses:us-east-1:123456789012:identity/example.com", # esp_extra - ) - - self.assertEqual(message.anymail_status.status, {"queued", "failed"}) - self.assertEqual( - # different for each recipient - message.anymail_status.message_id, - {"1111111111111111-bbbbbbbb-3333-7777", None}, - ) - self.assertEqual( - message.anymail_status.recipients["alice@example.com"].status, "queued" - ) - self.assertEqual( - message.anymail_status.recipients["bob@example.com"].status, "failed" - ) - self.assertEqual( - message.anymail_status.recipients["alice@example.com"].message_id, - "1111111111111111-bbbbbbbb-3333-7777", - ) - self.assertIsNone( - message.anymail_status.recipients["bob@example.com"].message_id - ) - self.assertEqual(message.anymail_status.esp_response, raw_response) - - def test_template_unsupported(self): - """A lot of options are not compatible with SendBulkTemplatedEmail""" - message = AnymailMessage(template_id="welcome_template", to=["to@example.com"]) - - message.subject = "nope, can't change template subject" - with self.assertRaisesMessage( - AnymailUnsupportedFeature, "overriding template subject" - ): - message.send() - message.subject = None - - message.body = "nope, can't change text body" - with self.assertRaisesMessage( - AnymailUnsupportedFeature, "overriding template body content" - ): - message.send() - message.content_subtype = "html" - with self.assertRaisesMessage( - AnymailUnsupportedFeature, "overriding template body content" - ): - message.send() - message.body = None - - message.attach("attachment.txt", "this is an attachment", "text/plain") - with self.assertRaisesMessage( - AnymailUnsupportedFeature, "attachments with template" - ): - message.send() - message.attachments = [] - - message.extra_headers = {"X-Custom": "header"} - with self.assertRaisesMessage( - AnymailUnsupportedFeature, "extra_headers with template" - ): - message.send() - message.extra_headers = {} - - message.metadata = {"meta": "data"} - with self.assertRaisesMessage( - AnymailUnsupportedFeature, "metadata with template" - ): - message.send() - message.metadata = None - - message.tags = ["tag 1", "tag 2"] - with self.assertRaisesMessage(AnymailUnsupportedFeature, "tags with template"): - message.send() - message.tags = None - - def test_send_anymail_message_without_template(self): - # Make sure SendRawEmail is used for non-template_id messages - message = AnymailMessage( - from_email="from@example.com", to=["to@example.com"], subject="subject" - ) - message.send() - self.assert_esp_not_called(operation_name="send_bulk_templated_email") - # fails if send_raw_email not called: - self.get_send_params(operation_name="send_raw_email") - - def test_default_omits_options(self): - """Make sure by default we don't send any ESP-specific options. - - Options not specified by the caller should be omitted entirely from - the API call (*not* sent as False or empty). This ensures - that your ESP account settings apply by default. - """ - self.message.send() - params = self.get_send_params() - self.assertNotIn("ConfigurationSetName", params) - self.assertNotIn("DefaultTags", params) - self.assertNotIn("DefaultTemplateData", params) - self.assertNotIn("FromArn", params) - self.assertNotIn("Message", params) - self.assertNotIn("ReplyToAddresses", params) - self.assertNotIn("ReturnPath", params) - self.assertNotIn("ReturnPathArn", params) - self.assertNotIn("Source", params) - self.assertNotIn("SourceArn", params) - self.assertNotIn("Tags", params) - self.assertNotIn("Template", params) - self.assertNotIn("TemplateArn", params) - self.assertNotIn("TemplateData", params) - - sent_message = self.get_sent_message() - # custom headers not added if not needed: - self.assertNotIn("X-Metadata", sent_message) - self.assertNotIn("X-Tag", sent_message) - - def test_esp_extra(self): - # Values in esp_extra are merged into the Amazon SES SendRawEmail parameters - self.message.esp_extra = { - # E.g., if you've set up a configuration set - # that disables open/click tracking: - "ConfigurationSetName": "NoTrackingConfigurationSet", - } - self.message.send() - params = self.get_send_params() - self.assertEqual(params["ConfigurationSetName"], "NoTrackingConfigurationSet") - - def test_send_attaches_anymail_status(self): - """The anymail_status should be attached to the message when it is sent""" - msg = mail.EmailMessage( - "Subject", - "Message", - "from@example.com", - ["to1@example.com"], - ) - sent = msg.send() - self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {"queued"}) - self.assertEqual( - msg.anymail_status.message_id, - "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000", - ) - self.assertEqual( - msg.anymail_status.recipients["to1@example.com"].status, "queued" - ) - self.assertEqual( - msg.anymail_status.recipients["to1@example.com"].message_id, - "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000", - ) - self.assertEqual(msg.anymail_status.esp_response, self.DEFAULT_SEND_RESPONSE) - - # Amazon SES doesn't report rejected addresses at send time in a form that can be - # distinguished from other API errors. If SES rejects *any* recipient you'll get - # an AnymailAPIError, and the message won't be sent to *all* recipients. - - # noinspection PyUnresolvedReferences - def test_send_unparsable_response(self): - """ - If the send succeeds, but result is unexpected format, - should raise an API exception - """ - response_content = {"wrong": "format"} - self.set_mock_response(response_content) - with self.assertRaisesMessage( - AnymailAPIError, "parsing Amazon SES send result" - ): - self.message.send() - self.assertIsNone(self.message.anymail_status.status) - self.assertIsNone(self.message.anymail_status.message_id) - self.assertEqual(self.message.anymail_status.recipients, {}) - self.assertEqual(self.message.anymail_status.esp_response, response_content) - - -@tag("amazon_ses") -class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase): - """Test configuration options""" - - def test_deprecation_warning(self): - with self.assertWarnsMessage( - AnymailDeprecationWarning, - "anymail.backends.amazon_sesv1.EmailBackend is deprecated", - ): - self.message.send() - - def test_boto_default_config(self): - """By default, boto3 gets credentials from the environment or its config files - - See http://boto3.readthedocs.io/en/stable/guide/configuration.html - """ - self.message.send() - - session_params = self.get_session_params() - # no additional params passed to boto3.session.Session(): - self.assertEqual(session_params, {}) - - client_params = self.get_client_params() - # Ignore botocore.config.Config, which doesn't support == - config = client_params.pop("config") - # no additional params passed to session.client('ses'): - self.assertEqual(client_params, {}) - self.assertIn( - f"django-anymail/{ANYMAIL_VERSION}-amazon-ses", - config.user_agent_extra, - ) - - @override_settings( - ANYMAIL={ - "AMAZON_SES_CLIENT_PARAMS": { - # Example for testing; it's not a good idea to hardcode credentials in - # your code. Safer: `os.getenv("MY_SPECIAL_AWS_KEY_ID")` etc. - "aws_access_key_id": "test-access-key-id", - "aws_secret_access_key": "test-secret-access-key", - "region_name": "ap-northeast-1", - # config can be given as dict of botocore.config.Config params - "config": { - "read_timeout": 30, - "retries": {"max_attempts": 2}, - }, - } - } - ) - def test_client_params_in_setting(self): - """ - The Anymail AMAZON_SES_CLIENT_PARAMS setting specifies - boto3 session.client() params for Anymail - """ - self.message.send() - client_params = self.get_client_params() - # Ignore botocore.config.Config, which doesn't support == - config = client_params.pop("config") - self.assertEqual( - client_params, - { - "aws_access_key_id": "test-access-key-id", - "aws_secret_access_key": "test-secret-access-key", - "region_name": "ap-northeast-1", - }, - ) - self.assertEqual(config.read_timeout, 30) - self.assertEqual(config.retries, {"max_attempts": 2}) - - def test_client_params_in_connection_init(self): - """ - You can also supply credentials specifically - for a particular EmailBackend connection instance - """ - from botocore.config import Config - - boto_config = Config(connect_timeout=30) - conn = mail.get_connection( - "anymail.backends.amazon_sesv1.EmailBackend", - client_params={ - "aws_session_token": "test-session-token", - "config": boto_config, - }, - ) - conn.send_messages([self.message]) - - client_params = self.get_client_params() - # Ignore botocore.config.Config, which doesn't support == - config = client_params.pop("config") - self.assertEqual(client_params, {"aws_session_token": "test-session-token"}) - self.assertEqual(config.connect_timeout, 30) - - @override_settings( - ANYMAIL={"AMAZON_SES_SESSION_PARAMS": {"profile_name": "anymail-testing"}} - ) - def test_session_params_in_setting(self): - """ - The Anymail AMAZON_SES_SESSION_PARAMS setting - specifies boto3.session.Session() params for Anymail - """ - self.message.send() - - session_params = self.get_session_params() - self.assertEqual(session_params, {"profile_name": "anymail-testing"}) - - client_params = self.get_client_params() - # Ignore botocore.config.Config, which doesn't support == - client_params.pop("config") - # no additional params passed to session.client('ses'): - self.assertEqual(client_params, {}) - - @override_settings( - ANYMAIL={"AMAZON_SES_CONFIGURATION_SET_NAME": "MyConfigurationSet"} - ) - def test_config_set_setting(self): - """You can supply a default ConfigurationSetName""" - self.message.send() - params = self.get_send_params() - self.assertEqual(params["ConfigurationSetName"], "MyConfigurationSet") - - # override on individual message using esp_extra - self.message.esp_extra = {"ConfigurationSetName": "CustomConfigurationSet"} - self.message.send() - params = self.get_send_params() - self.assertEqual(params["ConfigurationSetName"], "CustomConfigurationSet") diff --git a/tests/test_amazon_ses_integrationv1.py b/tests/test_amazon_ses_integrationv1.py deleted file mode 100644 index c9966eee..00000000 --- a/tests/test_amazon_ses_integrationv1.py +++ /dev/null @@ -1,207 +0,0 @@ -import os -import unittest -import warnings -from email.utils import formataddr - -from django.test import SimpleTestCase, override_settings, tag - -from anymail.exceptions import AnymailAPIError -from anymail.message import AnymailMessage - -from .utils import AnymailTestMixin, sample_image_path - -ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID = os.getenv( - "ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID" -) -ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY = os.getenv( - "ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY" -) -ANYMAIL_TEST_AMAZON_SES_REGION_NAME = os.getenv( - "ANYMAIL_TEST_AMAZON_SES_REGION_NAME", "us-east-1" -) -ANYMAIL_TEST_AMAZON_SES_DOMAIN = os.getenv("ANYMAIL_TEST_AMAZON_SES_DOMAIN") - - -@unittest.skipUnless( - ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID - and ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY - and ANYMAIL_TEST_AMAZON_SES_DOMAIN, - "Set ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID and" - " ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY and ANYMAIL_TEST_AMAZON_SES_DOMAIN" - " environment variables to run Amazon SES integration tests", -) -@override_settings( - EMAIL_BACKEND="anymail.backends.amazon_sesv1.EmailBackend", - ANYMAIL={ - "AMAZON_SES_CLIENT_PARAMS": { - # This setting provides Anymail-specific AWS credentials to boto3.client(), - # overriding any credentials in the environment or boto config. It's often - # *not* the best approach. See the Anymail and boto3 docs for other options. - "aws_access_key_id": ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID, - "aws_secret_access_key": ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY, - "region_name": ANYMAIL_TEST_AMAZON_SES_REGION_NAME, - # Can supply any other boto3.client params, - # including botocore.config.Config as dict - "config": {"retries": {"max_attempts": 2}}, - }, - # actual config set in Anymail test account: - "AMAZON_SES_CONFIGURATION_SET_NAME": "TestConfigurationSet", - }, -) -@tag("amazon_ses", "live") -class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): - """Amazon SES API integration tests - - These tests run against the **live** Amazon SES API, using the environment - variables `ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID` and - `ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY` as AWS credentials. - If those variables are not set, these tests won't run. - - (You can also set the environment variable `ANYMAIL_TEST_AMAZON_SES_REGION_NAME` - to test SES using a region other than the default "us-east-1".) - - Amazon SES doesn't offer a test mode -- it tries to send everything you ask. - To avoid stacking up a pile of undeliverable @example.com - emails, the tests use Amazon's @simulator.amazonses.com addresses. - https://docs.aws.amazon.com/ses/latest/DeveloperGuide/mailbox-simulator.html - """ - - def setUp(self): - super().setUp() - self.from_email = "test@%s" % ANYMAIL_TEST_AMAZON_SES_DOMAIN - self.message = AnymailMessage( - "Anymail Amazon SES integration test", - "Text content", - self.from_email, - ["success@simulator.amazonses.com"], - ) - self.message.attach_alternative("

HTML content

", "text/html") - - # boto3 relies on GC to close connections. Python 3 warns about unclosed - # ssl.SSLSocket during cleanup. We don't care. (It may be a false positive, - # or it may be a botocore problem, but it's not *our* problem.) - # https://github.com/boto/boto3/issues/454#issuecomment-586033745 - # Filter in TestCase.setUp because unittest resets the warning filters - # for each test. https://stackoverflow.com/a/26620811/647002 - warnings.filterwarnings( - "ignore", message=r"unclosed ", - ], - cc=[ - "success+cc1@simulator.amazonses.com", - "Copy 2 ", - ], - bcc=[ - "success+bcc1@simulator.amazonses.com", - "Blind Copy 2 ", - ], - reply_to=["reply1@example.com", "Reply 2 "], - headers={"X-Anymail-Test": "value"}, - metadata={"meta1": "simple_string", "meta2": 2}, - tags=["Re-engagement", "Cohort 12/2017"], - ) - message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") - message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") - cid = message.attach_inline_image_file(sample_image_path()) - message.attach_alternative( - "

HTML: with link" - "and image: " % cid, - "text/html", - ) - - message.attach_alternative( - "Amazon SES SendRawEmail actually supports multiple alternative parts", - "text/x-note-for-email-geeks", - ) - - message.send() - self.assertEqual(message.anymail_status.status, {"queued"}) - - def test_stored_template(self): - # Using a template created like this: - # boto3.client('ses').create_template(Template={ - # "TemplateName": "TestTemplate", - # "SubjectPart": "Your order {{order}} shipped", - # "HtmlPart": "

Dear {{name}}:

" - # "

Your order {{order}} shipped {{ship_date}}.

", - # "TextPart": "Dear {{name}}:\r\n" - # "Your order {{order}} shipped {{ship_date}}." - # }) - message = AnymailMessage( - template_id="TestTemplate", - from_email=formataddr(("Test From", self.from_email)), - to=[ - "First Recipient ", - "success+to2@simulator.amazonses.com", - ], - merge_data={ - "success+to1@simulator.amazonses.com": { - "order": 12345, - "name": "Test Recipient", - }, - "success+to2@simulator.amazonses.com": {"order": 6789}, - }, - merge_global_data={"name": "Customer", "ship_date": "today"}, # default - ) - message.send() - recipient_status = message.anymail_status.recipients - self.assertEqual( - recipient_status["success+to1@simulator.amazonses.com"].status, "queued" - ) - self.assertRegex( - recipient_status["success+to1@simulator.amazonses.com"].message_id, - r"[0-9a-f-]+", - ) - self.assertEqual( - recipient_status["success+to2@simulator.amazonses.com"].status, "queued" - ) - self.assertRegex( - recipient_status["success+to2@simulator.amazonses.com"].message_id, - r"[0-9a-f-]+", - ) - - @override_settings( - ANYMAIL={ - "AMAZON_SES_CLIENT_PARAMS": { - "aws_access_key_id": "test-invalid-access-key-id", - "aws_secret_access_key": "test-invalid-secret-access-key", - "region_name": ANYMAIL_TEST_AMAZON_SES_REGION_NAME, - } - } - ) - def test_invalid_aws_credentials(self): - # Make sure the exception message includes AWS's response: - with self.assertRaisesMessage( - AnymailAPIError, "The security token included in the request is invalid" - ): - self.message.send()