Amazon SES: use SES v2 API by default

- Rename `anymail.backends.amazon_sesv2.EmailBackend`
  to `amazon_ses`, making SES v2 the default.
- Rename the old `amazon_ses` backend to `amazon_sesv1`,
  keeping it available. Add a deprecation warning.
- Alias `amazon_sesv2` to `amazon_ses`, with a
  deprecation warning (for projects that opted
  into v2 early under Anymail 9.1 or 9.2).
- Similar renaming on the test files.
- Update docs to assume v2 in most places
  (other than migration-specific sections)
This commit is contained in:
Mike Edmunds
2023-05-04 12:27:10 -07:00
parent 2335b9cfc8
commit 41754d9813
9 changed files with 1022 additions and 992 deletions

View File

@@ -33,6 +33,18 @@ vNext
Breaking changes
~~~~~~~~~~~~~~~~
* **Amazon SES:** The Amazon SES backend now sends using the SES v2 API.
Most projects should not require code changes, but you may need to update
your IAM permissions. See
`Migrating to the SES v2 API <https://anymail.dev/en/latest/esps/amazon_ses/#amazon-ses-v2>`__.
If you were using SES v2 under Anymail 9.1 or 9.2, change your
``EMAIL_BACKEND`` setting from ``amazon_sesv2`` to just ``amazon_ses``.
(If you are not ready to migrate to SES v2, an ``amazon_sesv1`` EmailBackend
is available. But Anymail will drop support for that later this year. See
`Using SES v1 (deprecated) <https://anymail.dev/en/latest/esps/amazon_ses/#amazon-ses-v1>`__.)
* **Amazon SES:** The "extra name" for installation must now be spelled with
a hyphen rather than an underscore: ``django-anymail[amazon-ses]``.
Be sure to update any dependencies specification (pip install, requirements.txt,

View File

@@ -1,7 +1,8 @@
from email.charset import QP, Charset
from email.mime.text import MIMEText
import email.charset
import email.encoders
import email.policy
from .._version import __version__
from .. import __version__ as ANYMAIL_VERSION
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
from ..message import AnymailRecipientStatus
from ..utils import UNSET, get_anymail_setting
@@ -23,7 +24,7 @@ BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError)
class EmailBackend(AnymailBaseBackend):
"""
Amazon SES Email Backend (using boto3)
Amazon SES v2 Email Backend (using boto3)
"""
esp_name = "Amazon SES"
@@ -34,7 +35,7 @@ class EmailBackend(AnymailBaseBackend):
# 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
esp_name=self.esp_name, kwargs=kwargs
)
self.configuration_set_name = get_anymail_setting(
"configuration_set_name",
@@ -50,6 +51,7 @@ class EmailBackend(AnymailBaseBackend):
allow_bare=False,
default=None,
)
self.client = None
def open(self):
@@ -57,7 +59,7 @@ class EmailBackend(AnymailBaseBackend):
return False # already exists
try:
self.client = boto3.session.Session(**self.session_params).client(
"ses", **self.client_params
"sesv2", **self.client_params
)
except Exception:
if not self.fail_silently:
@@ -68,7 +70,7 @@ class EmailBackend(AnymailBaseBackend):
def close(self):
if self.client is None:
return
# self.client.close() # boto3 doesn't support (or require) client shutdown
self.client.close()
self.client = None
def _send(self, message):
@@ -88,16 +90,24 @@ class EmailBackend(AnymailBaseBackend):
)
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)
# For simplicity, use SESv2 SendBulkEmail for all templated messages
# (even though SESv2 SendEmail has a template option).
return AmazonSESV2SendBulkEmailPayload(message, defaults, self)
else:
return AmazonSESSendRawEmailPayload(message, defaults, self)
return AmazonSESV2SendEmailPayload(message, defaults, self)
def post_to_esp(self, payload, message):
payload.finalize_payload()
try:
response = payload.call_send_api(self.client)
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)
@@ -115,64 +125,70 @@ class EmailBackend(AnymailBaseBackend):
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 call_send_api(self, ses_client):
raise NotImplementedError()
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, FromArn, SourceArn, ReturnPathArn
# e.g., ConfigurationSetName, FromEmailAddressIdentityArn,
# FeedbackForwardingEmailAddress, ListManagementOptions
self.params.update(extra)
class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
class AmazonSESV2SendEmailPayload(AmazonSESBasePayload):
api_name = "send_email"
def init_payload(self):
super().init_payload()
self.all_recipients = []
self.all_recipients = [] # for parse_recipient_status
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 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 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 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(
"%s parsing Amazon SES send result %r" % (str(err), response),
f"{err!s} parsing Amazon SES send result {response!r}",
backend=self.backend,
email_message=self.message,
payload=self,
@@ -194,29 +210,39 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
# 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
f"Anymail send defaults for '{attr}' with Amazon SES"
)
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_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):
self.all_recipients += emails
# included in mime_message
assert recipient_type in ("to", "cc", "bcc")
# 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):
@@ -240,16 +266,18 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
self._no_send_defaults("attachments")
# Anymail-specific payload construction
def set_envelope_sender(self, email):
self.params["Source"] = email.addr_spec
# 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 self.all_recipients,
# which is used as the SendRawEmail Destinations later.
# The actual "to" is already in params["Destination"]["ToAddresses"].
#
# So, nothing to do here, except prevent the default
# "unsupported feature" error.
@@ -288,13 +316,13 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
self.unsupported_feature(
"multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting"
)
self.params.setdefault("Tags", []).append(
self.params.setdefault("EmailTags", []).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"
f"{self.__class__.__name__} should not have been used with template_id"
)
def set_merge_data(self, merge_data):
@@ -304,15 +332,19 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
self.unsupported_feature("global_merge_data without template_id")
class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
class AmazonSESV2SendBulkEmailPayload(AmazonSESBasePayload):
api_name = "send_bulk_email"
def init_payload(self):
super().init_payload()
# late-bind recipients and merge_data in call_send_api
# late-bind recipients and merge_data in finalize_payload
self.recipients = {"to": [], "cc": [], "bcc": []}
self.merge_data = {}
def call_send_api(self, ses_client):
# include any 'cc' or 'bcc' in every destination
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"] = [
@@ -323,55 +355,75 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
bcc.address for bcc in self.recipients["bcc"]
]
# set up destination and data for each 'to'
self.params["Destinations"] = [
# 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"]
]
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
results = response["BulkEmailEntryResults"]
ses_status_set = set(result["Status"] for result in results)
anymail_statuses = [
AnymailRecipientStatus(
message_id=status.get("MessageId", None),
status="queued" if status.get("Status") == "Success" else "failed",
message_id=result.get("MessageId", None),
status="queued" if result["Status"] == "SUCCESS" else "failed",
)
for status in response["Status"]
for result in results
]
except (KeyError, TypeError) as err:
raise AnymailAPIError(
"%s parsing Amazon SES send result %r" % (str(err), response),
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(
"Sent to %d destinations, but only %d statuses in Amazon SES"
" send result %r" % (len(to_addrs), len(anymail_statuses), response),
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["Source"] = email.address
self.params["FromEmailAddress"] = email.address
def set_recipients(self, recipient_type, emails):
# late-bound in call_send_api
# late-bound in finalize_payload
assert recipient_type in ("to", "cc", "bcc")
self.recipients[recipient_type] = emails
@@ -400,24 +452,27 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
self.unsupported_feature("attachments with template")
# Anymail-specific payload construction
def set_envelope_sender(self, email):
self.params["ReturnPath"] = email.addr_spec
# 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 SendBulkTemplatedEmail
# no custom headers with SendBulkEmail
self.unsupported_feature("metadata with template")
def set_tags(self, tags):
# no custom headers with SendBulkTemplatedEmail, but support
# no custom headers with SendBulkEmail, but support
# AMAZON_SES_MESSAGE_TAG_NAME if used (see tags/metadata in
# AmazonSESSendRawEmailPayload for more info)
# 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["DefaultTags"] = [
self.params["DefaultEmailTags"] = [
{"Name": self.backend.message_tag_name, "Value": tags[0]}
]
else:
@@ -427,14 +482,20 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
)
def set_template_id(self, template_id):
self.params["Template"] = template_id
# DefaultContent.Template.TemplateName
self.params.setdefault("DefaultContent", {}).setdefault("Template", {})[
"TemplateName"
] = template_id
def set_merge_data(self, merge_data):
# late-bound in call_send_api
# late-bound in finalize_payload
self.merge_data = merge_data
def set_merge_global_data(self, merge_global_data):
self.params["DefaultTemplateData"] = self.serialize_json(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):
@@ -459,7 +520,7 @@ def _get_anymail_boto3_params(esp_name=EmailBackend.esp_name, kwargs=None):
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__
esp=esp_name.lower().replace(" ", "-"), version=ANYMAIL_VERSION
)
)
if "config" in client_params:

View File

@@ -0,0 +1,449 @@
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)

View File

@@ -1,536 +1,14 @@
import email.charset
import email.encoders
import email.policy
import warnings
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", install_extra="amazon-ses"
) from err
from ..exceptions import AnymailDeprecationWarning
from .amazon_ses import EmailBackend as AmazonSESV2EmailBackend
# 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"
class EmailBackend(AmazonSESV2EmailBackend):
def __init__(self, **kwargs):
"""Init options from Django settings"""
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)
# 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 Exception:
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 _send(self, message):
if self.client:
return super()._send(message)
elif self.fail_silently:
# (Probably missing boto3 credentials in open().)
return False
else:
class_name = self.__class__.__name__
raise RuntimeError(
"boto3 Session has not been opened in {class_name}._send. "
"(This is either an implementation error in {class_name}, "
"or you are incorrectly calling _send directly.)".format(
class_name=class_name
)
)
def build_message_payload(self, message, defaults):
if getattr(message, "template_id", UNSET) is not UNSET:
# For simplicity, use SESv2 SendBulkEmail for all templated messages
# (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

@@ -6,17 +6,16 @@ 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:: 9.1
.. versionchanged:: 10.0
.. 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).
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.
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.
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.
.. sidebar:: Alternatives
@@ -46,18 +45,16 @@ or separately run ``pip install boto3``.
In earlier releases, the "extra name" could use an underscore
(``django-anymail[amazon_ses]``). That now causes pip to warn
that "django-anymail does not provide the extra 'amazon_ses'",
that "django-anymail does not provide the extra 'amazon_ses',"
and may result in a broken installation that is missing boto3.
To send mail with Anymail's Amazon SES backend, set:
.. code-block:: python
EMAIL_BACKEND = "anymail.backends.amazon_sesv2.EmailBackend"
EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend"
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 your settings.py.
In addition, you must make sure boto3 is configured with AWS credentials having the
necessary :ref:`amazon-ses-iam-permissions`.
@@ -75,16 +72,15 @@ setting to customize the Boto session.
Migrating to the SES v2 API
---------------------------
.. versionchanged:: 9.1
.. versionchanged:: 10.0
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.
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.
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).
(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:
@@ -98,7 +94,7 @@ Migrating to SES v2 requires minimal code changes:
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,
: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.
@@ -108,22 +104,34 @@ Migrating to SES v2 requires minimal code changes:
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:
.. _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" # SES v1
EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend" # default SES v2
to this:
.. code-block:: python
EMAIL_BACKEND = "anymail.backends.amazon_sesv2.EmailBackend" # SES v2
EMAIL_BACKEND = "anymail.backends.amazon_sesv1.EmailBackend" # SES v1
# ^^
The upgrade for SES v2 affects only sending email. There are no changes required
for status tracking webhooks or receiving inbound email.
Note that SES v1 support is deprecated and will be removed in a future Anymail release
(likely in late 2023).
.. _amazon-ses-quirks:
@@ -177,8 +185,7 @@ Limitations and quirks
**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
Amazon SES's ``FeedbackForwardingEmailAddress``. 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
@@ -282,10 +289,9 @@ esp_extra support
To use Amazon SES features not directly supported by Anymail, you can
set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to
a `dict` that will be shallow-merged into the params for the `SendEmail`_
or `SendBulkEmail`_ SES v2 API call. (Or if you are using the SES v1 API,
`SendRawEmail`_ or `SendBulkTemplatedEmail`_.)
or `SendBulkEmail`_ SES v2 API call.
Examples (for a non-template send using the SES v2 API):
Examples (for a non-template send):
.. code-block:: python
@@ -315,10 +321,6 @@ to apply it to all messages.)
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:
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-templates:
@@ -332,7 +334,7 @@ See Amazon's `Sending personalized email`_ guide for more information.
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 v2 `SendBulkEmail`_
(or v1 `SendBulkTemplatedEmail`_) call to send template messages personalized with data
call to send template messages personalized with data
from Anymail's normalized :attr:`~anymail.message.AnymailMessage.merge_data`
and :attr:`~anymail.message.AnymailMessage.merge_global_data`
message attributes.
@@ -752,16 +754,11 @@ IAM permissions
Anymail requires IAM permissions that will allow it to use these actions:
* To send mail with the SES v2 API:
* To send mail:
* 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``
* Template/merge sends: ``ses:SendBulkTemplatedEmail``
* To :ref:`automatically confirm <amazon-ses-confirm-sns-subscriptions>`
webhook SNS subscriptions: ``sns:ConfirmSubscription``
@@ -785,10 +782,6 @@ This IAM policy covers all of those:
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendBulkEmail"],
"Resource": "*"
}, {
"Effect": "Allow",
"Action": ["ses:SendRawEmail", "ses:SendBulkTemplatedEmail"],
"Resource": "*"
}, {
"Effect": "Allow",
"Action": ["sns:ConfirmSubscription"],
@@ -800,6 +793,12 @@ 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.)
.. _amazon-ses-iam-errors:
.. note:: **Misleading IAM error messages**
@@ -819,10 +818,6 @@ This IAM policy covers all of those:
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:
* 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,
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

View File

@@ -1,5 +1,6 @@
import json
from datetime import datetime
from email.encoders import encode_7or8bit
from email.mime.application import MIMEApplication
from unittest.mock import ANY, patch
@@ -8,7 +9,11 @@ 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, AnymailUnsupportedFeature
from anymail.exceptions import (
AnymailAPIError,
AnymailDeprecationWarning,
AnymailUnsupportedFeature,
)
from anymail.inbound import AnymailInboundMessage
from anymail.message import AnymailMessage, attach_inline_image_file
@@ -28,7 +33,7 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
def setUp(self):
super().setUp()
# Mock boto3.session.Session().client('ses').send_raw_email (and any other
# 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(
@@ -38,7 +43,7 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
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', ...)
#: boto3.session.Session().client('sesv2', ...)
self.mock_client_instance = self.mock_client.return_value
self.set_mock_response()
@@ -50,24 +55,25 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
DEFAULT_SEND_RESPONSE = {
"MessageId": "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000",
"ResponseMetadata": {
"RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb",
"RequestId": "900dd7f3-0399-4a1b-9d9f-bed91f46924a",
"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",
"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_raw_email"):
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_raw_email"):
def set_mock_failure(self, response, operation_name="send_email"):
from botocore.exceptions import ClientError
mock_operation = getattr(self.mock_client_instance, operation_name)
@@ -85,7 +91,7 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
)
return kwargs
def get_client_params(self, service="ses"):
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
@@ -103,12 +109,12 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
)
return kwargs
def get_send_params(self, operation_name="send_raw_email"):
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("ses", config=ANY)
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")
@@ -116,17 +122,13 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
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"]
"""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_raw_email"):
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")
@@ -146,16 +148,16 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
fail_silently=False,
)
params = self.get_send_params()
# send_raw_email takes a fully-formatted MIME message.
# 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["RawMessage"]["Data"]
self.assertIsInstance(raw_mime, bytes) # SendRawEmail expects Data as bytes
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)
# Destinations must include all recipients:
self.assertEqual(params["Destinations"], ["to@example.com"])
# 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
@@ -170,18 +172,18 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
self.message.send()
params = self.get_send_params()
self.assertEqual(
params["Destinations"],
[
params["Destination"],
{
"ToAddresses": [
"to1@example.com",
'"Recipient, second" <to2@example.com>',
"cc1@example.com",
"Also cc <cc2@example.com>",
"bcc1@example.com",
"BCC 2 <bcc2@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["RawMessage"]["Data"])
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
@@ -189,7 +191,7 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
self.message.cc = ["cc@thư.example.com"] # utf-8 in domain
self.message.send()
params = self.get_send_params()
raw_mime = params["RawMessage"]["Data"]
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:
@@ -201,13 +203,13 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
# SES doesn't support non-ASCII in the username@ part
# (RFC 6531 "SMTPUTF8" extension)
# Destinations must include all recipients:
# Destinations must include all recipients (addr-spec only, must use Punycode):
self.assertEqual(
params["Destinations"],
[
"=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= <to@example.com>",
"cc@xn--th-e0a.example.com",
],
params["Destination"],
{
"ToAddresses": ["=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= <to@example.com>"],
"CcAddresses": ["cc@xn--th-e0a.example.com"],
},
)
def test_attachments(self):
@@ -273,7 +275,7 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
# Make sure neither the html nor the inline image is treated as an attachment:
params = self.get_send_params()
raw_mime = params["RawMessage"]["Data"]
raw_mime = params["Content"]["Raw"]["Data"]
self.assertNotIn(b"\nContent-Disposition: attachment", raw_mime)
def test_multiple_html_alternatives(self):
@@ -282,31 +284,30 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
self.message.attach_alternative("<p>And so is second</p>", "text/html")
self.message.send()
params = self.get_send_params()
raw_mime = params["RawMessage"]["Data"]
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 *are* allowed
self.message.attach_alternative('{"is": "allowed"}', "application/json")
# 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["RawMessage"]["Data"]
raw_mime = params["Content"]["Raw"]["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)
# (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 which is Source
self.message.from_email = "from1@example.com, from2@example.com"
# 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()
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):
"""
@@ -321,47 +322,60 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
sent_message = self.get_sent_message()
self.assertEqual(sent_message["Subject"], self.message.subject)
def test_body_avoids_cte_8bit(self):
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 text parts use `Content-Transfer-Encoding: 8bit`.
# 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.)
text_part_encodings = [
# quoted-printable for them.)
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,
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": {
"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,
"RequestId": "c44b0ae2-e086-45ca-8820-b76a9b9f430a",
"HTTPStatusCode": 403,
"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",
"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,
},
@@ -444,7 +458,10 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
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")
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
@@ -453,15 +470,18 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
self.message.extra_headers["To"] = "Spoofed <spoofed-to@elsewhere.example.org>"
self.message.send()
params = self.get_send_params()
raw_mime = params["RawMessage"]["Data"]
self.assertEqual(params["Destinations"], ["Envelope <envelope-to@example.com>"])
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):
# (that \n is a header-injection test)
self.message.metadata = {
"User ID": 12345,
# that \n is a header-injection test:
"items": "Correct horse,Battery,\nStaple",
"Cart-Total": "22.70",
}
@@ -504,7 +524,9 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
self.message.tags = ["Welcome"]
self.message.send()
params = self.get_send_params()
self.assertEqual(params["Tags"], [{"Name": "Campaign", "Value": "Welcome"}])
self.assertEqual(
params["EmailTags"], [{"Name": "Campaign", "Value": "Welcome"}]
)
# Multiple Anymail tags are not supported when using this feature
self.message.tags = ["Welcome", "Variation_A"]
@@ -547,22 +569,25 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
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
"""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 = {
"Status": [
"BulkEmailEntryResults": [
{
"Status": "Success",
"Status": "SUCCESS",
"MessageId": "1111111111111111-bbbbbbbb-3333-7777",
},
{"Status": "AccountThrottled"},
{
"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_templated_email")
self.set_mock_response(raw_response, operation_name="send_bulk_email")
message = AnymailMessage(
template_id="welcome_template",
from_email='"Example, Inc." <from@example.com>',
@@ -577,30 +602,40 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
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",
envelope_sender="bounce@example.com",
esp_extra={
"SourceArn": "arn:aws:ses:us-east-1:123456789012:identity/example.com"
"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_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." <from@example.com>')
destinations = params["Destinations"]
self.assertEqual(len(destinations), 2)
self.assert_esp_not_called(operation_name="send_email")
params = self.get_send_params(operation_name="send_bulk_email")
self.assertEqual(
destinations[0]["Destination"],
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(destinations[0]["ReplacementTemplateData"]),
json.loads(
bulk_entries[0]["ReplacementEmailContent"]["ReplacementTemplate"][
"ReplacementTemplateData"
]
),
{"name": "Alice", "group": "Developers"},
)
self.assertEqual(
destinations[1]["Destination"],
bulk_entries[1]["Destination"],
{
# SES requires RFC2047:
"ToAddresses": ["=?utf-8?b?572X5Lyv54m5?= <bob@example.com>"],
@@ -608,10 +643,15 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
},
)
self.assertEqual(
json.loads(destinations[1]["ReplacementTemplateData"]), {"name": "Bob"}
json.loads(
bulk_entries[1]["ReplacementEmailContent"]["ReplacementTemplate"][
"ReplacementTemplateData"
]
),
{"name": "Bob"},
)
self.assertEqual(
json.loads(params["DefaultTemplateData"]),
json.loads(params["DefaultContent"]["Template"]["TemplateData"]),
{"group": "Users", "site": "ExampleCo"},
)
self.assertEqual(
@@ -619,12 +659,14 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
["reply1@example.com", "Reply 2 <reply2@example.com>"],
)
self.assertEqual(
params["DefaultTags"], [{"Name": "Campaign", "Value": "WelcomeVariantA"}]
params["DefaultEmailTags"],
[{"Name": "Campaign", "Value": "WelcomeVariantA"}],
)
self.assertEqual(params["ReturnPath"], "bounces@example.com")
self.assertEqual(params["FeedbackForwardingEmailAddress"], "bounce@example.com")
# esp_extra:
self.assertEqual(
params["SourceArn"],
"arn:aws:ses:us-east-1:123456789012:identity/example.com", # esp_extra
params["FromEmailAddressIdentityArn"],
"arn:aws:ses:us-east-1:123456789012:identity/example.com",
)
self.assertEqual(message.anymail_status.status, {"queued", "failed"})
@@ -648,6 +690,30 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
)
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"])
@@ -698,14 +764,14 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
message.tags = None
def test_send_anymail_message_without_template(self):
# Make sure SendRawEmail is used for non-template_id messages
# 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_templated_email")
# fails if send_raw_email not called:
self.get_send_params(operation_name="send_raw_email")
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.
@@ -716,20 +782,17 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
"""
self.message.send()
params = self.get_send_params()
self.assertNotIn("BulkEmailEntries", params)
self.assertNotIn("ConfigurationSetName", params)
self.assertNotIn("DefaultTags", params)
self.assertNotIn("DefaultTemplateData", params)
self.assertNotIn("FromArn", params)
self.assertNotIn("Message", 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)
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:
@@ -910,3 +973,18 @@ class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase):
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()

View File

@@ -1,6 +1,6 @@
import json
import warnings
from datetime import datetime
from email.encoders import encode_7or8bit
from email.mime.application import MIMEApplication
from unittest.mock import ANY, patch
@@ -9,7 +9,11 @@ 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, AnymailUnsupportedFeature
from anymail.exceptions import (
AnymailAPIError,
AnymailDeprecationWarning,
AnymailUnsupportedFeature,
)
from anymail.inbound import AnymailInboundMessage
from anymail.message import AnymailMessage, attach_inline_image_file
@@ -22,24 +26,28 @@ from .utils import (
@tag("amazon_ses")
@override_settings(EMAIL_BACKEND="anymail.backends.amazon_sesv2.EmailBackend")
@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()
# Mock boto3.session.Session().client('sesv2').send_raw_email (and any other
# 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_sesv2.boto3.session.Session", autospec=True
"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('sesv2', ...)
#: boto3.session.Session().client('ses', ...)
self.mock_client_instance = self.mock_client.return_value
self.set_mock_response()
@@ -51,25 +59,24 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
DEFAULT_SEND_RESPONSE = {
"MessageId": "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000",
"ResponseMetadata": {
"RequestId": "900dd7f3-0399-4a1b-9d9f-bed91f46924a",
"RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb",
"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",
"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_email"):
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_email"):
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)
@@ -87,7 +94,7 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
)
return kwargs
def get_client_params(self, service="sesv2"):
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
@@ -105,12 +112,12 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
)
return kwargs
def get_send_params(self, operation_name="send_email"):
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("sesv2", config=ANY)
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")
@@ -118,13 +125,17 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
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"]
"""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_email"):
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")
@@ -144,16 +155,16 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
fail_silently=False,
)
params = self.get_send_params()
# send_email takes a fully-formatted MIME message.
# 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["Content"]["Raw"]["Data"]
self.assertIsInstance(raw_mime, bytes) # SendEmail expects Data as bytes
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)
# Destination must include all recipients:
self.assertEqual(params["Destination"], {"ToAddresses": ["to@example.com"]})
# 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
@@ -168,18 +179,18 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
self.message.send()
params = self.get_send_params()
self.assertEqual(
params["Destination"],
{
"ToAddresses": [
params["Destinations"],
[
"to1@example.com",
'"Recipient, second" <to2@example.com>',
"cc1@example.com",
"Also cc <cc2@example.com>",
"bcc1@example.com",
"BCC 2 <bcc2@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"])
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
@@ -187,7 +198,7 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
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"]
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:
@@ -199,13 +210,13 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
# 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):
# Destinations must include all recipients:
self.assertEqual(
params["Destination"],
{
"ToAddresses": ["=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= <to@example.com>"],
"CcAddresses": ["cc@xn--th-e0a.example.com"],
},
params["Destinations"],
[
"=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= <to@example.com>",
"cc@xn--th-e0a.example.com",
],
)
def test_attachments(self):
@@ -271,7 +282,7 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
# 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"]
raw_mime = params["RawMessage"]["Data"]
self.assertNotIn(b"\nContent-Disposition: attachment", raw_mime)
def test_multiple_html_alternatives(self):
@@ -280,30 +291,31 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
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"]
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<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")
# Non-HTML alternatives *are* allowed
self.message.attach_alternative('{"is": "allowed"}', "application/json")
self.message.send()
params = self.get_send_params()
raw_mime = params["Content"]["Raw"]["Data"]
raw_mime = params["RawMessage"]["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)
# (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 a single one for the FromEmailAddress
self.message.from_email = "First <from1@example.com>, from2@example.com"
with self.assertRaisesMessage(
AnymailUnsupportedFeature, "multiple from emails"
):
# 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):
"""
@@ -318,60 +330,47 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
sent_message = self.get_sent_message()
self.assertEqual(sent_message["Subject"], self.message.subject)
def test_no_cte_8bit(self):
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)
# 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`.
# 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 for them.)
part_encodings = [
# 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(
part_encodings,
text_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": {
"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": "c44b0ae2-e086-45ca-8820-b76a9b9f430a",
"HTTPStatusCode": 403,
"RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb",
"HTTPStatusCode": 400,
"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",
"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,
},
@@ -454,10 +453,7 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
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",
)
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
@@ -466,18 +462,15 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
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>"]},
)
raw_mime = params["RawMessage"]["Data"]
self.assertEqual(params["Destinations"], ["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):
# (that \n is a header-injection test)
self.message.metadata = {
"User ID": 12345,
# that \n is a header-injection test:
"items": "Correct horse,Battery,\nStaple",
"Cart-Total": "22.70",
}
@@ -520,9 +513,7 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
self.message.tags = ["Welcome"]
self.message.send()
params = self.get_send_params()
self.assertEqual(
params["EmailTags"], [{"Name": "Campaign", "Value": "Welcome"}]
)
self.assertEqual(params["Tags"], [{"Name": "Campaign", "Value": "Welcome"}])
# Multiple Anymail tags are not supported when using this feature
self.message.tags = ["Welcome", "Variation_A"]
@@ -565,25 +556,22 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
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
"""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 = {
"BulkEmailEntryResults": [
"Status": [
{
"Status": "SUCCESS",
"Status": "Success",
"MessageId": "1111111111111111-bbbbbbbb-3333-7777",
},
{
"Status": "ACCOUNT_DAILY_QUOTA_EXCEEDED",
"Error": "Daily message quota exceeded",
},
{"Status": "AccountThrottled"},
],
"ResponseMetadata": self.DEFAULT_SEND_RESPONSE["ResponseMetadata"],
}
self.set_mock_response(raw_response, operation_name="send_bulk_email")
self.set_mock_response(raw_response, operation_name="send_bulk_templated_email")
message = AnymailMessage(
template_id="welcome_template",
from_email='"Example, Inc." <from@example.com>',
@@ -598,40 +586,30 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
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",
envelope_sender="bounces@example.com",
esp_extra={
"FromEmailAddressIdentityArn": (
"arn:aws:ses:us-east-1:123456789012:identity/example.com"
)
"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_email")
params = self.get_send_params(operation_name="send_bulk_email")
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." <from@example.com>')
destinations = params["Destinations"]
self.assertEqual(len(destinations), 2)
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"],
destinations[0]["Destination"],
{"ToAddresses": ["alice@example.com"], "CcAddresses": ["cc@example.com"]},
)
self.assertEqual(
json.loads(
bulk_entries[0]["ReplacementEmailContent"]["ReplacementTemplate"][
"ReplacementTemplateData"
]
),
json.loads(destinations[0]["ReplacementTemplateData"]),
{"name": "Alice", "group": "Developers"},
)
self.assertEqual(
bulk_entries[1]["Destination"],
destinations[1]["Destination"],
{
# SES requires RFC2047:
"ToAddresses": ["=?utf-8?b?572X5Lyv54m5?= <bob@example.com>"],
@@ -639,15 +617,10 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
},
)
self.assertEqual(
json.loads(
bulk_entries[1]["ReplacementEmailContent"]["ReplacementTemplate"][
"ReplacementTemplateData"
]
),
{"name": "Bob"},
json.loads(destinations[1]["ReplacementTemplateData"]), {"name": "Bob"}
)
self.assertEqual(
json.loads(params["DefaultContent"]["Template"]["TemplateData"]),
json.loads(params["DefaultTemplateData"]),
{"group": "Users", "site": "ExampleCo"},
)
self.assertEqual(
@@ -655,14 +628,12 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
["reply1@example.com", "Reply 2 <reply2@example.com>"],
)
self.assertEqual(
params["DefaultEmailTags"],
[{"Name": "Campaign", "Value": "WelcomeVariantA"}],
params["DefaultTags"], [{"Name": "Campaign", "Value": "WelcomeVariantA"}]
)
self.assertEqual(params["FeedbackForwardingEmailAddress"], "bounce@example.com")
# esp_extra:
self.assertEqual(params["ReturnPath"], "bounces@example.com")
self.assertEqual(
params["FromEmailAddressIdentityArn"],
"arn:aws:ses:us-east-1:123456789012:identity/example.com",
params["SourceArn"],
"arn:aws:ses:us-east-1:123456789012:identity/example.com", # esp_extra
)
self.assertEqual(message.anymail_status.status, {"queued", "failed"})
@@ -686,30 +657,6 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
)
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"])
@@ -760,14 +707,14 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
message.tags = None
def test_send_anymail_message_without_template(self):
# Make sure SendEmail is used for non-template_id messages
# 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_email")
# fails if send_email not called:
self.get_send_params(operation_name="send_email")
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.
@@ -778,17 +725,20 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
"""
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("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:
@@ -856,6 +806,13 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
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
@@ -922,7 +879,7 @@ class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase):
boto_config = Config(connect_timeout=30)
conn = mail.get_connection(
"anymail.backends.amazon_sesv2.EmailBackend",
"anymail.backends.amazon_sesv1.EmailBackend",
client_params={
"aws_session_token": "test-session-token",
"config": boto_config,

View File

@@ -1,6 +1,5 @@
import os
import unittest
import warnings
from email.utils import formataddr
from django.test import SimpleTestCase, override_settings, tag
@@ -68,7 +67,7 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
def setUp(self):
super().setUp()
self.from_email = "test@%s" % ANYMAIL_TEST_AMAZON_SES_DOMAIN
self.from_email = f"test@{ANYMAIL_TEST_AMAZON_SES_DOMAIN}"
self.message = AnymailMessage(
"Anymail Amazon SES integration test",
"Text content",
@@ -77,16 +76,6 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
)
self.message.attach_alternative("<p>HTML content</p>", "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 <ssl\.SSLSocket", category=ResourceWarning
)
def test_simple_send(self):
# Example of getting the Amazon SES send status and message id from the message
sent_count = self.message.send()
@@ -129,6 +118,7 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
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")
@@ -149,14 +139,16 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
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": "<h1>Dear {{name}}:</h1>"
# 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>",
# "TextPart": "Dear {{name}}:\r\n"
# "Text": "Dear {{name}}:\r\n"
# "Your order {{order}} shipped {{ship_date}}."
# })
# },
# )
message = AnymailMessage(
template_id="TestTemplate",
from_email=formataddr(("Test From", self.from_email)),

View File

@@ -1,5 +1,6 @@
import os
import unittest
import warnings
from email.utils import formataddr
from django.test import SimpleTestCase, override_settings, tag
@@ -30,7 +31,7 @@ ANYMAIL_TEST_AMAZON_SES_DOMAIN = os.getenv("ANYMAIL_TEST_AMAZON_SES_DOMAIN")
" environment variables to run Amazon SES integration tests",
)
@override_settings(
EMAIL_BACKEND="anymail.backends.amazon_sesv2.EmailBackend",
EMAIL_BACKEND="anymail.backends.amazon_sesv1.EmailBackend",
ANYMAIL={
"AMAZON_SES_CLIENT_PARAMS": {
# This setting provides Anymail-specific AWS credentials to boto3.client(),
@@ -67,7 +68,7 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
def setUp(self):
super().setUp()
self.from_email = f"test@{ANYMAIL_TEST_AMAZON_SES_DOMAIN}"
self.from_email = "test@%s" % ANYMAIL_TEST_AMAZON_SES_DOMAIN
self.message = AnymailMessage(
"Anymail Amazon SES integration test",
"Text content",
@@ -76,6 +77,16 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
)
self.message.attach_alternative("<p>HTML content</p>", "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 <ssl\.SSLSocket", category=ResourceWarning
)
def test_simple_send(self):
# Example of getting the Amazon SES send status and message id from the message
sent_count = self.message.send()
@@ -118,7 +129,6 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
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")
@@ -139,16 +149,14 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
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>"
# boto3.client('ses').create_template(Template={
# "TemplateName": "TestTemplate",
# "SubjectPart": "Your order {{order}} shipped",
# "HtmlPart": "<h1>Dear {{name}}:</h1>"
# "<p>Your order {{order}} shipped {{ship_date}}.</p>",
# "Text": "Dear {{name}}:\r\n"
# "TextPart": "Dear {{name}}:\r\n"
# "Your order {{order}} shipped {{ship_date}}."
# },
# )
# })
message = AnymailMessage(
template_id="TestTemplate",
from_email=formataddr(("Test From", self.from_email)),