Amazon SES: implement SES v2 API backend

* Add `anymail.backends.amazon_sesv2.EmailBackend` using SES v2 API
* Declare current SES v1 backend as "deprecated"

Closes #274
This commit is contained in:
Mike Edmunds
2023-02-26 14:16:46 -08:00
committed by GitHub
parent 67b19678d2
commit ea446a94d3
5 changed files with 1841 additions and 29 deletions

View File

@@ -30,6 +30,14 @@ vNext
*Unreleased changes* *Unreleased changes*
Deprecations
~~~~~~~~~~~~
* **Amazon SES:** Anymail is switching to the Amazon SES v2 API for sending mail.
Support for the original SES v1 API is now deprecated, and will be dropped in a
future Anymail release (likely in late 2023). Many projects will not
require code changes, but you will need to update your IAM permissions. See
`Migrating to the SES v2 API <https://anymail.dev/en/latest/esps/amazon_ses/#amazon-ses-v2>`__.
Other Other
~~~~~ ~~~~~
* Test against Django 4.2 prerelease, Python 3.11 (with Django 4.2), * Test against Django 4.2 prerelease, Python 3.11 (with Django 4.2),

View File

@@ -0,0 +1,520 @@
import email.charset
import email.encoders
import email.policy
from .._version import __version__
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
from ..message import AnymailRecipientStatus
from ..utils import UNSET, get_anymail_setting
from .base import AnymailBaseBackend, BasePayload
try:
import boto3
from botocore.client import Config
from botocore.exceptions import BotoCoreError, ClientError, ConnectionError
except ImportError as err:
raise AnymailImproperlyInstalled(
missing_package="boto3", backend="amazon_sesv2"
) 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 v2 Email Backend (using boto3)
"""
# Deliberately no "v2" in the esp_name: we don't want to force client changes
# when the v1 backend is retired and this one replace it as just "amazon_ses".
esp_name = "Amazon SES"
def __init__(self, **kwargs):
"""Init options from Django settings"""
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(
esp_name=self.esp_name, 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(
"sesv2", **self.client_params
)
except BOTO_BASE_ERRORS:
if not self.fail_silently:
raise
else:
return True # created client
def close(self):
if self.client is None:
return
self.client.close()
self.client = None
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
# (even though SESv2 SendEmail has a template option).
return AmazonSESV2SendBulkEmailPayload(message, defaults, self)
else:
return AmazonSESV2SendEmailPayload(message, defaults, self)
def post_to_esp(self, payload, message):
payload.finalize_payload()
try:
client_send_api = getattr(self.client, payload.api_name)
except AttributeError:
raise NotImplementedError(
f"boto3 sesv2 client does not have method {payload.api_name!r}."
f" Check {payload.__class__.__name__}.api_name."
) from None
try:
response = client_send_api(**payload.params)
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):
#: Name of the boto3 SES/SESv2 client method to call
api_name = "SUBCLASS_MUST_OVERRIDE"
def init_payload(self):
self.params = {}
if self.backend.configuration_set_name is not None:
self.params["ConfigurationSetName"] = self.backend.configuration_set_name
def finalize_payload(self):
pass
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, FromEmailAddressIdentityArn,
# FeedbackForwardingEmailAddress, ListManagementOptions
self.params.update(extra)
class AmazonSESV2SendEmailPayload(AmazonSESBasePayload):
api_name = "send_email"
def init_payload(self):
super().init_payload()
self.all_recipients = [] # for parse_recipient_status
self.mime_message = self.message.message()
def finalize_payload(self):
# (The boto3 SES client handles base64 encoding raw_message.)
raw_message = self.generate_raw_message()
self.params["Content"] = {"Raw": {"Data": raw_message}}
def generate_raw_message(self):
"""
Serialize self.mime_message as an RFC-5322/-2045 MIME message,
encoded as 7bit-clean, us-ascii byte data.
"""
# Amazon SES does not support `Content-Transfer-Encoding: 8bit`. And using 8bit
# with SES open or click tracking results in mis-encoded characters. To avoid
# this, convert any 8bit parts to 7bit quoted printable or base64. (We own
# self.mime_message, so destructively modifying it should be OK.)
# (You might think cte_type="7bit" in the email.policy below would cover this,
# but it seems that cte_type is only examined as the MIME parts are constructed,
# not when an email.generator serializes them.)
for part in self.mime_message.walk():
if part["Content-Transfer-Encoding"] == "8bit":
del part["Content-Transfer-Encoding"]
if part.get_content_maintype() == "text":
# (Avoid base64 for text parts, which can trigger spam filters)
email.encoders.encode_quopri(part)
else:
email.encoders.encode_base64(part)
self.mime_message.policy = email.policy.default.clone(cte_type="7bit")
return self.mime_message.as_bytes()
def parse_recipient_status(self, response):
try:
message_id = response["MessageId"]
except (KeyError, TypeError) as err:
raise AnymailAPIError(
f"{err!s} parsing Amazon SES send result {response!r}",
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(
f"Anymail send defaults for '{attr}' with Amazon SES"
)
def set_from_email(self, email):
# If params["FromEmailAddress"] is not provided, SES will parse it from the raw
# mime_message headers. (And setting it replaces any From header. Note that
# v2 SendEmail doesn't have an equivalent to v1 SendRawEmail's Sender param.)
self._no_send_defaults("from_email")
def set_recipients(self, recipient_type, emails):
# Although Amazon SES can parse the 'to' and 'cc' recipients from the raw
# mime_message headers, providing them in the Destination param makes it
# explicit (and is required for 'bcc' and for spoofed 'to').
self.all_recipients += emails # save for parse_recipient_status
self._no_send_defaults(recipient_type)
if emails:
# params["Destination"] = {"ToAddresses": [...], "CcAddresses": etc.}
# (Unlike most SendEmail params, these _don't_ replace the corresponding
# raw mime_message headers.)
assert recipient_type in ("to", "cc", "bcc")
destination_key = f"{recipient_type.capitalize()}Addresses"
self.params.setdefault("Destination", {})[destination_key] = [
email.address for email in emails
]
def set_subject(self, subject):
# included in mime_message
self._no_send_defaults("subject")
def set_reply_to(self, emails):
# included in mime_message
# (and setting params["ReplyToAddresses"] replaces any Reply-To header)
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):
# Amazon SES will generate a unique mailfrom, and then forward any delivery
# problem reports that address receives to the address specified here:
self.params["FeedbackForwardingEmailAddress"] = 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 params["Destination"]["ToAddresses"].
#
# 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("EmailTags", []).append(
{"Name": self.backend.message_tag_name, "Value": tags[0]}
)
def set_template_id(self, template_id):
raise NotImplementedError(
f"{self.__class__.__name__} 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 AmazonSESV2SendBulkEmailPayload(AmazonSESBasePayload):
api_name = "send_bulk_email"
def init_payload(self):
super().init_payload()
# late-bind recipients and merge_data in finalize_payload
self.recipients = {"to": [], "cc": [], "bcc": []}
self.merge_data = {}
def finalize_payload(self):
# Build BulkEmailEntries from recipients and merge_data.
# Any cc and bcc recipients should be included in every entry:
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"]
]
# Construct an entry with merge data for each "to" recipient:
self.params["BulkEmailEntries"] = [
{
"Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses),
"ReplacementEmailContent": {
"ReplacementTemplate": {
"ReplacementTemplateData": self.serialize_json(
self.merge_data.get(to.addr_spec, {})
),
}
},
}
for to in self.recipients["to"]
]
def parse_recipient_status(self, response):
try:
results = response["BulkEmailEntryResults"]
ses_status_set = set(result["Status"] for result in results)
anymail_statuses = [
AnymailRecipientStatus(
message_id=result.get("MessageId", None),
status="queued" if result["Status"] == "SUCCESS" else "failed",
)
for result in results
]
except (KeyError, TypeError) as err:
raise AnymailAPIError(
f"{err!s} parsing Amazon SES send result {response!r}",
backend=self.backend,
email_message=self.message,
payload=self,
) from None
# If all BulkEmailEntryResults[].Status are the same non-success status,
# raise an APIError to expose the error message/reason (matching behavior
# of non-template SendEmail call).
if len(ses_status_set) == 1 and ses_status_set != {"SUCCESS"}:
raise AnymailAPIError(
# use Error text if available, else the Status enum, from first result
results[0].get("Error", results[0]["Status"]),
backend=self.backend,
email_message=self.message,
payload=self,
response=response,
)
# Otherwise, return per-recipient status (just "queued" or "failed") for
# all-success, mixed success/error, or all-error mixed-reason cases.
# The BulkEmailEntryResults are in the same order as the Destination param
# (which is in the same order as recipients["to"]).
to_addrs = [to.addr_spec for to in self.recipients["to"]]
if len(anymail_statuses) != len(to_addrs):
raise AnymailAPIError(
f"Sent to {len(to_addrs)} destinations,"
f" but only {len(anymail_statuses)} statuses"
f" in Amazon SES send result {response!r}",
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["FromEmailAddress"] = email.address
def set_recipients(self, recipient_type, emails):
# late-bound in finalize_payload
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):
# Amazon SES will generate a unique mailfrom, and then forward any delivery
# problem reports that address receives to the address specified here:
self.params["FeedbackForwardingEmailAddress"] = email.addr_spec
def set_metadata(self, metadata):
# no custom headers with SendBulkEmail
self.unsupported_feature("metadata with template")
def set_tags(self, tags):
# no custom headers with SendBulkEmail, but support
# AMAZON_SES_MESSAGE_TAG_NAME if used (see tags/metadata in
# AmazonSESV2SendEmailPayload 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["DefaultEmailTags"] = [
{"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):
# DefaultContent.Template.TemplateName
self.params.setdefault("DefaultContent", {}).setdefault("Template", {})[
"TemplateName"
] = template_id
def set_merge_data(self, merge_data):
# late-bound in finalize_payload
self.merge_data = merge_data
def set_merge_global_data(self, merge_global_data):
# DefaultContent.Template.TemplateData
self.params.setdefault("DefaultContent", {}).setdefault("Template", {})[
"TemplateData"
] = self.serialize_json(merge_global_data)
def _get_anymail_boto3_params(esp_name=EmailBackend.esp_name, kwargs=None):
"""Returns 2 dicts of params for boto3.session.Session() and .client()
Incorporates ANYMAIL["AMAZON_SES_SESSION_PARAMS"] and
ANYMAIL["AMAZON_SES_CLIENT_PARAMS"] settings.
Converts config dict to botocore.client.Config if needed
May remove keys from kwargs, but won't modify original settings
"""
# (shared with ..webhooks.amazon_ses)
session_params = get_anymail_setting(
"session_params", esp_name=esp_name, kwargs=kwargs, default={}
)
client_params = get_anymail_setting(
"client_params", esp_name=esp_name, kwargs=kwargs, default={}
)
# Add Anymail user-agent, and convert config dict to botocore.client.Config
client_params = client_params.copy() # don't modify source
config = Config(
user_agent_extra="django-anymail/{version}-{esp}".format(
esp=esp_name.lower().replace(" ", "-"), version=__version__
)
)
if "config" in client_params:
# convert config dict to botocore.client.Config if needed
client_params_config = client_params["config"]
if not isinstance(client_params_config, Config):
client_params_config = Config(**client_params_config)
config = config.merge(client_params_config)
client_params["config"] = config
return session_params, client_params

View File

@@ -3,8 +3,21 @@
Amazon SES Amazon SES
========== ==========
Anymail integrates with `Amazon Simple Email Service`_ (SES) using the `Boto 3`_ Anymail integrates with the `Amazon Simple Email Service`_ (SES) using the `Boto 3`_
AWS SDK for Python, and includes sending, tracking, and inbound receiving capabilities. AWS SDK for Python, and supports sending, tracking, and inbound receiving capabilities.
.. versionchanged:: 9.1
.. note::
AWS has two versions of the SES API available for sending email. Anymail 9.0
and earlier used the SES v1 API. Anymail 9.1 supports both SES v1 and v2, but
support for the v1 API is now deprecated and will be removed in a future Anymail
release (likely in late 2023).
For new projects, you should use the SES v2 API. For existing projects that are
using the SES v1 API, see :ref:`amazon-ses-v2` below.
.. sidebar:: Alternatives .. sidebar:: Alternatives
@@ -21,21 +34,23 @@ Installation
------------ ------------
You must ensure the :pypi:`boto3` package is installed to use Anymail's Amazon SES You must ensure the :pypi:`boto3` package is installed to use Anymail's Amazon SES
backend. Either include the "amazon_ses" option when you install Anymail: backend. Either include the ``amazon_ses`` option when you install Anymail:
.. code-block:: console .. code-block:: console
$ pip install "django-anymail[amazon_ses]" $ pip install "django-anymail[amazon_ses]"
or separately run `pip install boto3`. or separately run ``pip install boto3``.
To send mail with Anymail's Amazon SES backend, set: To send mail with Anymail's Amazon SES backend, set:
.. code-block:: python .. code-block:: python
EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend" EMAIL_BACKEND = "anymail.backends.amazon_sesv2.EmailBackend"
in your settings.py. in your settings.py. (If you need to use the older Amazon SES v1 API, replace
``amazon_sesv2`` with just ``amazon_ses``---but be aware SES v1 support is
deprecated and will be dropped in a near-future Anymail release.)
In addition, you must make sure boto3 is configured with AWS credentials having the In addition, you must make sure boto3 is configured with AWS credentials having the
necessary :ref:`amazon-ses-iam-permissions`. necessary :ref:`amazon-ses-iam-permissions`.
@@ -48,6 +63,61 @@ setting to customize the Boto session.
.. _Credentials: https://boto3.readthedocs.io/en/stable/guide/configuration.html#configuring-credentials .. _Credentials: https://boto3.readthedocs.io/en/stable/guide/configuration.html#configuring-credentials
.. _amazon-ses-v2:
Migrating to the SES v2 API
---------------------------
.. versionchanged:: 9.1
Anymail is in the process of switching email sending from the original Amazon SES API (v1)
to the updated SES v2 API. Although the capabilities of the two API versions are virtually
identical, Amazon is implementing SES improvements (such as increased maximum message size)
only in the v2 API.
If you used Anymail 9.0 or earlier to integrate with Amazon SES, you are using the
older SES v1 API. Your code will continue to work with Anymail 9.1, but SES v1 support
is now deprecated and will be removed in a future Anymail release (likely in late 2023).
Migrating to SES v2 requires minimal code changes:
1. Update your :ref:`IAM permissions <amazon-ses-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 <amazon-ses-iam-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
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`_.
3. In your settings.py, update the :setting:`!EMAIL_BACKEND` to use ``amazon_sesv2``.
Change this:
.. code-block:: python
EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend" # SES v1
to this:
.. code-block:: python
EMAIL_BACKEND = "anymail.backends.amazon_sesv2.EmailBackend" # SES v2
# ^^
The upgrade for SES v2 affects only sending email. There are no changes required
for status tracking webhooks or receiving inbound email.
.. _amazon-ses-quirks: .. _amazon-ses-quirks:
@@ -76,7 +146,7 @@ Limitations and quirks
:attr:`~anymail.message.AnymailMessage.track_clicks` are not supported. :attr:`~anymail.message.AnymailMessage.track_clicks` are not supported.
Although Amazon SES *does* support open and click tracking, it doesn't offer Although Amazon SES *does* support open and click tracking, it doesn't offer
a simple mechanism to override the settings for individual messages. If you a simple mechanism to override the settings for individual messages. If you
need this feature, provide a custom ConfigurationSetName in Anymail's need this feature, provide a custom ``ConfigurationSetName`` in Anymail's
:ref:`esp_extra <amazon-ses-esp-extra>`. :ref:`esp_extra <amazon-ses-esp-extra>`.
**No delayed sending** **No delayed sending**
@@ -98,11 +168,19 @@ Limitations and quirks
``message.attach_alternative("...AMPHTML content...", "text/x-amp-html")`` ``message.attach_alternative("...AMPHTML content...", "text/x-amp-html")``
(and be sure to also include regular HTML and text bodies, too). (and be sure to also include regular HTML and text bodies, too).
**Spoofed To header and multiple From emails allowed** **Envelope-sender is forwarded**
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` becomes
Amazon SES's ``FeedbackForwardingEmailAddress`` (for the SES v2 API; or for SES v1
either ``Source`` or ``ReturnPath``). That address will receive bounce and other
delivery notifications, but will not appear in the message sent to the recipient.
SES always generates its own anonymized envelope sender (mailfrom) for each outgoing
message, and then forwards that address to your envelope-sender. See
`Email feedback forwarding destination`_ in the SES docs.
**Spoofed To header allowed**
Amazon SES is one of the few ESPs that supports spoofing the :mailheader:`To` header Amazon SES is one of the few ESPs that supports spoofing the :mailheader:`To` header
(see :ref:`message-headers`) and supplying multiple addresses in a message's `from_email`. (see :ref:`message-headers`). (But be aware that most ISPs consider this a strong spam
(Most ISPs consider these to be very strong spam signals, and using either them will almost signal, and using it will likely prevent delivery of your email.)
certainly prevent delivery of your mail.)
**Template limitations** **Template limitations**
Messages sent with templates have a number of additional limitations, such as not Messages sent with templates have a number of additional limitations, such as not
@@ -112,6 +190,10 @@ Limitations and quirks
.. _throttles sending: .. _throttles sending:
https://docs.aws.amazon.com/ses/latest/DeveloperGuide/manage-sending-limits.html https://docs.aws.amazon.com/ses/latest/DeveloperGuide/manage-sending-limits.html
.. _Email feedback forwarding destination:
https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications-email.html#monitor-sending-activity-using-notifications-email-destination
.. _amazon-ses-tags: .. _amazon-ses-tags:
Tags and metadata Tags and metadata
@@ -168,11 +250,12 @@ setting is disabled by default. If you use it, then only a single tag is support
and both the tag and the name must be limited to alphanumeric, hyphen, and underscore and both the tag and the name must be limited to alphanumeric, hyphen, and underscore
characters. characters.
For more complex use cases, set the SES `Tags` parameter directly in Anymail's For more complex use cases, set the SES ``EmailTags`` parameter (or ``DefaultEmailTags``
:ref:`esp_extra <amazon-ses-esp-extra>`. See the example below. (Because custom headers do not for template sends) directly in Anymail's :ref:`esp_extra <amazon-ses-esp-extra>`. See
work with SES's SendBulkTemplatedEmail call, esp_extra Tags is the only way to attach the example below. (Because custom headers do not work with SES's SendBulkEmail call,
data to SES messages also using Anymail's :attr:`~anymail.message.AnymailMessage.template_id` esp_extra ``DefaultEmailTags`` is the only way to attach data to SES messages also using
and :attr:`~anymail.message.AnymailMessage.merge_data` features, and the Anymail's :attr:`~anymail.message.AnymailMessage.template_id` and
:attr:`~anymail.message.AnymailMessage.merge_data` features, and
:attr:`~anymail.message.AnymailMessage.merge_metadata` cannot be supported.) :attr:`~anymail.message.AnymailMessage.merge_metadata` cannot be supported.)
@@ -191,33 +274,42 @@ esp_extra support
To use Amazon SES features not directly supported by Anymail, you can To use Amazon SES features not directly supported by Anymail, you can
set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to
a `dict` that will be merged into the params for the `SendRawEmail`_ a `dict` that will be shallow-merged into the params for the `SendEmail`_
or `SendBulkTemplatedEmail`_ SES API call. or `SendBulkEmail`_ SES v2 API call. (Or if you are using the SES v1 API,
`SendRawEmail`_ or `SendBulkTemplatedEmail`_.)
Example: Examples (for a non-template send using the SES v2 API):
.. code-block:: python .. code-block:: python
message.esp_extra = { message.esp_extra = {
# Override AMAZON_SES_CONFIGURATION_SET_NAME for this message # Override AMAZON_SES_CONFIGURATION_SET_NAME for this message:
'ConfigurationSetName': 'NoOpenOrClickTrackingConfigSet', 'ConfigurationSetName': 'NoOpenOrClickTrackingConfigSet',
# Authorize a custom sender # Authorize a custom sender:
'SourceArn': 'arn:aws:ses:us-east-1:123456789012:identity/example.com', 'FromEmailAddressIdentityArn': 'arn:aws:ses:us-east-1:123456789012:identity/example.com',
# Set Amazon SES Message Tags # Set SES Message Tags (change to 'DefaultEmailTags' for template sends):
'Tags': [ 'EmailTags': [
# (Names and values must be A-Z a-z 0-9 - and _ only) # (Names and values must be A-Z a-z 0-9 - and _ only)
{'Name': 'UserID', 'Value': str(user_id)}, {'Name': 'UserID', 'Value': str(user_id)},
{'Name': 'TestVariation', 'Value': 'Subject-Emoji-Trial-A'}, {'Name': 'TestVariation', 'Value': 'Subject-Emoji-Trial-A'},
], ],
# Set options for unsubscribe links:
'ListManagementOptions': {
'ContactListName': 'RegisteredUsers',
'TopicName': 'DailyUpdates',
},
} }
(You can also set `"esp_extra"` in Anymail's :ref:`global send defaults <send-defaults>` (You can also set `"esp_extra"` in Anymail's :ref:`global send defaults <send-defaults>`
to apply it to all messages.) to apply it to all messages.)
.. _SendEmail:
https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html
.. _SendBulkEmail:
https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendBulkEmail.html
.. _SendRawEmail: .. _SendRawEmail:
https://docs.aws.amazon.com/ses/latest/APIReference/API_SendRawEmail.html https://docs.aws.amazon.com/ses/latest/APIReference/API_SendRawEmail.html
.. _SendBulkTemplatedEmail: .. _SendBulkTemplatedEmail:
https://docs.aws.amazon.com/ses/latest/APIReference/API_SendBulkTemplatedEmail.html https://docs.aws.amazon.com/ses/latest/APIReference/API_SendBulkTemplatedEmail.html
@@ -232,8 +324,8 @@ and :ref:`batch sending <batch-send>` with per-recipient merge data.
See Amazon's `Sending personalized email`_ guide for more information. See Amazon's `Sending personalized email`_ guide for more information.
When you set a message's :attr:`~anymail.message.AnymailMessage.template_id` When you set a message's :attr:`~anymail.message.AnymailMessage.template_id`
to the name of one of your SES templates, Anymail will use the SES to the name of one of your SES templates, Anymail will use the SES v2 `SendBulkEmail`_
`SendBulkTemplatedEmail`_ call to send template messages personalized with data (or v1 `SendBulkTemplatedEmail`_) call to send template messages personalized with data
from Anymail's normalized :attr:`~anymail.message.AnymailMessage.merge_data` from Anymail's normalized :attr:`~anymail.message.AnymailMessage.merge_data`
and :attr:`~anymail.message.AnymailMessage.merge_global_data` and :attr:`~anymail.message.AnymailMessage.merge_global_data`
message attributes. message attributes.
@@ -338,7 +430,8 @@ Finally, switch to Amazon's **Simple Email Service** console:
.. code-block:: python .. code-block:: python
ANYMAIL = { ANYMAIL = {
... # ... other settings ...
# Use the name from step 5a above:
"AMAZON_SES_CONFIGURATION_SET_NAME": "TrackingConfigSet", "AMAZON_SES_CONFIGURATION_SET_NAME": "TrackingConfigSet",
} }
@@ -652,7 +745,12 @@ IAM permissions
Anymail requires IAM permissions that will allow it to use these actions: Anymail requires IAM permissions that will allow it to use these actions:
* To send mail: * To send mail with the SES v2 API:
* Ordinary (non-templated) sends: ``ses:SendEmail``
* Template/merge sends: ``ses:SendBulkEmail``
* To send mail with the older SES v1 API (deprecated in Anymail 9.1):
* Ordinary (non-templated) sends: ``ses:SendRawEmail`` * Ordinary (non-templated) sends: ``ses:SendRawEmail``
* Template/merge sends: ``ses:SendBulkTemplatedEmail`` * Template/merge sends: ``ses:SendBulkTemplatedEmail``
@@ -677,6 +775,10 @@ This IAM policy covers all of those:
{ {
"Version": "2012-10-17", "Version": "2012-10-17",
"Statement": [{ "Statement": [{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendBulkEmail"],
"Resource": "*"
}, {
"Effect": "Allow", "Effect": "Allow",
"Action": ["ses:SendRawEmail", "ses:SendBulkTemplatedEmail"], "Action": ["ses:SendRawEmail", "ses:SendBulkTemplatedEmail"],
"Resource": "*" "Resource": "*"
@@ -691,11 +793,35 @@ This IAM policy covers all of those:
}] }]
} }
.. _amazon-ses-iam-errors:
.. note:: **Misleading IAM error messages**
Permissions errors for the SES v2 API often refer to the equivalent SES v1 API name,
which can be confusing. For example, this error (emphasis added):
.. parsed-literal::
An error occurred (AccessDeniedException) when calling the **SendEmail** operation:
User 'arn:...' is not authorized to perform **'ses:SendRawEmail'** on resource 'arn:...'
actually indicates problems with IAM policies for the v2 ``ses:SendEmail`` action,
*not* the v1 ``ses:SendRawEmail`` action. (The correct action appears as the "operation"
in the first line of the error message.)
Following the principle of `least privilege`_, you should omit permissions Following the principle of `least privilege`_, you should omit permissions
for any features you aren't using, and you may want to add additional restrictions: for any features you aren't using, and you may want to add additional restrictions:
* If you are not using the older Amazon SES v1 API, you can omit permissions
that allow ``ses:SendRawEmail`` and ``ses:SendBulkTemplatedEmail``. (See
:ref:`amazon-ses-v2` above.)
* For Amazon SES sending, you can add conditions to restrict senders, recipients, times, * For Amazon SES sending, you can add conditions to restrict senders, recipients, times,
or other properties. See Amazon's `Controlling access to Amazon SES`_ guide. or other properties. See Amazon's `Controlling access to Amazon SES`_ guide.
(Be aware that the SES v2 ``SendBulkEmail`` API does not support condition keys
that restrict email addresses, and using them can cause misleading error messages.
All other SES APIs used by Anymail *do* support address restrictions, including
the SES v2 ``SendEmail`` API used for non-template sends.)
* For auto-confirming webhooks, you might limit the resource to SNS topics owned * For auto-confirming webhooks, you might limit the resource to SNS topics owned
by your AWS account, and/or specific topic names or patterns. E.g., by your AWS account, and/or specific topic names or patterns. E.g.,

View File

@@ -0,0 +1,959 @@
import json
from datetime import datetime
from email.encoders import encode_7or8bit
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.exceptions import AnymailAPIError, 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_sesv2.EmailBackend")
class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
"""TestCase that uses the Amazon SES EmailBackend with a mocked boto3 client"""
def setUp(self):
super().setUp()
# Mock boto3.session.Session().client('sesv2').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_sesv2.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('sesv2', ...)
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": "900dd7f3-0399-4a1b-9d9f-bed91f46924a",
"HTTPStatusCode": 200,
"HTTPHeaders": {
"date": "Tue, 21 Feb 2023 22:59:46 GMT",
"content-type": "application/json",
"content-length": "76",
"connection": "keep-alive",
"x-amzn-requestid": "900dd7f3-0399-4a1b-9d9f-bed91f46924a",
},
"RetryAttempts": 0,
},
}
def set_mock_response(self, response=None, operation_name="send_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_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="sesv2"):
"""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_email"):
"""Returns kwargs params passed to the mock send API.
Fails test if API wasn't called.
"""
self.mock_client.assert_called_with("sesv2", 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_email Content.Raw.Data param"""
params = self.get_send_params(operation_name="send_email")
raw_mime = params["Content"]["Raw"]["Data"]
parsed = AnymailInboundMessage.parse_raw_mime_bytes(raw_mime)
return parsed
def assert_esp_not_called(self, msg=None, operation_name="send_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_email takes a fully-formatted MIME message.
# This is a simple (if inexact) way to check for expected headers and body:
raw_mime = params["Content"]["Raw"]["Data"]
self.assertIsInstance(raw_mime, bytes) # SendEmail 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)
# Destination must include all recipients:
self.assertEqual(params["Destination"], {"ToAddresses": ["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" <to2@example.com>']
self.message.cc = ["cc1@example.com", "Also cc <cc2@example.com>"]
self.message.bcc = ["bcc1@example.com", "BCC 2 <bcc2@example.com>"]
self.message.send()
params = self.get_send_params()
self.assertEqual(
params["Destination"],
{
"ToAddresses": [
"to1@example.com",
'"Recipient, second" <to2@example.com>',
],
"CcAddresses": ["cc1@example.com", "Also cc <cc2@example.com>"],
"BccAddresses": ["bcc1@example.com", "BCC 2 <bcc2@example.com>"],
},
)
# Bcc's shouldn't appear in the message itself:
self.assertNotIn(b"bcc", params["Content"]["Raw"]["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" <to@example.com>'] # 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["Content"]["Raw"]["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?= <to@example.com>\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 (addr-spec only, must use Punycode):
self.assertEqual(
params["Destination"],
{
"ToAddresses": ["=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= <to@example.com>"],
"CcAddresses": ["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 = (
'<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % 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.inline_attachments
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["Content"]["Raw"]["Data"]
self.assertNotIn(b"\nContent-Disposition: attachment", raw_mime)
def test_multiple_html_alternatives(self):
# Multiple alternatives *are* allowed
self.message.attach_alternative("<p>First html is OK</p>", "text/html")
self.message.attach_alternative("<p>And so is second</p>", "text/html")
self.message.send()
params = self.get_send_params()
raw_mime = params["Content"]["Raw"]["Data"]
# just check the alternative smade it into the message
# (assume that Django knows how to format them properly)
self.assertIn(b"\n\n<p>First html is OK</p>\n", raw_mime)
self.assertIn(b"\n\n<p>And so is second</p>\n", raw_mime)
def test_alternative(self):
# Non-HTML alternatives (including AMP) *are* allowed
self.message.attach_alternative("<p>AMP HTML</p>", "text/x-amp-html")
self.message.send()
params = self.get_send_params()
raw_mime = params["Content"]["Raw"]["Data"]
# just check the alternative made it into the message
# (assume that Python email knows how to format it properly)
self.assertIn(b"\nContent-Type: text/x-amp-html", raw_mime)
def test_multiple_from(self):
# Amazon allows multiple addresses in the From header,
# but must specify a single one for the FromEmailAddress
self.message.from_email = "First <from1@example.com>, from2@example.com"
with self.assertRaisesMessage(
AnymailUnsupportedFeature, "multiple from emails"
):
self.message.send()
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_no_cte_8bit(self):
"""Anymail works around an Amazon SES bug that can corrupt non-ASCII bodies."""
# (see detailed comments in the backend code)
# The generated MIMEText for each of these ends up using CTE 8bit by default:
self.message.body = "Это text body"
self.message.attach_alternative("<p>Это html body</p>", "text/html")
self.message.attach("sample.csv", "Это attachment", "text/csv")
# Also force a CTE 8bit attachment (which normally defaults to CTE base64):
att = MIMEApplication("Это data".encode("utf8"), "data", encode_7or8bit)
self.assertEqual(att["Content-Transfer-Encoding"], "8bit")
self.message.attach(att)
self.message.send()
sent_message = self.get_sent_message()
# Make sure none of the resulting 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 for them.)
part_encodings = [
(part.get_content_type(), part["Content-Transfer-Encoding"])
for part in sent_message.walk()
]
self.assertEqual(
part_encodings,
[
("multipart/mixed", None),
("multipart/alternative", None),
("text/plain", "quoted-printable"),
("text/html", "quoted-printable"),
("text/csv", "quoted-printable"),
("application/data", "base64"),
],
)
def test_api_failure(self):
error_response = {
"Error": {
"Code": "MessageRejected",
"Message": "Email address is not verified. The following identities"
" failed the check in region US-EAST-1: to@example.com",
},
"ResponseMetadata": {
"RequestId": "c44b0ae2-e086-45ca-8820-b76a9b9f430a",
"HTTPStatusCode": 403,
"HTTPHeaders": {
"date": "Tue, 21 Feb 2023 23:49:31 GMT",
"content-type": "application/json",
"content-length": "196",
"connection": "keep-alive",
"x-amzn-requestid": "c44b0ae2-e086-45ca-8820-b76a9b9f430a",
"x-amzn-errortype": "MessageRejected",
},
"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_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" <from@example.com>',
["to@example.com"],
)
with self.assertRaises(BadHeaderError):
mail.send_mail(
"Subject",
"Body",
"from@example.com",
['"Display-Name\rInjected" <to@example.com>'],
)
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["FeedbackForwardingEmailAddress"],
"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 <envelope-to@example.com>"]
self.message.extra_headers["To"] = "Spoofed <spoofed-to@elsewhere.example.org>"
self.message.send()
params = self.get_send_params()
raw_mime = params["Content"]["Raw"]["Data"]
self.assertEqual(
params["Destination"],
{"ToAddresses": ["Envelope <envelope-to@example.com>"]},
)
self.assertIn(b"\nTo: Spoofed <spoofed-to@elsewhere.example.org>\n", raw_mime)
self.assertNotIn(b"envelope-to@example.com", raw_mime)
def test_metadata(self):
self.message.metadata = {
"User ID": 12345,
# that \n is a header-injection test:
"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["EmailTags"], [{"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 SESv2 SendBulkEmail"""
# SendBulkEmail 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 = {
"BulkEmailEntryResults": [
{
"Status": "SUCCESS",
"MessageId": "1111111111111111-bbbbbbbb-3333-7777",
},
{
"Status": "ACCOUNT_DAILY_QUOTA_EXCEEDED",
"Error": "Daily message quota exceeded",
},
],
"ResponseMetadata": self.DEFAULT_SEND_RESPONSE["ResponseMetadata"],
}
self.set_mock_response(raw_response, operation_name="send_bulk_email")
message = AnymailMessage(
template_id="welcome_template",
from_email='"Example, Inc." <from@example.com>',
to=["alice@example.com", "罗伯特 <bob@example.com>"],
cc=["cc@example.com"],
reply_to=["reply1@example.com", "Reply 2 <reply2@example.com>"],
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="bounce@example.com",
esp_extra={
"FromEmailAddressIdentityArn": (
"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_email")
params = self.get_send_params(operation_name="send_bulk_email")
self.assertEqual(
params["DefaultContent"]["Template"]["TemplateName"], "welcome_template"
)
self.assertEqual(
params["FromEmailAddress"], '"Example, Inc." <from@example.com>'
)
bulk_entries = params["BulkEmailEntries"]
self.assertEqual(len(bulk_entries), 2)
self.assertEqual(
bulk_entries[0]["Destination"],
{"ToAddresses": ["alice@example.com"], "CcAddresses": ["cc@example.com"]},
)
self.assertEqual(
json.loads(
bulk_entries[0]["ReplacementEmailContent"]["ReplacementTemplate"][
"ReplacementTemplateData"
]
),
{"name": "Alice", "group": "Developers"},
)
self.assertEqual(
bulk_entries[1]["Destination"],
{
# SES requires RFC2047:
"ToAddresses": ["=?utf-8?b?572X5Lyv54m5?= <bob@example.com>"],
"CcAddresses": ["cc@example.com"],
},
)
self.assertEqual(
json.loads(
bulk_entries[1]["ReplacementEmailContent"]["ReplacementTemplate"][
"ReplacementTemplateData"
]
),
{"name": "Bob"},
)
self.assertEqual(
json.loads(params["DefaultContent"]["Template"]["TemplateData"]),
{"group": "Users", "site": "ExampleCo"},
)
self.assertEqual(
params["ReplyToAddresses"],
["reply1@example.com", "Reply 2 <reply2@example.com>"],
)
self.assertEqual(
params["DefaultEmailTags"],
[{"Name": "Campaign", "Value": "WelcomeVariantA"}],
)
self.assertEqual(params["FeedbackForwardingEmailAddress"], "bounce@example.com")
# esp_extra:
self.assertEqual(
params["FromEmailAddressIdentityArn"],
"arn:aws:ses:us-east-1:123456789012:identity/example.com",
)
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_failure(self):
"""Failures to all recipients raise a similar error to non-template sends"""
raw_response = {
"BulkEmailEntryResults": [
{
"Status": "TEMPLATE_DOES_NOT_EXIST",
"Error": "No template named 'oops'",
},
{
"Status": "TEMPLATE_DOES_NOT_EXIST",
"Error": "No template named 'oops'",
},
],
"ResponseMetadata": self.DEFAULT_SEND_RESPONSE["ResponseMetadata"],
}
self.set_mock_response(raw_response, operation_name="send_bulk_email")
message = AnymailMessage(
template_id="oops",
from_email="from@example.com",
to=["alice@example.com", "bob@example.com"],
)
with self.assertRaisesMessage(AnymailAPIError, "No template named 'oops'"):
message.send()
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 SendEmail 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_email")
# fails if send_email not called:
self.get_send_params(operation_name="send_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("BulkEmailEntries", params)
self.assertNotIn("ConfigurationSetName", params)
self.assertNotIn("DefaultContent", params)
self.assertNotIn("DefaultContent", params)
self.assertNotIn("DefaultEmailTags", params)
self.assertNotIn("EmailTags", params)
self.assertNotIn("FeedbackForwardingEmailAddress", params)
self.assertNotIn("FeedbackForwardingEmailAddressIdentityArn", params)
self.assertNotIn("FromEmailAddressIdentityArn", params)
self.assertNotIn("ListManagementOptions", params)
self.assertNotIn("ReplyToAddresses", 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_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.assertRegex(
config.user_agent_extra, r"django-anymail/\d(\.\w+){1,}-amazon-ses"
)
@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_sesv2.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")

View File

@@ -0,0 +1,199 @@
import os
import unittest
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_sesv2.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 = f"test@{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("<p>HTML content</p>", "text/html")
def test_simple_send(self):
# Example of getting the Amazon SES send status and message id from the message
sent_count = self.message.send()
self.assertEqual(sent_count, 1)
anymail_status = self.message.anymail_status
sent_status = anymail_status.recipients[
"success@simulator.amazonses.com"
].status
message_id = anymail_status.recipients[
"success@simulator.amazonses.com"
].message_id
# Amazon SES always queues (or raises an error):
self.assertEqual(sent_status, "queued")
# Amazon SES message ids are groups of hex chars:
self.assertRegex(message_id, r"[0-9a-f-]+")
# set of all recipient statuses:
self.assertEqual(anymail_status.status, {sent_status})
self.assertEqual(anymail_status.message_id, message_id)
def test_all_options(self):
message = AnymailMessage(
subject="Anymail Amazon SES all-options integration test",
body="This is the text body",
from_email=formataddr(("Test From, with comma", self.from_email)),
to=[
"success+to1@simulator.amazonses.com",
"Recipient 2 <success+to2@simulator.amazonses.com>",
],
cc=[
"success+cc1@simulator.amazonses.com",
"Copy 2 <success+cc2@simulator.amazonses.com>",
],
bcc=[
"success+bcc1@simulator.amazonses.com",
"Blind Copy 2 <success+bcc2@simulator.amazonses.com>",
],
reply_to=["reply1@example.com", "Reply 2 <reply2@example.com>"],
headers={"X-Anymail-Test": "value"},
metadata={"meta1": "simple_string", "meta2": 2},
tags=["Re-engagement", "Cohort 12/2017"],
envelope_sender=f"bounce-handler@{ANYMAIL_TEST_AMAZON_SES_DOMAIN}",
)
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(
"<p><b>HTML:</b> with <a href='http://example.com'>link</a>"
"and image: <img src='cid:%s'></div>" % 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('sesv2').create_email_template(
# TemplateName="TestTemplate",
# TemplateContent={
# "Subject": "Your order {{order}} shipped",
# "Html": "<h1>Dear {{name}}:</h1>"
# "<p>Your order {{order}} shipped {{ship_date}}.</p>",
# "Text": "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+to1@simulator.amazonses.com>",
"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()