mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
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:
@@ -33,6 +33,18 @@ vNext
|
|||||||
Breaking changes
|
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
|
* **Amazon SES:** The "extra name" for installation must now be spelled with
|
||||||
a hyphen rather than an underscore: ``django-anymail[amazon-ses]``.
|
a hyphen rather than an underscore: ``django-anymail[amazon-ses]``.
|
||||||
Be sure to update any dependencies specification (pip install, requirements.txt,
|
Be sure to update any dependencies specification (pip install, requirements.txt,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from email.charset import QP, Charset
|
import email.charset
|
||||||
from email.mime.text import MIMEText
|
import email.encoders
|
||||||
|
import email.policy
|
||||||
|
|
||||||
from .._version import __version__
|
from .. import __version__ as ANYMAIL_VERSION
|
||||||
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
|
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
|
||||||
from ..message import AnymailRecipientStatus
|
from ..message import AnymailRecipientStatus
|
||||||
from ..utils import UNSET, get_anymail_setting
|
from ..utils import UNSET, get_anymail_setting
|
||||||
@@ -23,7 +24,7 @@ BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError)
|
|||||||
|
|
||||||
class EmailBackend(AnymailBaseBackend):
|
class EmailBackend(AnymailBaseBackend):
|
||||||
"""
|
"""
|
||||||
Amazon SES Email Backend (using boto3)
|
Amazon SES v2 Email Backend (using boto3)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
esp_name = "Amazon SES"
|
esp_name = "Amazon SES"
|
||||||
@@ -34,7 +35,7 @@ class EmailBackend(AnymailBaseBackend):
|
|||||||
# AMAZON_SES_CLIENT_PARAMS is optional
|
# AMAZON_SES_CLIENT_PARAMS is optional
|
||||||
# (boto3 can find credentials several other ways)
|
# (boto3 can find credentials several other ways)
|
||||||
self.session_params, self.client_params = _get_anymail_boto3_params(
|
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(
|
self.configuration_set_name = get_anymail_setting(
|
||||||
"configuration_set_name",
|
"configuration_set_name",
|
||||||
@@ -50,6 +51,7 @@ class EmailBackend(AnymailBaseBackend):
|
|||||||
allow_bare=False,
|
allow_bare=False,
|
||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.client = None
|
self.client = None
|
||||||
|
|
||||||
def open(self):
|
def open(self):
|
||||||
@@ -57,7 +59,7 @@ class EmailBackend(AnymailBaseBackend):
|
|||||||
return False # already exists
|
return False # already exists
|
||||||
try:
|
try:
|
||||||
self.client = boto3.session.Session(**self.session_params).client(
|
self.client = boto3.session.Session(**self.session_params).client(
|
||||||
"ses", **self.client_params
|
"sesv2", **self.client_params
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
if not self.fail_silently:
|
if not self.fail_silently:
|
||||||
@@ -68,7 +70,7 @@ class EmailBackend(AnymailBaseBackend):
|
|||||||
def close(self):
|
def close(self):
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
return
|
return
|
||||||
# self.client.close() # boto3 doesn't support (or require) client shutdown
|
self.client.close()
|
||||||
self.client = None
|
self.client = None
|
||||||
|
|
||||||
def _send(self, message):
|
def _send(self, message):
|
||||||
@@ -88,16 +90,24 @@ class EmailBackend(AnymailBaseBackend):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def build_message_payload(self, message, defaults):
|
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:
|
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:
|
else:
|
||||||
return AmazonSESSendRawEmailPayload(message, defaults, self)
|
return AmazonSESV2SendEmailPayload(message, defaults, self)
|
||||||
|
|
||||||
def post_to_esp(self, payload, message):
|
def post_to_esp(self, payload, message):
|
||||||
|
payload.finalize_payload()
|
||||||
try:
|
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:
|
except BOTO_BASE_ERRORS as err:
|
||||||
# ClientError has a response attr with parsed json error response
|
# ClientError has a response attr with parsed json error response
|
||||||
# (other errors don't)
|
# (other errors don't)
|
||||||
@@ -115,64 +125,70 @@ class EmailBackend(AnymailBaseBackend):
|
|||||||
|
|
||||||
|
|
||||||
class AmazonSESBasePayload(BasePayload):
|
class AmazonSESBasePayload(BasePayload):
|
||||||
|
#: Name of the boto3 SES/SESv2 client method to call
|
||||||
|
api_name = "SUBCLASS_MUST_OVERRIDE"
|
||||||
|
|
||||||
def init_payload(self):
|
def init_payload(self):
|
||||||
self.params = {}
|
self.params = {}
|
||||||
if self.backend.configuration_set_name is not None:
|
if self.backend.configuration_set_name is not None:
|
||||||
self.params["ConfigurationSetName"] = self.backend.configuration_set_name
|
self.params["ConfigurationSetName"] = self.backend.configuration_set_name
|
||||||
|
|
||||||
def call_send_api(self, ses_client):
|
def finalize_payload(self):
|
||||||
raise NotImplementedError()
|
pass
|
||||||
|
|
||||||
def parse_recipient_status(self, response):
|
def parse_recipient_status(self, response):
|
||||||
# response is the parsed (dict) JSON returned from the API call
|
# response is the parsed (dict) JSON returned from the API call
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def set_esp_extra(self, extra):
|
def set_esp_extra(self, extra):
|
||||||
# e.g., ConfigurationSetName, FromArn, SourceArn, ReturnPathArn
|
# e.g., ConfigurationSetName, FromEmailAddressIdentityArn,
|
||||||
|
# FeedbackForwardingEmailAddress, ListManagementOptions
|
||||||
self.params.update(extra)
|
self.params.update(extra)
|
||||||
|
|
||||||
|
|
||||||
class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
class AmazonSESV2SendEmailPayload(AmazonSESBasePayload):
|
||||||
|
api_name = "send_email"
|
||||||
|
|
||||||
def init_payload(self):
|
def init_payload(self):
|
||||||
super().init_payload()
|
super().init_payload()
|
||||||
self.all_recipients = []
|
self.all_recipients = [] # for parse_recipient_status
|
||||||
self.mime_message = self.message.message()
|
self.mime_message = self.message.message()
|
||||||
|
|
||||||
# Work around an Amazon SES bug where, if all of:
|
def finalize_payload(self):
|
||||||
# - the message body (text or html) contains non-ASCII characters
|
# (The boto3 SES client handles base64 encoding raw_message.)
|
||||||
# - the body is sent with `Content-Transfer-Encoding: 8bit`
|
raw_message = self.generate_raw_message()
|
||||||
# (which is Django email's default for most non-ASCII bodies)
|
self.params["Content"] = {"Raw": {"Data": raw_message}}
|
||||||
# - 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):
|
def generate_raw_message(self):
|
||||||
# Set Destinations to make sure we pick up all recipients (including bcc).
|
"""
|
||||||
# Any non-ASCII characters in recipient domains must be encoded with Punycode.
|
Serialize self.mime_message as an RFC-5322/-2045 MIME message,
|
||||||
# (Amazon SES doesn't support non-ASCII recipient usernames.)
|
encoded as 7bit-clean, us-ascii byte data.
|
||||||
self.params["Destinations"] = [email.address for email in self.all_recipients]
|
"""
|
||||||
self.params["RawMessage"] = {"Data": self.mime_message.as_bytes()}
|
# Amazon SES does not support `Content-Transfer-Encoding: 8bit`. And using 8bit
|
||||||
return ses_client.send_raw_email(**self.params)
|
# 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):
|
def parse_recipient_status(self, response):
|
||||||
try:
|
try:
|
||||||
message_id = response["MessageId"]
|
message_id = response["MessageId"]
|
||||||
except (KeyError, TypeError) as err:
|
except (KeyError, TypeError) as err:
|
||||||
raise AnymailAPIError(
|
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,
|
backend=self.backend,
|
||||||
email_message=self.message,
|
email_message=self.message,
|
||||||
payload=self,
|
payload=self,
|
||||||
@@ -194,29 +210,39 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
|||||||
# merged/computed value isn't forced back into the EmailMessage.
|
# merged/computed value isn't forced back into the EmailMessage.
|
||||||
if attr in self.defaults:
|
if attr in self.defaults:
|
||||||
self.unsupported_feature(
|
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):
|
def set_from_email(self, email):
|
||||||
# Although Amazon SES will send messages with any From header, it can only parse
|
# If params["FromEmailAddress"] is not provided, SES will parse it from the raw
|
||||||
# Source if the From header is a single email. Explicit Source avoids an
|
# mime_message headers. (And setting it replaces any From header. Note that
|
||||||
# "Illegal address" error:
|
# v2 SendEmail doesn't have an equivalent to v1 SendRawEmail's Sender param.)
|
||||||
if len(emails) > 1:
|
self._no_send_defaults("from_email")
|
||||||
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):
|
def set_recipients(self, recipient_type, emails):
|
||||||
self.all_recipients += emails
|
# Although Amazon SES can parse the 'to' and 'cc' recipients from the raw
|
||||||
# included in mime_message
|
# mime_message headers, providing them in the Destination param makes it
|
||||||
assert recipient_type in ("to", "cc", "bcc")
|
# 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)
|
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):
|
def set_subject(self, subject):
|
||||||
# included in mime_message
|
# included in mime_message
|
||||||
self._no_send_defaults("subject")
|
self._no_send_defaults("subject")
|
||||||
|
|
||||||
def set_reply_to(self, emails):
|
def set_reply_to(self, emails):
|
||||||
# included in mime_message
|
# included in mime_message
|
||||||
|
# (and setting params["ReplyToAddresses"] replaces any Reply-To header)
|
||||||
self._no_send_defaults("reply_to")
|
self._no_send_defaults("reply_to")
|
||||||
|
|
||||||
def set_extra_headers(self, headers):
|
def set_extra_headers(self, headers):
|
||||||
@@ -240,16 +266,18 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
|||||||
self._no_send_defaults("attachments")
|
self._no_send_defaults("attachments")
|
||||||
|
|
||||||
# Anymail-specific payload construction
|
# Anymail-specific payload construction
|
||||||
|
|
||||||
def set_envelope_sender(self, email):
|
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):
|
def set_spoofed_to_header(self, header_to):
|
||||||
# django.core.mail.EmailMessage.message() has already set
|
# django.core.mail.EmailMessage.message() has already set
|
||||||
# self.mime_message["To"] = header_to
|
# self.mime_message["To"] = header_to
|
||||||
# and performed any necessary header sanitization.
|
# and performed any necessary header sanitization.
|
||||||
#
|
#
|
||||||
# The actual "to" is already in self.all_recipients,
|
# The actual "to" is already in params["Destination"]["ToAddresses"].
|
||||||
# which is used as the SendRawEmail Destinations later.
|
|
||||||
#
|
#
|
||||||
# So, nothing to do here, except prevent the default
|
# So, nothing to do here, except prevent the default
|
||||||
# "unsupported feature" error.
|
# "unsupported feature" error.
|
||||||
@@ -288,13 +316,13 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
|||||||
self.unsupported_feature(
|
self.unsupported_feature(
|
||||||
"multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting"
|
"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]}
|
{"Name": self.backend.message_tag_name, "Value": tags[0]}
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_template_id(self, template_id):
|
def set_template_id(self, template_id):
|
||||||
raise NotImplementedError(
|
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):
|
def set_merge_data(self, merge_data):
|
||||||
@@ -304,15 +332,19 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
|||||||
self.unsupported_feature("global_merge_data without template_id")
|
self.unsupported_feature("global_merge_data without template_id")
|
||||||
|
|
||||||
|
|
||||||
class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
class AmazonSESV2SendBulkEmailPayload(AmazonSESBasePayload):
|
||||||
|
api_name = "send_bulk_email"
|
||||||
|
|
||||||
def init_payload(self):
|
def init_payload(self):
|
||||||
super().init_payload()
|
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.recipients = {"to": [], "cc": [], "bcc": []}
|
||||||
self.merge_data = {}
|
self.merge_data = {}
|
||||||
|
|
||||||
def call_send_api(self, ses_client):
|
def finalize_payload(self):
|
||||||
# include any 'cc' or 'bcc' in every destination
|
# Build BulkEmailEntries from recipients and merge_data.
|
||||||
|
|
||||||
|
# Any cc and bcc recipients should be included in every entry:
|
||||||
cc_and_bcc_addresses = {}
|
cc_and_bcc_addresses = {}
|
||||||
if self.recipients["cc"]:
|
if self.recipients["cc"]:
|
||||||
cc_and_bcc_addresses["CcAddresses"] = [
|
cc_and_bcc_addresses["CcAddresses"] = [
|
||||||
@@ -323,55 +355,75 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
|||||||
bcc.address for bcc in self.recipients["bcc"]
|
bcc.address for bcc in self.recipients["bcc"]
|
||||||
]
|
]
|
||||||
|
|
||||||
# set up destination and data for each 'to'
|
# Construct an entry with merge data for each "to" recipient:
|
||||||
self.params["Destinations"] = [
|
self.params["BulkEmailEntries"] = [
|
||||||
{
|
{
|
||||||
"Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses),
|
"Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses),
|
||||||
|
"ReplacementEmailContent": {
|
||||||
|
"ReplacementTemplate": {
|
||||||
"ReplacementTemplateData": self.serialize_json(
|
"ReplacementTemplateData": self.serialize_json(
|
||||||
self.merge_data.get(to.addr_spec, {})
|
self.merge_data.get(to.addr_spec, {})
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
for to in self.recipients["to"]
|
for to in self.recipients["to"]
|
||||||
]
|
]
|
||||||
|
|
||||||
return ses_client.send_bulk_templated_email(**self.params)
|
|
||||||
|
|
||||||
def parse_recipient_status(self, response):
|
def parse_recipient_status(self, response):
|
||||||
try:
|
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 = [
|
anymail_statuses = [
|
||||||
AnymailRecipientStatus(
|
AnymailRecipientStatus(
|
||||||
message_id=status.get("MessageId", None),
|
message_id=result.get("MessageId", None),
|
||||||
status="queued" if status.get("Status") == "Success" else "failed",
|
status="queued" if result["Status"] == "SUCCESS" else "failed",
|
||||||
)
|
)
|
||||||
for status in response["Status"]
|
for result in results
|
||||||
]
|
]
|
||||||
except (KeyError, TypeError) as err:
|
except (KeyError, TypeError) as err:
|
||||||
raise AnymailAPIError(
|
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,
|
backend=self.backend,
|
||||||
email_message=self.message,
|
email_message=self.message,
|
||||||
payload=self,
|
payload=self,
|
||||||
) from None
|
) 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"]]
|
to_addrs = [to.addr_spec for to in self.recipients["to"]]
|
||||||
if len(anymail_statuses) != len(to_addrs):
|
if len(anymail_statuses) != len(to_addrs):
|
||||||
raise AnymailAPIError(
|
raise AnymailAPIError(
|
||||||
"Sent to %d destinations, but only %d statuses in Amazon SES"
|
f"Sent to {len(to_addrs)} destinations,"
|
||||||
" send result %r" % (len(to_addrs), len(anymail_statuses), response),
|
f" but only {len(anymail_statuses)} statuses"
|
||||||
|
f" in Amazon SES send result {response!r}",
|
||||||
backend=self.backend,
|
backend=self.backend,
|
||||||
email_message=self.message,
|
email_message=self.message,
|
||||||
payload=self,
|
payload=self,
|
||||||
)
|
)
|
||||||
|
|
||||||
return dict(zip(to_addrs, anymail_statuses))
|
return dict(zip(to_addrs, anymail_statuses))
|
||||||
|
|
||||||
def set_from_email(self, email):
|
def set_from_email(self, email):
|
||||||
# this will RFC2047-encode display_name if needed:
|
# 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):
|
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")
|
assert recipient_type in ("to", "cc", "bcc")
|
||||||
self.recipients[recipient_type] = emails
|
self.recipients[recipient_type] = emails
|
||||||
|
|
||||||
@@ -400,24 +452,27 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
|||||||
self.unsupported_feature("attachments with template")
|
self.unsupported_feature("attachments with template")
|
||||||
|
|
||||||
# Anymail-specific payload construction
|
# Anymail-specific payload construction
|
||||||
|
|
||||||
def set_envelope_sender(self, email):
|
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):
|
def set_metadata(self, metadata):
|
||||||
# no custom headers with SendBulkTemplatedEmail
|
# no custom headers with SendBulkEmail
|
||||||
self.unsupported_feature("metadata with template")
|
self.unsupported_feature("metadata with template")
|
||||||
|
|
||||||
def set_tags(self, tags):
|
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
|
# AMAZON_SES_MESSAGE_TAG_NAME if used (see tags/metadata in
|
||||||
# AmazonSESSendRawEmailPayload for more info)
|
# AmazonSESV2SendEmailPayload for more info)
|
||||||
if tags:
|
if tags:
|
||||||
if self.backend.message_tag_name is not None:
|
if self.backend.message_tag_name is not None:
|
||||||
if len(tags) > 1:
|
if len(tags) > 1:
|
||||||
self.unsupported_feature(
|
self.unsupported_feature(
|
||||||
"multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting"
|
"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]}
|
{"Name": self.backend.message_tag_name, "Value": tags[0]}
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
@@ -427,14 +482,20 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def set_template_id(self, template_id):
|
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):
|
def set_merge_data(self, merge_data):
|
||||||
# late-bound in call_send_api
|
# late-bound in finalize_payload
|
||||||
self.merge_data = merge_data
|
self.merge_data = merge_data
|
||||||
|
|
||||||
def set_merge_global_data(self, merge_global_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):
|
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
|
client_params = client_params.copy() # don't modify source
|
||||||
config = Config(
|
config = Config(
|
||||||
user_agent_extra="django-anymail/{version}-{esp}".format(
|
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:
|
if "config" in client_params:
|
||||||
|
|||||||
449
anymail/backends/amazon_sesv1.py
Normal file
449
anymail/backends/amazon_sesv1.py
Normal 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)
|
||||||
@@ -1,536 +1,14 @@
|
|||||||
import email.charset
|
import warnings
|
||||||
import email.encoders
|
|
||||||
import email.policy
|
|
||||||
|
|
||||||
from .._version import __version__
|
from ..exceptions import AnymailDeprecationWarning
|
||||||
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
|
from .amazon_ses import EmailBackend as AmazonSESV2EmailBackend
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# boto3 has several root exception classes; this is meant to cover all of them
|
class EmailBackend(AmazonSESV2EmailBackend):
|
||||||
BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError)
|
|
||||||
|
|
||||||
|
|
||||||
class EmailBackend(AnymailBaseBackend):
|
|
||||||
"""
|
|
||||||
Amazon SES v2 Email Backend (using boto3)
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Deliberately no "v2" in the esp_name: we don't want to force client changes
|
|
||||||
# when the v1 backend is retired and this one replace it as just "amazon_ses".
|
|
||||||
esp_name = "Amazon SES"
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
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)
|
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
|
|
||||||
|
|||||||
@@ -6,17 +6,16 @@ Amazon SES
|
|||||||
Anymail integrates with the `Amazon Simple Email Service`_ (SES) using the `Boto 3`_
|
Anymail integrates with the `Amazon Simple Email Service`_ (SES) using the `Boto 3`_
|
||||||
AWS SDK for Python, and supports sending, tracking, and inbound receiving capabilities.
|
AWS SDK for Python, and supports sending, tracking, and inbound receiving capabilities.
|
||||||
|
|
||||||
.. versionchanged:: 9.1
|
.. versionchanged:: 10.0
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
AWS has two versions of the SES API available for sending email. Anymail 9.0
|
AWS has two versions of the SES API available for sending email. Anymail 10.0
|
||||||
and earlier used the SES v1 API. Anymail 9.1 supports both SES v1 and v2, but
|
uses the newer SES v2 API by default, and this is recommended for new projects.
|
||||||
support for the v1 API is now deprecated and will be removed in a future Anymail
|
|
||||||
release (likely in late 2023).
|
|
||||||
|
|
||||||
For new projects, you should use the SES v2 API. For existing projects that are
|
If you integrated Amazon SES using an earlier Anymail release, you may need to
|
||||||
using the SES v1 API, see :ref:`amazon-ses-v2` below.
|
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
|
.. sidebar:: Alternatives
|
||||||
@@ -46,18 +45,16 @@ or separately run ``pip install boto3``.
|
|||||||
|
|
||||||
In earlier releases, the "extra name" could use an underscore
|
In earlier releases, the "extra name" could use an underscore
|
||||||
(``django-anymail[amazon_ses]``). That now causes pip to warn
|
(``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.
|
and may result in a broken installation that is missing boto3.
|
||||||
|
|
||||||
To send mail with Anymail's Amazon SES backend, set:
|
To send mail with Anymail's Amazon SES backend, set:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
EMAIL_BACKEND = "anymail.backends.amazon_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
|
in your settings.py.
|
||||||
``amazon_sesv2`` with just ``amazon_ses``---but be aware SES v1 support is
|
|
||||||
deprecated and will be dropped in a near-future Anymail release.)
|
|
||||||
|
|
||||||
In addition, you must make sure boto3 is configured with AWS credentials having the
|
In addition, you must make sure boto3 is configured with AWS credentials having the
|
||||||
necessary :ref:`amazon-ses-iam-permissions`.
|
necessary :ref:`amazon-ses-iam-permissions`.
|
||||||
@@ -75,16 +72,15 @@ setting to customize the Boto session.
|
|||||||
Migrating to the SES v2 API
|
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)
|
Anymail 10.0 uses Amazon's updated SES v2 API to send email. Earlier Anymail releases
|
||||||
to the updated SES v2 API. Although the capabilities of the two API versions are virtually
|
used the original Amazon SES API (v1) by default. Although the capabilities of the two
|
||||||
identical, Amazon is implementing SES improvements (such as increased maximum message size)
|
SES versions are virtually identical, Amazon is implementing improvements (such as
|
||||||
only in the v2 API.
|
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
|
(The upgrade for SES v2 affects only sending email. There are no changes required
|
||||||
older SES v1 API. Your code will continue to work with Anymail 9.1, but SES v1 support
|
for status tracking webhooks or receiving inbound email.)
|
||||||
is now deprecated and will be removed in a future Anymail release (likely in late 2023).
|
|
||||||
|
|
||||||
Migrating to SES v2 requires minimal code changes:
|
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`
|
2. If your code uses Anymail's :attr:`~anymail.message.AnymailMessage.esp_extra`
|
||||||
to pass additional SES API parameters, or examines the raw
|
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
|
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
|
in the v2 API compared to the equivalent v1 calls, and the response formats are
|
||||||
slightly different.
|
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
|
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`_.
|
with SES templates, compare v1 `SendBulkTemplatedEmail`_ to v2 `SendBulkEmail`_.
|
||||||
|
|
||||||
3. In your settings.py, update the :setting:`!EMAIL_BACKEND` to use ``amazon_sesv2``.
|
.. _SendRawEmail:
|
||||||
Change this:
|
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
|
.. code-block:: python
|
||||||
|
|
||||||
EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend" # SES v1
|
EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend" # default SES v2
|
||||||
|
|
||||||
to this:
|
to this:
|
||||||
|
|
||||||
.. code-block:: python
|
.. 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
|
Note that SES v1 support is deprecated and will be removed in a future Anymail release
|
||||||
for status tracking webhooks or receiving inbound email.
|
(likely in late 2023).
|
||||||
|
|
||||||
|
|
||||||
.. _amazon-ses-quirks:
|
.. _amazon-ses-quirks:
|
||||||
@@ -177,8 +185,7 @@ Limitations and quirks
|
|||||||
|
|
||||||
**Envelope-sender is forwarded**
|
**Envelope-sender is forwarded**
|
||||||
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` becomes
|
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` becomes
|
||||||
Amazon SES's ``FeedbackForwardingEmailAddress`` (for the SES v2 API; or for SES v1
|
Amazon SES's ``FeedbackForwardingEmailAddress``. That address will receive bounce and other
|
||||||
either ``Source`` or ``ReturnPath``). That address will receive bounce and other
|
|
||||||
delivery notifications, but will not appear in the message sent to the recipient.
|
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
|
SES always generates its own anonymized envelope sender (mailfrom) for each outgoing
|
||||||
message, and then forwards that address to your envelope-sender. See
|
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
|
To use Amazon SES features not directly supported by Anymail, you can
|
||||||
set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to
|
set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to
|
||||||
a `dict` that will be shallow-merged into the params for the `SendEmail`_
|
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,
|
or `SendBulkEmail`_ SES v2 API call.
|
||||||
`SendRawEmail`_ or `SendBulkTemplatedEmail`_.)
|
|
||||||
|
|
||||||
Examples (for a non-template send using the SES v2 API):
|
Examples (for a non-template send):
|
||||||
|
|
||||||
.. code-block:: python
|
.. 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
|
https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html
|
||||||
.. _SendBulkEmail:
|
.. _SendBulkEmail:
|
||||||
https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendBulkEmail.html
|
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:
|
.. _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`
|
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`_
|
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`
|
from Anymail's normalized :attr:`~anymail.message.AnymailMessage.merge_data`
|
||||||
and :attr:`~anymail.message.AnymailMessage.merge_global_data`
|
and :attr:`~anymail.message.AnymailMessage.merge_global_data`
|
||||||
message attributes.
|
message attributes.
|
||||||
@@ -752,16 +754,11 @@ IAM permissions
|
|||||||
|
|
||||||
Anymail requires IAM permissions that will allow it to use these actions:
|
Anymail requires IAM permissions that will allow it to use these actions:
|
||||||
|
|
||||||
* To send mail with the SES v2 API:
|
* To send mail:
|
||||||
|
|
||||||
* Ordinary (non-templated) sends: ``ses:SendEmail``
|
* Ordinary (non-templated) sends: ``ses:SendEmail``
|
||||||
* Template/merge sends: ``ses:SendBulkEmail``
|
* 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>`
|
* To :ref:`automatically confirm <amazon-ses-confirm-sns-subscriptions>`
|
||||||
webhook SNS subscriptions: ``sns:ConfirmSubscription``
|
webhook SNS subscriptions: ``sns:ConfirmSubscription``
|
||||||
|
|
||||||
@@ -785,10 +782,6 @@ This IAM policy covers all of those:
|
|||||||
"Effect": "Allow",
|
"Effect": "Allow",
|
||||||
"Action": ["ses:SendEmail", "ses:SendBulkEmail"],
|
"Action": ["ses:SendEmail", "ses:SendBulkEmail"],
|
||||||
"Resource": "*"
|
"Resource": "*"
|
||||||
}, {
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Action": ["ses:SendRawEmail", "ses:SendBulkTemplatedEmail"],
|
|
||||||
"Resource": "*"
|
|
||||||
}, {
|
}, {
|
||||||
"Effect": "Allow",
|
"Effect": "Allow",
|
||||||
"Action": ["sns:ConfirmSubscription"],
|
"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:
|
.. _amazon-ses-iam-errors:
|
||||||
|
|
||||||
.. note:: **Misleading IAM error messages**
|
.. 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
|
Following the principle of `least privilege`_, you should omit permissions
|
||||||
for any features you aren't using, and you may want to add additional restrictions:
|
for any features you aren't using, and you may want to add additional restrictions:
|
||||||
|
|
||||||
* If you are not using the older Amazon SES v1 API, you can omit permissions
|
|
||||||
that allow ``ses:SendRawEmail`` and ``ses:SendBulkTemplatedEmail``. (See
|
|
||||||
:ref:`amazon-ses-v2` above.)
|
|
||||||
|
|
||||||
* For Amazon SES sending, you can add conditions to restrict senders, recipients, times,
|
* For Amazon SES sending, you can add conditions to restrict senders, recipients, times,
|
||||||
or other properties. See Amazon's `Controlling access to Amazon SES`_ guide.
|
or other properties. See Amazon's `Controlling access to Amazon SES`_ guide.
|
||||||
(Be aware that the SES v2 ``SendBulkEmail`` API does not support condition keys
|
(Be aware that the SES v2 ``SendBulkEmail`` API does not support condition keys
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from email.encoders import encode_7or8bit
|
||||||
from email.mime.application import MIMEApplication
|
from email.mime.application import MIMEApplication
|
||||||
from unittest.mock import ANY, patch
|
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 django.test import SimpleTestCase, override_settings, tag
|
||||||
|
|
||||||
from anymail import __version__ as ANYMAIL_VERSION
|
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.inbound import AnymailInboundMessage
|
||||||
from anymail.message import AnymailMessage, attach_inline_image_file
|
from anymail.message import AnymailMessage, attach_inline_image_file
|
||||||
|
|
||||||
@@ -28,7 +33,7 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
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
|
# client operations). (We could also use botocore.stub.Stubber, but mock works
|
||||||
# well with our test structure.)
|
# well with our test structure.)
|
||||||
self.patch_boto3_session = patch(
|
self.patch_boto3_session = patch(
|
||||||
@@ -38,7 +43,7 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
|||||||
self.addCleanup(self.patch_boto3_session.stop)
|
self.addCleanup(self.patch_boto3_session.stop)
|
||||||
#: boto3.session.Session().client
|
#: boto3.session.Session().client
|
||||||
self.mock_client = self.mock_session.return_value.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.mock_client_instance = self.mock_client.return_value
|
||||||
self.set_mock_response()
|
self.set_mock_response()
|
||||||
|
|
||||||
@@ -50,24 +55,25 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
|||||||
DEFAULT_SEND_RESPONSE = {
|
DEFAULT_SEND_RESPONSE = {
|
||||||
"MessageId": "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000",
|
"MessageId": "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000",
|
||||||
"ResponseMetadata": {
|
"ResponseMetadata": {
|
||||||
"RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb",
|
"RequestId": "900dd7f3-0399-4a1b-9d9f-bed91f46924a",
|
||||||
"HTTPStatusCode": 200,
|
"HTTPStatusCode": 200,
|
||||||
"HTTPHeaders": {
|
"HTTPHeaders": {
|
||||||
"x-amzn-requestid": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb",
|
"date": "Tue, 21 Feb 2023 22:59:46 GMT",
|
||||||
"content-type": "text/xml",
|
"content-type": "application/json",
|
||||||
"content-length": "338",
|
"content-length": "76",
|
||||||
"date": "Sat, 17 Mar 2018 03:33:33 GMT",
|
"connection": "keep-alive",
|
||||||
|
"x-amzn-requestid": "900dd7f3-0399-4a1b-9d9f-bed91f46924a",
|
||||||
},
|
},
|
||||||
"RetryAttempts": 0,
|
"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 = getattr(self.mock_client_instance, operation_name)
|
||||||
mock_operation.return_value = response or self.DEFAULT_SEND_RESPONSE
|
mock_operation.return_value = response or self.DEFAULT_SEND_RESPONSE
|
||||||
return mock_operation.return_value
|
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
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
mock_operation = getattr(self.mock_client_instance, operation_name)
|
mock_operation = getattr(self.mock_client_instance, operation_name)
|
||||||
@@ -85,7 +91,7 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
|||||||
)
|
)
|
||||||
return kwargs
|
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
|
"""Returns kwargs params passed to mock boto3 client constructor
|
||||||
|
|
||||||
Fails test if boto3 client wasn't constructed with named service
|
Fails test if boto3 client wasn't constructed with named service
|
||||||
@@ -103,12 +109,12 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
|||||||
)
|
)
|
||||||
return kwargs
|
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.
|
"""Returns kwargs params passed to the mock send API.
|
||||||
|
|
||||||
Fails test if API wasn't called.
|
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)
|
mock_operation = getattr(self.mock_client_instance, operation_name)
|
||||||
if mock_operation.call_args is None:
|
if mock_operation.call_args is None:
|
||||||
raise AssertionError("API was not called")
|
raise AssertionError("API was not called")
|
||||||
@@ -116,17 +122,13 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
|||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_sent_message(self):
|
def get_sent_message(self):
|
||||||
"""Returns a parsed version of the send_raw_email RawMessage.Data param"""
|
"""Returns a parsed version of the send_email Content.Raw.Data param"""
|
||||||
|
params = self.get_send_params(operation_name="send_email")
|
||||||
params = self.get_send_params(
|
raw_mime = params["Content"]["Raw"]["Data"]
|
||||||
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)
|
parsed = AnymailInboundMessage.parse_raw_mime_bytes(raw_mime)
|
||||||
return parsed
|
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)
|
mock_operation = getattr(self.mock_client_instance, operation_name)
|
||||||
if mock_operation.called:
|
if mock_operation.called:
|
||||||
raise AssertionError(msg or "ESP API was called and shouldn't have been")
|
raise AssertionError(msg or "ESP API was called and shouldn't have been")
|
||||||
@@ -146,16 +148,16 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
|
|||||||
fail_silently=False,
|
fail_silently=False,
|
||||||
)
|
)
|
||||||
params = self.get_send_params()
|
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:
|
# This is a simple (if inexact) way to check for expected headers and body:
|
||||||
raw_mime = params["RawMessage"]["Data"]
|
raw_mime = params["Content"]["Raw"]["Data"]
|
||||||
self.assertIsInstance(raw_mime, bytes) # SendRawEmail expects Data as bytes
|
self.assertIsInstance(raw_mime, bytes) # SendEmail expects Data as bytes
|
||||||
self.assertIn(b"\nFrom: from@example.com\n", raw_mime)
|
self.assertIn(b"\nFrom: from@example.com\n", raw_mime)
|
||||||
self.assertIn(b"\nTo: to@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"\nSubject: Subject here\n", raw_mime)
|
||||||
self.assertIn(b"\n\nHere is the message", raw_mime)
|
self.assertIn(b"\n\nHere is the message", raw_mime)
|
||||||
# Destinations must include all recipients:
|
# Destination must include all recipients:
|
||||||
self.assertEqual(params["Destinations"], ["to@example.com"])
|
self.assertEqual(params["Destination"], {"ToAddresses": ["to@example.com"]})
|
||||||
|
|
||||||
# Since the SES backend generates the MIME message using Django's
|
# Since the SES backend generates the MIME message using Django's
|
||||||
# EmailMessage.message().to_string(), there's not really a need
|
# EmailMessage.message().to_string(), there's not really a need
|
||||||
@@ -170,18 +172,18 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
|
|||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
params = self.get_send_params()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
params["Destinations"],
|
params["Destination"],
|
||||||
[
|
{
|
||||||
|
"ToAddresses": [
|
||||||
"to1@example.com",
|
"to1@example.com",
|
||||||
'"Recipient, second" <to2@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:
|
# 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):
|
def test_non_ascii_headers(self):
|
||||||
self.message.subject = "Thử tin nhắn" # utf-8 in subject header
|
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.cc = ["cc@thư.example.com"] # utf-8 in domain
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
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:
|
# Non-ASCII headers must use MIME encoded-word syntax:
|
||||||
self.assertIn(b"\nSubject: =?utf-8?b?VGjhu60gdGluIG5o4bqvbg==?=\n", raw_mime)
|
self.assertIn(b"\nSubject: =?utf-8?b?VGjhu60gdGluIG5o4bqvbg==?=\n", raw_mime)
|
||||||
# Non-ASCII display names as well:
|
# Non-ASCII display names as well:
|
||||||
@@ -201,13 +203,13 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
|
|||||||
# SES doesn't support non-ASCII in the username@ part
|
# SES doesn't support non-ASCII in the username@ part
|
||||||
# (RFC 6531 "SMTPUTF8" extension)
|
# (RFC 6531 "SMTPUTF8" extension)
|
||||||
|
|
||||||
# Destinations must include all recipients:
|
# Destinations must include all recipients (addr-spec only, must use Punycode):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
params["Destinations"],
|
params["Destination"],
|
||||||
[
|
{
|
||||||
"=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= <to@example.com>",
|
"ToAddresses": ["=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= <to@example.com>"],
|
||||||
"cc@xn--th-e0a.example.com",
|
"CcAddresses": ["cc@xn--th-e0a.example.com"],
|
||||||
],
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_attachments(self):
|
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:
|
# Make sure neither the html nor the inline image is treated as an attachment:
|
||||||
params = self.get_send_params()
|
params = self.get_send_params()
|
||||||
raw_mime = params["RawMessage"]["Data"]
|
raw_mime = params["Content"]["Raw"]["Data"]
|
||||||
self.assertNotIn(b"\nContent-Disposition: attachment", raw_mime)
|
self.assertNotIn(b"\nContent-Disposition: attachment", raw_mime)
|
||||||
|
|
||||||
def test_multiple_html_alternatives(self):
|
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.attach_alternative("<p>And so is second</p>", "text/html")
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
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
|
# just check the alternative smade it into the message
|
||||||
# (assume that Django knows how to format them properly)
|
# (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>First html is OK</p>\n", raw_mime)
|
||||||
self.assertIn(b"\n\n<p>And so is second</p>\n", raw_mime)
|
self.assertIn(b"\n\n<p>And so is second</p>\n", raw_mime)
|
||||||
|
|
||||||
def test_alternative(self):
|
def test_alternative(self):
|
||||||
# Non-HTML alternatives *are* allowed
|
# Non-HTML alternatives (including AMP) *are* allowed
|
||||||
self.message.attach_alternative('{"is": "allowed"}', "application/json")
|
self.message.attach_alternative("<p>AMP HTML</p>", "text/x-amp-html")
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
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
|
# just check the alternative made it into the message
|
||||||
# (assume that Django knows how to format it properly)
|
# (assume that Python email knows how to format it properly)
|
||||||
self.assertIn(b"\nContent-Type: application/json\n", raw_mime)
|
self.assertIn(b"\nContent-Type: text/x-amp-html", raw_mime)
|
||||||
|
|
||||||
def test_multiple_from(self):
|
def test_multiple_from(self):
|
||||||
# Amazon allows multiple addresses in the From header,
|
# Amazon allows multiple addresses in the From header,
|
||||||
# but must specify which is Source
|
# but must specify a single one for the FromEmailAddress
|
||||||
self.message.from_email = "from1@example.com, from2@example.com"
|
self.message.from_email = "First <from1@example.com>, from2@example.com"
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
AnymailUnsupportedFeature, "multiple from emails"
|
||||||
|
):
|
||||||
self.message.send()
|
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):
|
def test_commas_in_subject(self):
|
||||||
"""
|
"""
|
||||||
@@ -321,47 +322,60 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
|
|||||||
sent_message = self.get_sent_message()
|
sent_message = self.get_sent_message()
|
||||||
self.assertEqual(sent_message["Subject"], self.message.subject)
|
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."""
|
"""Anymail works around an Amazon SES bug that can corrupt non-ASCII bodies."""
|
||||||
# (see detailed comments in the backend code)
|
# (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.body = "Это text body"
|
||||||
self.message.attach_alternative("<p>Это html body</p>", "text/html")
|
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()
|
self.message.send()
|
||||||
sent_message = self.get_sent_message()
|
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
|
# (Technically, either quoted-printable or base64 would be OK, but base64 text
|
||||||
# parts have a reputation for triggering spam filters, so just require
|
# parts have a reputation for triggering spam filters, so just require
|
||||||
# quoted-printable.)
|
# quoted-printable for them.)
|
||||||
text_part_encodings = [
|
part_encodings = [
|
||||||
(part.get_content_type(), part["Content-Transfer-Encoding"])
|
(part.get_content_type(), part["Content-Transfer-Encoding"])
|
||||||
for part in sent_message.walk()
|
for part in sent_message.walk()
|
||||||
if part.get_content_maintype() == "text"
|
|
||||||
]
|
]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
text_part_encodings,
|
part_encodings,
|
||||||
[
|
[
|
||||||
|
("multipart/mixed", None),
|
||||||
|
("multipart/alternative", None),
|
||||||
("text/plain", "quoted-printable"),
|
("text/plain", "quoted-printable"),
|
||||||
("text/html", "quoted-printable"),
|
("text/html", "quoted-printable"),
|
||||||
|
("text/csv", "quoted-printable"),
|
||||||
|
("application/data", "base64"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_api_failure(self):
|
def test_api_failure(self):
|
||||||
error_response = {
|
error_response = {
|
||||||
"Error": {
|
"Error": {
|
||||||
"Type": "Sender",
|
|
||||||
"Code": "MessageRejected",
|
"Code": "MessageRejected",
|
||||||
"Message": "Email address is not verified. The following identities"
|
"Message": "Email address is not verified. The following identities"
|
||||||
" failed the check in region US-EAST-1: to@example.com",
|
" failed the check in region US-EAST-1: to@example.com",
|
||||||
},
|
},
|
||||||
"ResponseMetadata": {
|
"ResponseMetadata": {
|
||||||
"RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb",
|
"RequestId": "c44b0ae2-e086-45ca-8820-b76a9b9f430a",
|
||||||
"HTTPStatusCode": 400,
|
"HTTPStatusCode": 403,
|
||||||
"HTTPHeaders": {
|
"HTTPHeaders": {
|
||||||
"x-amzn-requestid": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb",
|
"date": "Tue, 21 Feb 2023 23:49:31 GMT",
|
||||||
"content-type": "text/xml",
|
"content-type": "application/json",
|
||||||
"content-length": "277",
|
"content-length": "196",
|
||||||
"date": "Sat, 17 Mar 2018 04:44:44 GMT",
|
"connection": "keep-alive",
|
||||||
|
"x-amzn-requestid": "c44b0ae2-e086-45ca-8820-b76a9b9f430a",
|
||||||
|
"x-amzn-errortype": "MessageRejected",
|
||||||
},
|
},
|
||||||
"RetryAttempts": 0,
|
"RetryAttempts": 0,
|
||||||
},
|
},
|
||||||
@@ -444,7 +458,10 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
self.message.envelope_sender = "bounce-handler@bounces.example.com"
|
self.message.envelope_sender = "bounce-handler@bounces.example.com"
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
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):
|
def test_spoofed_to(self):
|
||||||
# Amazon SES is one of the few ESPs that actually permits the To header
|
# 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.extra_headers["To"] = "Spoofed <spoofed-to@elsewhere.example.org>"
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
params = self.get_send_params()
|
||||||
raw_mime = params["RawMessage"]["Data"]
|
raw_mime = params["Content"]["Raw"]["Data"]
|
||||||
self.assertEqual(params["Destinations"], ["Envelope <envelope-to@example.com>"])
|
self.assertEqual(
|
||||||
|
params["Destination"],
|
||||||
|
{"ToAddresses": ["Envelope <envelope-to@example.com>"]},
|
||||||
|
)
|
||||||
self.assertIn(b"\nTo: Spoofed <spoofed-to@elsewhere.example.org>\n", raw_mime)
|
self.assertIn(b"\nTo: Spoofed <spoofed-to@elsewhere.example.org>\n", raw_mime)
|
||||||
self.assertNotIn(b"envelope-to@example.com", raw_mime)
|
self.assertNotIn(b"envelope-to@example.com", raw_mime)
|
||||||
|
|
||||||
def test_metadata(self):
|
def test_metadata(self):
|
||||||
# (that \n is a header-injection test)
|
|
||||||
self.message.metadata = {
|
self.message.metadata = {
|
||||||
"User ID": 12345,
|
"User ID": 12345,
|
||||||
|
# that \n is a header-injection test:
|
||||||
"items": "Correct horse,Battery,\nStaple",
|
"items": "Correct horse,Battery,\nStaple",
|
||||||
"Cart-Total": "22.70",
|
"Cart-Total": "22.70",
|
||||||
}
|
}
|
||||||
@@ -504,7 +524,9 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
self.message.tags = ["Welcome"]
|
self.message.tags = ["Welcome"]
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
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
|
# Multiple Anymail tags are not supported when using this feature
|
||||||
self.message.tags = ["Welcome", "Variation_A"]
|
self.message.tags = ["Welcome", "Variation_A"]
|
||||||
@@ -547,22 +569,25 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign"
|
ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign"
|
||||||
)
|
)
|
||||||
def test_template(self):
|
def test_template(self):
|
||||||
"""With template_id, Anymail switches to SES SendBulkTemplatedEmail"""
|
"""With template_id, Anymail switches to SESv2 SendBulkEmail"""
|
||||||
# SendBulkTemplatedEmail uses a completely different API call and payload
|
# SendBulkEmail uses a completely different API call and payload
|
||||||
# structure, so this re-tests a bunch of Anymail features that were handled
|
# structure, so this re-tests a bunch of Anymail features that were handled
|
||||||
# differently above. (See test_amazon_ses_integration for a more realistic
|
# differently above. (See test_amazon_ses_integration for a more realistic
|
||||||
# template example.)
|
# template example.)
|
||||||
raw_response = {
|
raw_response = {
|
||||||
"Status": [
|
"BulkEmailEntryResults": [
|
||||||
{
|
{
|
||||||
"Status": "Success",
|
"Status": "SUCCESS",
|
||||||
"MessageId": "1111111111111111-bbbbbbbb-3333-7777",
|
"MessageId": "1111111111111111-bbbbbbbb-3333-7777",
|
||||||
},
|
},
|
||||||
{"Status": "AccountThrottled"},
|
{
|
||||||
|
"Status": "ACCOUNT_DAILY_QUOTA_EXCEEDED",
|
||||||
|
"Error": "Daily message quota exceeded",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"ResponseMetadata": self.DEFAULT_SEND_RESPONSE["ResponseMetadata"],
|
"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(
|
message = AnymailMessage(
|
||||||
template_id="welcome_template",
|
template_id="welcome_template",
|
||||||
from_email='"Example, Inc." <from@example.com>',
|
from_email='"Example, Inc." <from@example.com>',
|
||||||
@@ -577,30 +602,40 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
merge_global_data={"group": "Users", "site": "ExampleCo"},
|
merge_global_data={"group": "Users", "site": "ExampleCo"},
|
||||||
# (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template):
|
# (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template):
|
||||||
tags=["WelcomeVariantA"],
|
tags=["WelcomeVariantA"],
|
||||||
envelope_sender="bounces@example.com",
|
envelope_sender="bounce@example.com",
|
||||||
esp_extra={
|
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()
|
message.send()
|
||||||
|
|
||||||
# templates use a different API call...
|
# templates use a different API call...
|
||||||
self.assert_esp_not_called(operation_name="send_raw_email")
|
self.assert_esp_not_called(operation_name="send_email")
|
||||||
params = self.get_send_params(operation_name="send_bulk_templated_email")
|
params = self.get_send_params(operation_name="send_bulk_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(
|
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"]},
|
{"ToAddresses": ["alice@example.com"], "CcAddresses": ["cc@example.com"]},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
json.loads(destinations[0]["ReplacementTemplateData"]),
|
json.loads(
|
||||||
|
bulk_entries[0]["ReplacementEmailContent"]["ReplacementTemplate"][
|
||||||
|
"ReplacementTemplateData"
|
||||||
|
]
|
||||||
|
),
|
||||||
{"name": "Alice", "group": "Developers"},
|
{"name": "Alice", "group": "Developers"},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
destinations[1]["Destination"],
|
bulk_entries[1]["Destination"],
|
||||||
{
|
{
|
||||||
# SES requires RFC2047:
|
# SES requires RFC2047:
|
||||||
"ToAddresses": ["=?utf-8?b?572X5Lyv54m5?= <bob@example.com>"],
|
"ToAddresses": ["=?utf-8?b?572X5Lyv54m5?= <bob@example.com>"],
|
||||||
@@ -608,10 +643,15 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
json.loads(destinations[1]["ReplacementTemplateData"]), {"name": "Bob"}
|
json.loads(
|
||||||
|
bulk_entries[1]["ReplacementEmailContent"]["ReplacementTemplate"][
|
||||||
|
"ReplacementTemplateData"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
{"name": "Bob"},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
json.loads(params["DefaultTemplateData"]),
|
json.loads(params["DefaultContent"]["Template"]["TemplateData"]),
|
||||||
{"group": "Users", "site": "ExampleCo"},
|
{"group": "Users", "site": "ExampleCo"},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -619,12 +659,14 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
["reply1@example.com", "Reply 2 <reply2@example.com>"],
|
["reply1@example.com", "Reply 2 <reply2@example.com>"],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
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(
|
self.assertEqual(
|
||||||
params["SourceArn"],
|
params["FromEmailAddressIdentityArn"],
|
||||||
"arn:aws:ses:us-east-1:123456789012:identity/example.com", # esp_extra
|
"arn:aws:ses:us-east-1:123456789012:identity/example.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(message.anymail_status.status, {"queued", "failed"})
|
self.assertEqual(message.anymail_status.status, {"queued", "failed"})
|
||||||
@@ -648,6 +690,30 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(message.anymail_status.esp_response, raw_response)
|
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):
|
def test_template_unsupported(self):
|
||||||
"""A lot of options are not compatible with SendBulkTemplatedEmail"""
|
"""A lot of options are not compatible with SendBulkTemplatedEmail"""
|
||||||
message = AnymailMessage(template_id="welcome_template", to=["to@example.com"])
|
message = AnymailMessage(template_id="welcome_template", to=["to@example.com"])
|
||||||
@@ -698,14 +764,14 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
message.tags = None
|
message.tags = None
|
||||||
|
|
||||||
def test_send_anymail_message_without_template(self):
|
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(
|
message = AnymailMessage(
|
||||||
from_email="from@example.com", to=["to@example.com"], subject="subject"
|
from_email="from@example.com", to=["to@example.com"], subject="subject"
|
||||||
)
|
)
|
||||||
message.send()
|
message.send()
|
||||||
self.assert_esp_not_called(operation_name="send_bulk_templated_email")
|
self.assert_esp_not_called(operation_name="send_bulk_email")
|
||||||
# fails if send_raw_email not called:
|
# fails if send_email not called:
|
||||||
self.get_send_params(operation_name="send_raw_email")
|
self.get_send_params(operation_name="send_email")
|
||||||
|
|
||||||
def test_default_omits_options(self):
|
def test_default_omits_options(self):
|
||||||
"""Make sure by default we don't send any ESP-specific options.
|
"""Make sure by default we don't send any ESP-specific options.
|
||||||
@@ -716,20 +782,17 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
"""
|
"""
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
params = self.get_send_params()
|
||||||
|
self.assertNotIn("BulkEmailEntries", params)
|
||||||
self.assertNotIn("ConfigurationSetName", params)
|
self.assertNotIn("ConfigurationSetName", params)
|
||||||
self.assertNotIn("DefaultTags", params)
|
self.assertNotIn("DefaultContent", params)
|
||||||
self.assertNotIn("DefaultTemplateData", params)
|
self.assertNotIn("DefaultContent", params)
|
||||||
self.assertNotIn("FromArn", params)
|
self.assertNotIn("DefaultEmailTags", params)
|
||||||
self.assertNotIn("Message", 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("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()
|
sent_message = self.get_sent_message()
|
||||||
# custom headers not added if not needed:
|
# custom headers not added if not needed:
|
||||||
@@ -910,3 +973,18 @@ class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase):
|
|||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
params = self.get_send_params()
|
||||||
self.assertEqual(params["ConfigurationSetName"], "CustomConfigurationSet")
|
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()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
import warnings
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from email.encoders import encode_7or8bit
|
|
||||||
from email.mime.application import MIMEApplication
|
from email.mime.application import MIMEApplication
|
||||||
from unittest.mock import ANY, patch
|
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 django.test import SimpleTestCase, override_settings, tag
|
||||||
|
|
||||||
from anymail import __version__ as ANYMAIL_VERSION
|
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.inbound import AnymailInboundMessage
|
||||||
from anymail.message import AnymailMessage, attach_inline_image_file
|
from anymail.message import AnymailMessage, attach_inline_image_file
|
||||||
|
|
||||||
@@ -22,24 +26,28 @@ from .utils import (
|
|||||||
|
|
||||||
|
|
||||||
@tag("amazon_ses")
|
@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):
|
class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
||||||
"""TestCase that uses the Amazon SES EmailBackend with a mocked boto3 client"""
|
"""TestCase that uses the Amazon SES EmailBackend with a mocked boto3 client"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
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
|
# client operations). (We could also use botocore.stub.Stubber, but mock works
|
||||||
# well with our test structure.)
|
# well with our test structure.)
|
||||||
self.patch_boto3_session = patch(
|
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.mock_session = self.patch_boto3_session.start() # boto3.session.Session
|
||||||
self.addCleanup(self.patch_boto3_session.stop)
|
self.addCleanup(self.patch_boto3_session.stop)
|
||||||
#: boto3.session.Session().client
|
#: boto3.session.Session().client
|
||||||
self.mock_client = self.mock_session.return_value.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.mock_client_instance = self.mock_client.return_value
|
||||||
self.set_mock_response()
|
self.set_mock_response()
|
||||||
|
|
||||||
@@ -51,25 +59,24 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
|||||||
DEFAULT_SEND_RESPONSE = {
|
DEFAULT_SEND_RESPONSE = {
|
||||||
"MessageId": "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000",
|
"MessageId": "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000",
|
||||||
"ResponseMetadata": {
|
"ResponseMetadata": {
|
||||||
"RequestId": "900dd7f3-0399-4a1b-9d9f-bed91f46924a",
|
"RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb",
|
||||||
"HTTPStatusCode": 200,
|
"HTTPStatusCode": 200,
|
||||||
"HTTPHeaders": {
|
"HTTPHeaders": {
|
||||||
"date": "Tue, 21 Feb 2023 22:59:46 GMT",
|
"x-amzn-requestid": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb",
|
||||||
"content-type": "application/json",
|
"content-type": "text/xml",
|
||||||
"content-length": "76",
|
"content-length": "338",
|
||||||
"connection": "keep-alive",
|
"date": "Sat, 17 Mar 2018 03:33:33 GMT",
|
||||||
"x-amzn-requestid": "900dd7f3-0399-4a1b-9d9f-bed91f46924a",
|
|
||||||
},
|
},
|
||||||
"RetryAttempts": 0,
|
"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 = getattr(self.mock_client_instance, operation_name)
|
||||||
mock_operation.return_value = response or self.DEFAULT_SEND_RESPONSE
|
mock_operation.return_value = response or self.DEFAULT_SEND_RESPONSE
|
||||||
return mock_operation.return_value
|
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
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
mock_operation = getattr(self.mock_client_instance, operation_name)
|
mock_operation = getattr(self.mock_client_instance, operation_name)
|
||||||
@@ -87,7 +94,7 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
|||||||
)
|
)
|
||||||
return kwargs
|
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
|
"""Returns kwargs params passed to mock boto3 client constructor
|
||||||
|
|
||||||
Fails test if boto3 client wasn't constructed with named service
|
Fails test if boto3 client wasn't constructed with named service
|
||||||
@@ -105,12 +112,12 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
|||||||
)
|
)
|
||||||
return kwargs
|
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.
|
"""Returns kwargs params passed to the mock send API.
|
||||||
|
|
||||||
Fails test if API wasn't called.
|
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)
|
mock_operation = getattr(self.mock_client_instance, operation_name)
|
||||||
if mock_operation.call_args is None:
|
if mock_operation.call_args is None:
|
||||||
raise AssertionError("API was not called")
|
raise AssertionError("API was not called")
|
||||||
@@ -118,13 +125,17 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
|||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_sent_message(self):
|
def get_sent_message(self):
|
||||||
"""Returns a parsed version of the send_email Content.Raw.Data param"""
|
"""Returns a parsed version of the send_raw_email RawMessage.Data param"""
|
||||||
params = self.get_send_params(operation_name="send_email")
|
|
||||||
raw_mime = params["Content"]["Raw"]["Data"]
|
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)
|
parsed = AnymailInboundMessage.parse_raw_mime_bytes(raw_mime)
|
||||||
return parsed
|
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)
|
mock_operation = getattr(self.mock_client_instance, operation_name)
|
||||||
if mock_operation.called:
|
if mock_operation.called:
|
||||||
raise AssertionError(msg or "ESP API was called and shouldn't have been")
|
raise AssertionError(msg or "ESP API was called and shouldn't have been")
|
||||||
@@ -144,16 +155,16 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
|
|||||||
fail_silently=False,
|
fail_silently=False,
|
||||||
)
|
)
|
||||||
params = self.get_send_params()
|
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:
|
# This is a simple (if inexact) way to check for expected headers and body:
|
||||||
raw_mime = params["Content"]["Raw"]["Data"]
|
raw_mime = params["RawMessage"]["Data"]
|
||||||
self.assertIsInstance(raw_mime, bytes) # SendEmail expects Data as bytes
|
self.assertIsInstance(raw_mime, bytes) # SendRawEmail expects Data as bytes
|
||||||
self.assertIn(b"\nFrom: from@example.com\n", raw_mime)
|
self.assertIn(b"\nFrom: from@example.com\n", raw_mime)
|
||||||
self.assertIn(b"\nTo: to@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"\nSubject: Subject here\n", raw_mime)
|
||||||
self.assertIn(b"\n\nHere is the message", raw_mime)
|
self.assertIn(b"\n\nHere is the message", raw_mime)
|
||||||
# Destination must include all recipients:
|
# Destinations must include all recipients:
|
||||||
self.assertEqual(params["Destination"], {"ToAddresses": ["to@example.com"]})
|
self.assertEqual(params["Destinations"], ["to@example.com"])
|
||||||
|
|
||||||
# Since the SES backend generates the MIME message using Django's
|
# Since the SES backend generates the MIME message using Django's
|
||||||
# EmailMessage.message().to_string(), there's not really a need
|
# EmailMessage.message().to_string(), there's not really a need
|
||||||
@@ -168,18 +179,18 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
|
|||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
params = self.get_send_params()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
params["Destination"],
|
params["Destinations"],
|
||||||
{
|
[
|
||||||
"ToAddresses": [
|
|
||||||
"to1@example.com",
|
"to1@example.com",
|
||||||
'"Recipient, second" <to2@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:
|
# 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):
|
def test_non_ascii_headers(self):
|
||||||
self.message.subject = "Thử tin nhắn" # utf-8 in subject header
|
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.cc = ["cc@thư.example.com"] # utf-8 in domain
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
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:
|
# Non-ASCII headers must use MIME encoded-word syntax:
|
||||||
self.assertIn(b"\nSubject: =?utf-8?b?VGjhu60gdGluIG5o4bqvbg==?=\n", raw_mime)
|
self.assertIn(b"\nSubject: =?utf-8?b?VGjhu60gdGluIG5o4bqvbg==?=\n", raw_mime)
|
||||||
# Non-ASCII display names as well:
|
# Non-ASCII display names as well:
|
||||||
@@ -199,13 +210,13 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
|
|||||||
# SES doesn't support non-ASCII in the username@ part
|
# SES doesn't support non-ASCII in the username@ part
|
||||||
# (RFC 6531 "SMTPUTF8" extension)
|
# (RFC 6531 "SMTPUTF8" extension)
|
||||||
|
|
||||||
# Destinations must include all recipients (addr-spec only, must use Punycode):
|
# Destinations must include all recipients:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
params["Destination"],
|
params["Destinations"],
|
||||||
{
|
[
|
||||||
"ToAddresses": ["=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= <to@example.com>"],
|
"=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= <to@example.com>",
|
||||||
"CcAddresses": ["cc@xn--th-e0a.example.com"],
|
"cc@xn--th-e0a.example.com",
|
||||||
},
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_attachments(self):
|
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:
|
# Make sure neither the html nor the inline image is treated as an attachment:
|
||||||
params = self.get_send_params()
|
params = self.get_send_params()
|
||||||
raw_mime = params["Content"]["Raw"]["Data"]
|
raw_mime = params["RawMessage"]["Data"]
|
||||||
self.assertNotIn(b"\nContent-Disposition: attachment", raw_mime)
|
self.assertNotIn(b"\nContent-Disposition: attachment", raw_mime)
|
||||||
|
|
||||||
def test_multiple_html_alternatives(self):
|
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.attach_alternative("<p>And so is second</p>", "text/html")
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
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
|
# just check the alternative smade it into the message
|
||||||
# (assume that Django knows how to format them properly)
|
# (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>First html is OK</p>\n", raw_mime)
|
||||||
self.assertIn(b"\n\n<p>And so is second</p>\n", raw_mime)
|
self.assertIn(b"\n\n<p>And so is second</p>\n", raw_mime)
|
||||||
|
|
||||||
def test_alternative(self):
|
def test_alternative(self):
|
||||||
# Non-HTML alternatives (including AMP) *are* allowed
|
# Non-HTML alternatives *are* allowed
|
||||||
self.message.attach_alternative("<p>AMP HTML</p>", "text/x-amp-html")
|
self.message.attach_alternative('{"is": "allowed"}', "application/json")
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
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
|
# just check the alternative made it into the message
|
||||||
# (assume that Python email knows how to format it properly)
|
# (assume that Django knows how to format it properly)
|
||||||
self.assertIn(b"\nContent-Type: text/x-amp-html", raw_mime)
|
self.assertIn(b"\nContent-Type: application/json\n", raw_mime)
|
||||||
|
|
||||||
def test_multiple_from(self):
|
def test_multiple_from(self):
|
||||||
# Amazon allows multiple addresses in the From header,
|
# Amazon allows multiple addresses in the From header,
|
||||||
# but must specify a single one for the FromEmailAddress
|
# but must specify which is Source
|
||||||
self.message.from_email = "First <from1@example.com>, from2@example.com"
|
self.message.from_email = "from1@example.com, from2@example.com"
|
||||||
with self.assertRaisesMessage(
|
|
||||||
AnymailUnsupportedFeature, "multiple from emails"
|
|
||||||
):
|
|
||||||
self.message.send()
|
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):
|
def test_commas_in_subject(self):
|
||||||
"""
|
"""
|
||||||
@@ -318,60 +330,47 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
|
|||||||
sent_message = self.get_sent_message()
|
sent_message = self.get_sent_message()
|
||||||
self.assertEqual(sent_message["Subject"], self.message.subject)
|
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."""
|
"""Anymail works around an Amazon SES bug that can corrupt non-ASCII bodies."""
|
||||||
# (see detailed comments in the backend code)
|
# (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.body = "Это text body"
|
||||||
self.message.attach_alternative("<p>Это html body</p>", "text/html")
|
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()
|
self.message.send()
|
||||||
sent_message = self.get_sent_message()
|
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
|
# (Technically, either quoted-printable or base64 would be OK, but base64 text
|
||||||
# parts have a reputation for triggering spam filters, so just require
|
# parts have a reputation for triggering spam filters, so just require
|
||||||
# quoted-printable for them.)
|
# quoted-printable.)
|
||||||
part_encodings = [
|
text_part_encodings = [
|
||||||
(part.get_content_type(), part["Content-Transfer-Encoding"])
|
(part.get_content_type(), part["Content-Transfer-Encoding"])
|
||||||
for part in sent_message.walk()
|
for part in sent_message.walk()
|
||||||
|
if part.get_content_maintype() == "text"
|
||||||
]
|
]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
part_encodings,
|
text_part_encodings,
|
||||||
[
|
[
|
||||||
("multipart/mixed", None),
|
|
||||||
("multipart/alternative", None),
|
|
||||||
("text/plain", "quoted-printable"),
|
("text/plain", "quoted-printable"),
|
||||||
("text/html", "quoted-printable"),
|
("text/html", "quoted-printable"),
|
||||||
("text/csv", "quoted-printable"),
|
|
||||||
("application/data", "base64"),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_api_failure(self):
|
def test_api_failure(self):
|
||||||
error_response = {
|
error_response = {
|
||||||
"Error": {
|
"Error": {
|
||||||
|
"Type": "Sender",
|
||||||
"Code": "MessageRejected",
|
"Code": "MessageRejected",
|
||||||
"Message": "Email address is not verified. The following identities"
|
"Message": "Email address is not verified. The following identities"
|
||||||
" failed the check in region US-EAST-1: to@example.com",
|
" failed the check in region US-EAST-1: to@example.com",
|
||||||
},
|
},
|
||||||
"ResponseMetadata": {
|
"ResponseMetadata": {
|
||||||
"RequestId": "c44b0ae2-e086-45ca-8820-b76a9b9f430a",
|
"RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb",
|
||||||
"HTTPStatusCode": 403,
|
"HTTPStatusCode": 400,
|
||||||
"HTTPHeaders": {
|
"HTTPHeaders": {
|
||||||
"date": "Tue, 21 Feb 2023 23:49:31 GMT",
|
"x-amzn-requestid": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb",
|
||||||
"content-type": "application/json",
|
"content-type": "text/xml",
|
||||||
"content-length": "196",
|
"content-length": "277",
|
||||||
"connection": "keep-alive",
|
"date": "Sat, 17 Mar 2018 04:44:44 GMT",
|
||||||
"x-amzn-requestid": "c44b0ae2-e086-45ca-8820-b76a9b9f430a",
|
|
||||||
"x-amzn-errortype": "MessageRejected",
|
|
||||||
},
|
},
|
||||||
"RetryAttempts": 0,
|
"RetryAttempts": 0,
|
||||||
},
|
},
|
||||||
@@ -454,10 +453,7 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
self.message.envelope_sender = "bounce-handler@bounces.example.com"
|
self.message.envelope_sender = "bounce-handler@bounces.example.com"
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
params = self.get_send_params()
|
||||||
self.assertEqual(
|
self.assertEqual(params["Source"], "bounce-handler@bounces.example.com")
|
||||||
params["FeedbackForwardingEmailAddress"],
|
|
||||||
"bounce-handler@bounces.example.com",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_spoofed_to(self):
|
def test_spoofed_to(self):
|
||||||
# Amazon SES is one of the few ESPs that actually permits the To header
|
# 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.extra_headers["To"] = "Spoofed <spoofed-to@elsewhere.example.org>"
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
params = self.get_send_params()
|
||||||
raw_mime = params["Content"]["Raw"]["Data"]
|
raw_mime = params["RawMessage"]["Data"]
|
||||||
self.assertEqual(
|
self.assertEqual(params["Destinations"], ["Envelope <envelope-to@example.com>"])
|
||||||
params["Destination"],
|
|
||||||
{"ToAddresses": ["Envelope <envelope-to@example.com>"]},
|
|
||||||
)
|
|
||||||
self.assertIn(b"\nTo: Spoofed <spoofed-to@elsewhere.example.org>\n", raw_mime)
|
self.assertIn(b"\nTo: Spoofed <spoofed-to@elsewhere.example.org>\n", raw_mime)
|
||||||
self.assertNotIn(b"envelope-to@example.com", raw_mime)
|
self.assertNotIn(b"envelope-to@example.com", raw_mime)
|
||||||
|
|
||||||
def test_metadata(self):
|
def test_metadata(self):
|
||||||
|
# (that \n is a header-injection test)
|
||||||
self.message.metadata = {
|
self.message.metadata = {
|
||||||
"User ID": 12345,
|
"User ID": 12345,
|
||||||
# that \n is a header-injection test:
|
|
||||||
"items": "Correct horse,Battery,\nStaple",
|
"items": "Correct horse,Battery,\nStaple",
|
||||||
"Cart-Total": "22.70",
|
"Cart-Total": "22.70",
|
||||||
}
|
}
|
||||||
@@ -520,9 +513,7 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
self.message.tags = ["Welcome"]
|
self.message.tags = ["Welcome"]
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
params = self.get_send_params()
|
||||||
self.assertEqual(
|
self.assertEqual(params["Tags"], [{"Name": "Campaign", "Value": "Welcome"}])
|
||||||
params["EmailTags"], [{"Name": "Campaign", "Value": "Welcome"}]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Multiple Anymail tags are not supported when using this feature
|
# Multiple Anymail tags are not supported when using this feature
|
||||||
self.message.tags = ["Welcome", "Variation_A"]
|
self.message.tags = ["Welcome", "Variation_A"]
|
||||||
@@ -565,25 +556,22 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign"
|
ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign"
|
||||||
)
|
)
|
||||||
def test_template(self):
|
def test_template(self):
|
||||||
"""With template_id, Anymail switches to SESv2 SendBulkEmail"""
|
"""With template_id, Anymail switches to SES SendBulkTemplatedEmail"""
|
||||||
# SendBulkEmail uses a completely different API call and payload
|
# SendBulkTemplatedEmail uses a completely different API call and payload
|
||||||
# structure, so this re-tests a bunch of Anymail features that were handled
|
# structure, so this re-tests a bunch of Anymail features that were handled
|
||||||
# differently above. (See test_amazon_ses_integration for a more realistic
|
# differently above. (See test_amazon_ses_integration for a more realistic
|
||||||
# template example.)
|
# template example.)
|
||||||
raw_response = {
|
raw_response = {
|
||||||
"BulkEmailEntryResults": [
|
"Status": [
|
||||||
{
|
{
|
||||||
"Status": "SUCCESS",
|
"Status": "Success",
|
||||||
"MessageId": "1111111111111111-bbbbbbbb-3333-7777",
|
"MessageId": "1111111111111111-bbbbbbbb-3333-7777",
|
||||||
},
|
},
|
||||||
{
|
{"Status": "AccountThrottled"},
|
||||||
"Status": "ACCOUNT_DAILY_QUOTA_EXCEEDED",
|
|
||||||
"Error": "Daily message quota exceeded",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"ResponseMetadata": self.DEFAULT_SEND_RESPONSE["ResponseMetadata"],
|
"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(
|
message = AnymailMessage(
|
||||||
template_id="welcome_template",
|
template_id="welcome_template",
|
||||||
from_email='"Example, Inc." <from@example.com>',
|
from_email='"Example, Inc." <from@example.com>',
|
||||||
@@ -598,40 +586,30 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
merge_global_data={"group": "Users", "site": "ExampleCo"},
|
merge_global_data={"group": "Users", "site": "ExampleCo"},
|
||||||
# (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template):
|
# (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template):
|
||||||
tags=["WelcomeVariantA"],
|
tags=["WelcomeVariantA"],
|
||||||
envelope_sender="bounce@example.com",
|
envelope_sender="bounces@example.com",
|
||||||
esp_extra={
|
esp_extra={
|
||||||
"FromEmailAddressIdentityArn": (
|
"SourceArn": "arn:aws:ses:us-east-1:123456789012:identity/example.com"
|
||||||
"arn:aws:ses:us-east-1:123456789012:identity/example.com"
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
message.send()
|
message.send()
|
||||||
|
|
||||||
# templates use a different API call...
|
# templates use a different API call...
|
||||||
self.assert_esp_not_called(operation_name="send_email")
|
self.assert_esp_not_called(operation_name="send_raw_email")
|
||||||
params = self.get_send_params(operation_name="send_bulk_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(
|
self.assertEqual(
|
||||||
params["DefaultContent"]["Template"]["TemplateName"], "welcome_template"
|
destinations[0]["Destination"],
|
||||||
)
|
|
||||||
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"]},
|
{"ToAddresses": ["alice@example.com"], "CcAddresses": ["cc@example.com"]},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
json.loads(
|
json.loads(destinations[0]["ReplacementTemplateData"]),
|
||||||
bulk_entries[0]["ReplacementEmailContent"]["ReplacementTemplate"][
|
|
||||||
"ReplacementTemplateData"
|
|
||||||
]
|
|
||||||
),
|
|
||||||
{"name": "Alice", "group": "Developers"},
|
{"name": "Alice", "group": "Developers"},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
bulk_entries[1]["Destination"],
|
destinations[1]["Destination"],
|
||||||
{
|
{
|
||||||
# SES requires RFC2047:
|
# SES requires RFC2047:
|
||||||
"ToAddresses": ["=?utf-8?b?572X5Lyv54m5?= <bob@example.com>"],
|
"ToAddresses": ["=?utf-8?b?572X5Lyv54m5?= <bob@example.com>"],
|
||||||
@@ -639,15 +617,10 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
json.loads(
|
json.loads(destinations[1]["ReplacementTemplateData"]), {"name": "Bob"}
|
||||||
bulk_entries[1]["ReplacementEmailContent"]["ReplacementTemplate"][
|
|
||||||
"ReplacementTemplateData"
|
|
||||||
]
|
|
||||||
),
|
|
||||||
{"name": "Bob"},
|
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
json.loads(params["DefaultContent"]["Template"]["TemplateData"]),
|
json.loads(params["DefaultTemplateData"]),
|
||||||
{"group": "Users", "site": "ExampleCo"},
|
{"group": "Users", "site": "ExampleCo"},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -655,14 +628,12 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
["reply1@example.com", "Reply 2 <reply2@example.com>"],
|
["reply1@example.com", "Reply 2 <reply2@example.com>"],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
params["DefaultEmailTags"],
|
params["DefaultTags"], [{"Name": "Campaign", "Value": "WelcomeVariantA"}]
|
||||||
[{"Name": "Campaign", "Value": "WelcomeVariantA"}],
|
|
||||||
)
|
)
|
||||||
self.assertEqual(params["FeedbackForwardingEmailAddress"], "bounce@example.com")
|
self.assertEqual(params["ReturnPath"], "bounces@example.com")
|
||||||
# esp_extra:
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
params["FromEmailAddressIdentityArn"],
|
params["SourceArn"],
|
||||||
"arn:aws:ses:us-east-1:123456789012:identity/example.com",
|
"arn:aws:ses:us-east-1:123456789012:identity/example.com", # esp_extra
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(message.anymail_status.status, {"queued", "failed"})
|
self.assertEqual(message.anymail_status.status, {"queued", "failed"})
|
||||||
@@ -686,30 +657,6 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(message.anymail_status.esp_response, raw_response)
|
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):
|
def test_template_unsupported(self):
|
||||||
"""A lot of options are not compatible with SendBulkTemplatedEmail"""
|
"""A lot of options are not compatible with SendBulkTemplatedEmail"""
|
||||||
message = AnymailMessage(template_id="welcome_template", to=["to@example.com"])
|
message = AnymailMessage(template_id="welcome_template", to=["to@example.com"])
|
||||||
@@ -760,14 +707,14 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
message.tags = None
|
message.tags = None
|
||||||
|
|
||||||
def test_send_anymail_message_without_template(self):
|
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(
|
message = AnymailMessage(
|
||||||
from_email="from@example.com", to=["to@example.com"], subject="subject"
|
from_email="from@example.com", to=["to@example.com"], subject="subject"
|
||||||
)
|
)
|
||||||
message.send()
|
message.send()
|
||||||
self.assert_esp_not_called(operation_name="send_bulk_email")
|
self.assert_esp_not_called(operation_name="send_bulk_templated_email")
|
||||||
# fails if send_email not called:
|
# fails if send_raw_email not called:
|
||||||
self.get_send_params(operation_name="send_email")
|
self.get_send_params(operation_name="send_raw_email")
|
||||||
|
|
||||||
def test_default_omits_options(self):
|
def test_default_omits_options(self):
|
||||||
"""Make sure by default we don't send any ESP-specific options.
|
"""Make sure by default we don't send any ESP-specific options.
|
||||||
@@ -778,17 +725,20 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
"""
|
"""
|
||||||
self.message.send()
|
self.message.send()
|
||||||
params = self.get_send_params()
|
params = self.get_send_params()
|
||||||
self.assertNotIn("BulkEmailEntries", params)
|
|
||||||
self.assertNotIn("ConfigurationSetName", params)
|
self.assertNotIn("ConfigurationSetName", params)
|
||||||
self.assertNotIn("DefaultContent", params)
|
self.assertNotIn("DefaultTags", params)
|
||||||
self.assertNotIn("DefaultContent", params)
|
self.assertNotIn("DefaultTemplateData", params)
|
||||||
self.assertNotIn("DefaultEmailTags", params)
|
self.assertNotIn("FromArn", params)
|
||||||
self.assertNotIn("EmailTags", params)
|
self.assertNotIn("Message", params)
|
||||||
self.assertNotIn("FeedbackForwardingEmailAddress", params)
|
|
||||||
self.assertNotIn("FeedbackForwardingEmailAddressIdentityArn", params)
|
|
||||||
self.assertNotIn("FromEmailAddressIdentityArn", params)
|
|
||||||
self.assertNotIn("ListManagementOptions", params)
|
|
||||||
self.assertNotIn("ReplyToAddresses", 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()
|
sent_message = self.get_sent_message()
|
||||||
# custom headers not added if not needed:
|
# custom headers not added if not needed:
|
||||||
@@ -856,6 +806,13 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase):
|
|||||||
class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase):
|
class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase):
|
||||||
"""Test configuration options"""
|
"""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):
|
def test_boto_default_config(self):
|
||||||
"""By default, boto3 gets credentials from the environment or its config files
|
"""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)
|
boto_config = Config(connect_timeout=30)
|
||||||
conn = mail.get_connection(
|
conn = mail.get_connection(
|
||||||
"anymail.backends.amazon_sesv2.EmailBackend",
|
"anymail.backends.amazon_sesv1.EmailBackend",
|
||||||
client_params={
|
client_params={
|
||||||
"aws_session_token": "test-session-token",
|
"aws_session_token": "test-session-token",
|
||||||
"config": boto_config,
|
"config": boto_config,
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
import warnings
|
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
|
|
||||||
from django.test import SimpleTestCase, override_settings, tag
|
from django.test import SimpleTestCase, override_settings, tag
|
||||||
@@ -68,7 +67,7 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
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(
|
self.message = AnymailMessage(
|
||||||
"Anymail Amazon SES integration test",
|
"Anymail Amazon SES integration test",
|
||||||
"Text content",
|
"Text content",
|
||||||
@@ -77,16 +76,6 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
)
|
)
|
||||||
self.message.attach_alternative("<p>HTML content</p>", "text/html")
|
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):
|
def test_simple_send(self):
|
||||||
# Example of getting the Amazon SES send status and message id from the message
|
# Example of getting the Amazon SES send status and message id from the message
|
||||||
sent_count = self.message.send()
|
sent_count = self.message.send()
|
||||||
@@ -129,6 +118,7 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
headers={"X-Anymail-Test": "value"},
|
headers={"X-Anymail-Test": "value"},
|
||||||
metadata={"meta1": "simple_string", "meta2": 2},
|
metadata={"meta1": "simple_string", "meta2": 2},
|
||||||
tags=["Re-engagement", "Cohort 12/2017"],
|
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("attachment1.txt", "Here is some\ntext for you", "text/plain")
|
||||||
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
|
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
|
||||||
@@ -149,14 +139,16 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
|
|
||||||
def test_stored_template(self):
|
def test_stored_template(self):
|
||||||
# Using a template created like this:
|
# Using a template created like this:
|
||||||
# boto3.client('ses').create_template(Template={
|
# boto3.client('sesv2').create_email_template(
|
||||||
# "TemplateName": "TestTemplate",
|
# TemplateName="TestTemplate",
|
||||||
# "SubjectPart": "Your order {{order}} shipped",
|
# TemplateContent={
|
||||||
# "HtmlPart": "<h1>Dear {{name}}:</h1>"
|
# "Subject": "Your order {{order}} shipped",
|
||||||
|
# "Html": "<h1>Dear {{name}}:</h1>"
|
||||||
# "<p>Your order {{order}} shipped {{ship_date}}.</p>",
|
# "<p>Your order {{order}} shipped {{ship_date}}.</p>",
|
||||||
# "TextPart": "Dear {{name}}:\r\n"
|
# "Text": "Dear {{name}}:\r\n"
|
||||||
# "Your order {{order}} shipped {{ship_date}}."
|
# "Your order {{order}} shipped {{ship_date}}."
|
||||||
# })
|
# },
|
||||||
|
# )
|
||||||
message = AnymailMessage(
|
message = AnymailMessage(
|
||||||
template_id="TestTemplate",
|
template_id="TestTemplate",
|
||||||
from_email=formataddr(("Test From", self.from_email)),
|
from_email=formataddr(("Test From", self.from_email)),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
|
import warnings
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
|
|
||||||
from django.test import SimpleTestCase, override_settings, tag
|
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",
|
" environment variables to run Amazon SES integration tests",
|
||||||
)
|
)
|
||||||
@override_settings(
|
@override_settings(
|
||||||
EMAIL_BACKEND="anymail.backends.amazon_sesv2.EmailBackend",
|
EMAIL_BACKEND="anymail.backends.amazon_sesv1.EmailBackend",
|
||||||
ANYMAIL={
|
ANYMAIL={
|
||||||
"AMAZON_SES_CLIENT_PARAMS": {
|
"AMAZON_SES_CLIENT_PARAMS": {
|
||||||
# This setting provides Anymail-specific AWS credentials to boto3.client(),
|
# This setting provides Anymail-specific AWS credentials to boto3.client(),
|
||||||
@@ -67,7 +68,7 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
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(
|
self.message = AnymailMessage(
|
||||||
"Anymail Amazon SES integration test",
|
"Anymail Amazon SES integration test",
|
||||||
"Text content",
|
"Text content",
|
||||||
@@ -76,6 +77,16 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
)
|
)
|
||||||
self.message.attach_alternative("<p>HTML content</p>", "text/html")
|
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):
|
def test_simple_send(self):
|
||||||
# Example of getting the Amazon SES send status and message id from the message
|
# Example of getting the Amazon SES send status and message id from the message
|
||||||
sent_count = self.message.send()
|
sent_count = self.message.send()
|
||||||
@@ -118,7 +129,6 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
headers={"X-Anymail-Test": "value"},
|
headers={"X-Anymail-Test": "value"},
|
||||||
metadata={"meta1": "simple_string", "meta2": 2},
|
metadata={"meta1": "simple_string", "meta2": 2},
|
||||||
tags=["Re-engagement", "Cohort 12/2017"],
|
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("attachment1.txt", "Here is some\ntext for you", "text/plain")
|
||||||
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
|
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
|
||||||
@@ -139,16 +149,14 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
|||||||
|
|
||||||
def test_stored_template(self):
|
def test_stored_template(self):
|
||||||
# Using a template created like this:
|
# Using a template created like this:
|
||||||
# boto3.client('sesv2').create_email_template(
|
# boto3.client('ses').create_template(Template={
|
||||||
# TemplateName="TestTemplate",
|
# "TemplateName": "TestTemplate",
|
||||||
# TemplateContent={
|
# "SubjectPart": "Your order {{order}} shipped",
|
||||||
# "Subject": "Your order {{order}} shipped",
|
# "HtmlPart": "<h1>Dear {{name}}:</h1>"
|
||||||
# "Html": "<h1>Dear {{name}}:</h1>"
|
|
||||||
# "<p>Your order {{order}} shipped {{ship_date}}.</p>",
|
# "<p>Your order {{order}} shipped {{ship_date}}.</p>",
|
||||||
# "Text": "Dear {{name}}:\r\n"
|
# "TextPart": "Dear {{name}}:\r\n"
|
||||||
# "Your order {{order}} shipped {{ship_date}}."
|
# "Your order {{order}} shipped {{ship_date}}."
|
||||||
# },
|
# })
|
||||||
# )
|
|
||||||
message = AnymailMessage(
|
message = AnymailMessage(
|
||||||
template_id="TestTemplate",
|
template_id="TestTemplate",
|
||||||
from_email=formataddr(("Test From", self.from_email)),
|
from_email=formataddr(("Test From", self.from_email)),
|
||||||
Reference in New Issue
Block a user