from email.header import Header from email.mime.base import MIMEBase from django.core.mail import BadHeaderError from .base import AnymailBaseBackend, BasePayload from .._version import __version__ from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting, UNSET try: import boto3 from botocore.client import Config from botocore.exceptions import BotoCoreError, ClientError, ConnectionError except ImportError: raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses') # boto3 has several root exception classes; this is meant to cover all of them BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError) # Work around Python 2 bug in email.message.Message.to_string, where long headers # containing commas or semicolons get an extra space inserted after every ',' or ';' # not already followed by a space. https://bugs.python.org/issue25257 if Header("test,Python2,header,comma,bug", maxlinelen=20).encode() == "test,Python2,header,comma,bug": # no workaround needed HeaderBugWorkaround = None def add_header(message, name, val): message[name] = val else: # workaround: custom Header subclass that won't consider ',' and ';' as folding candidates class HeaderBugWorkaround(Header): def encode(self, splitchars=' ', **kwargs): # only split on spaces, rather than splitchars=';, ' return Header.encode(self, splitchars, **kwargs) def add_header(message, name, val): # Must bypass Django's SafeMIMEMessage.__set_item__, because its call to # forbid_multi_line_headers converts the val back to a str, undoing this # workaround. That makes this code responsible for sanitizing val: if '\n' in val or '\r' in val: raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (val, name)) val = HeaderBugWorkaround(val, header_name=name) assert isinstance(message, MIMEBase) MIMEBase.__setitem__(message, name, val) class EmailBackend(AnymailBaseBackend): """ Amazon SES Email Backend (using boto3) """ esp_name = "Amazon SES" def __init__(self, **kwargs): """Init options from Django settings""" super(EmailBackend, self).__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 BOTO_BASE_ERRORS: if not self.fail_silently: raise def close(self): if self.client is None: return # self.client.close() # boto3 doesn't currently seem to support (or require) this self.client = None 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), raised_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(AmazonSESSendRawEmailPayload, self).init_payload() self.all_recipients = [] self.mime_message = self.message.message() if HeaderBugWorkaround and "Subject" in self.mime_message: # (message.message() will have already checked subject for BadHeaderError) self.mime_message.replace_header("Subject", HeaderBugWorkaround(self.message.subject)) def call_send_api(self, ses_client): self.params["RawMessage"] = { # Note: "Destinations" is determined from message headers if not provided # "Destinations": [email.addr_spec for email in self.all_recipients], "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) 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 self.params["Destinations"] = [email.addr_spec for email in self.all_recipients] 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. add_header(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: add_header(self.mime_message, "X-Tag", tag) # creates multiple X-Tag headers, one per 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(AmazonSESSendBulkTemplatedEmailPayload, self).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) 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): self.params["Source"] = email.address # this will RFC2047-encode display_name if needed 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) 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