mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-22 21:01:05 -05:00
387
anymail/backends/amazon_ses.py
Normal file
387
anymail/backends/amazon_ses.py
Normal file
@@ -0,0 +1,387 @@
|
||||
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
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.conf.urls import url
|
||||
|
||||
from .webhooks.amazon_ses import AmazonSESInboundWebhookView, AmazonSESTrackingWebhookView
|
||||
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
|
||||
from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView
|
||||
from .webhooks.mandrill import MandrillCombinedWebhookView
|
||||
@@ -11,12 +12,14 @@ from .webhooks.sparkpost import SparkPostInboundWebhookView, SparkPostTrackingWe
|
||||
|
||||
app_name = 'anymail'
|
||||
urlpatterns = [
|
||||
url(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'),
|
||||
url(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'),
|
||||
url(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'),
|
||||
url(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'),
|
||||
url(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'),
|
||||
url(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'),
|
||||
|
||||
url(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'),
|
||||
url(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
|
||||
url(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'),
|
||||
url(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
|
||||
|
||||
@@ -349,7 +349,7 @@ def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_b
|
||||
pass
|
||||
|
||||
if esp_name is not None:
|
||||
setting = "{}_{}".format(esp_name.upper(), name.upper())
|
||||
setting = "{}_{}".format(esp_name.upper().replace(" ", "_"), name.upper())
|
||||
else:
|
||||
setting = name.upper()
|
||||
anymail_setting = "ANYMAIL_%s" % setting
|
||||
|
||||
348
anymail/webhooks/amazon_ses.py
Normal file
348
anymail/webhooks/amazon_ses.py
Normal file
@@ -0,0 +1,348 @@
|
||||
import io
|
||||
import json
|
||||
from base64 import b64decode
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from ..backends.amazon_ses import _get_anymail_boto3_params
|
||||
from ..exceptions import (
|
||||
AnymailAPIError, AnymailConfigurationError, AnymailImproperlyInstalled, AnymailWebhookValidationFailure)
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking
|
||||
from ..utils import combine, get_anymail_setting, getfirst
|
||||
|
||||
try:
|
||||
import boto3
|
||||
import botocore.exceptions
|
||||
except ImportError:
|
||||
raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses')
|
||||
|
||||
|
||||
class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
|
||||
"""Base view class for Amazon SES webhooks (SNS Notifications)"""
|
||||
|
||||
esp_name = "Amazon SES"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# whether to automatically respond to SNS SubscriptionConfirmation requests; default True
|
||||
# (Future: could also take a TopicArn or list to auto-confirm)
|
||||
self.auto_confirm_enabled = get_anymail_setting(
|
||||
"auto_confirm_sns_subscriptions", esp_name=self.esp_name, kwargs=kwargs, default=True)
|
||||
# boto3 params for connecting to S3 (inbound downloads) and SNS (auto-confirm subscriptions):
|
||||
self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs)
|
||||
super(AmazonSESBaseWebhookView, self).__init__(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _parse_sns_message(request):
|
||||
# cache so we don't have to parse the json multiple times
|
||||
if not hasattr(request, '_sns_message'):
|
||||
try:
|
||||
body = request.body.decode(request.encoding or 'utf-8')
|
||||
request._sns_message = json.loads(body)
|
||||
except (TypeError, ValueError, UnicodeDecodeError) as err:
|
||||
raise AnymailAPIError("Malformed SNS message body %r" % request.body, raised_from=err)
|
||||
return request._sns_message
|
||||
|
||||
def validate_request(self, request):
|
||||
# Block random posts that don't even have matching SNS headers
|
||||
sns_message = self._parse_sns_message(request)
|
||||
header_type = request.META.get("HTTP_X_AMZ_SNS_MESSAGE_TYPE", "<<missing>>")
|
||||
body_type = sns_message.get("Type", "<<missing>>")
|
||||
if header_type != body_type:
|
||||
raise AnymailWebhookValidationFailure(
|
||||
'SNS header "x-amz-sns-message-type: %s" doesn\'t match body "Type": "%s"'
|
||||
% (header_type, body_type))
|
||||
|
||||
if header_type not in ["Notification", "SubscriptionConfirmation", "UnsubscribeConfirmation"]:
|
||||
raise AnymailAPIError("Unknown SNS message type '%s'" % header_type)
|
||||
|
||||
header_id = request.META.get("HTTP_X_AMZ_SNS_MESSAGE_ID", "<<missing>>")
|
||||
body_id = sns_message.get("MessageId", "<<missing>>")
|
||||
if header_id != body_id:
|
||||
raise AnymailWebhookValidationFailure(
|
||||
'SNS header "x-amz-sns-message-id: %s" doesn\'t match body "MessageId": "%s"'
|
||||
% (header_id, body_id))
|
||||
|
||||
# Future: Verify SNS message signature
|
||||
# https://docs.aws.amazon.com/sns/latest/dg/SendMessageToHttp.verify.signature.html
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
# request has *not* yet been validated at this point
|
||||
if self.basic_auth and not request.META.get("HTTP_AUTHORIZATION"):
|
||||
# Amazon SNS requires a proper 401 response before it will attempt to send basic auth
|
||||
response = HttpResponse(status=401)
|
||||
response["WWW-Authenticate"] = 'Basic realm="Anymail WEBHOOK_SECRET"'
|
||||
return response
|
||||
return super(AmazonSESBaseWebhookView, self).post(request, *args, **kwargs)
|
||||
|
||||
def parse_events(self, request):
|
||||
# request *has* been validated by now
|
||||
events = []
|
||||
sns_message = self._parse_sns_message(request)
|
||||
sns_type = sns_message.get("Type")
|
||||
if sns_type == "Notification":
|
||||
message_string = sns_message.get("Message")
|
||||
try:
|
||||
ses_event = json.loads(message_string)
|
||||
except (TypeError, ValueError):
|
||||
if message_string == "Successfully validated SNS topic for Amazon SES event publishing.":
|
||||
pass # this Notification is generated after SubscriptionConfirmation
|
||||
else:
|
||||
raise AnymailAPIError("Unparsable SNS Message %r" % message_string)
|
||||
else:
|
||||
events = self.esp_to_anymail_events(ses_event, sns_message)
|
||||
elif sns_type == "SubscriptionConfirmation":
|
||||
self.auto_confirm_sns_subscription(sns_message)
|
||||
# else: just ignore other SNS messages (e.g., "UnsubscribeConfirmation")
|
||||
return events
|
||||
|
||||
def esp_to_anymail_events(self, ses_event, sns_message):
|
||||
raise NotImplementedError()
|
||||
|
||||
def auto_confirm_sns_subscription(self, sns_message):
|
||||
"""Automatically accept a subscription to Amazon SNS topics, if the request is expected.
|
||||
|
||||
If an SNS SubscriptionConfirmation arrives with HTTP basic auth proving it is meant for us,
|
||||
automatically load the SubscribeURL to confirm the subscription.
|
||||
"""
|
||||
if not self.auto_confirm_enabled:
|
||||
return
|
||||
|
||||
if not self.basic_auth:
|
||||
# Note: basic_auth (shared secret) confirms the notification was meant for us.
|
||||
# If WEBHOOK_SECRET isn't set, Anymail logs a warning but allows the request.
|
||||
# (Also, verifying the SNS message signature would be insufficient here:
|
||||
# if someone else tried to point their own SNS topic at our webhook url,
|
||||
# SNS would send a SubscriptionConfirmation with a valid Amazon signature.)
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Anymail received an unexpected SubscriptionConfirmation request for Amazon SNS topic "
|
||||
"'{topic_arn!s}'. (Anymail can automatically confirm SNS subscriptions if you set a "
|
||||
"WEBHOOK_SECRET and use that in your SNS notification url. Or you can manually confirm "
|
||||
"this subscription in the SNS dashboard with token '{token!s}'.)"
|
||||
"".format(topic_arn=sns_message.get('TopicArn'), token=sns_message.get('Token')))
|
||||
|
||||
# WEBHOOK_SECRET *is* set, so the request's basic auth has been verified by now (in run_validators).
|
||||
# We're good to confirm...
|
||||
sns_client = boto3.session.Session(**self.session_params).client('sns', **self.client_params)
|
||||
sns_client.confirm_subscription(
|
||||
TopicArn=sns_message["TopicArn"], Token=sns_message["Token"], AuthenticateOnUnsubscribe='true')
|
||||
|
||||
|
||||
class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
|
||||
"""Handler for Amazon SES tracking notifications"""
|
||||
|
||||
signal = tracking
|
||||
|
||||
def esp_to_anymail_events(self, ses_event, sns_message):
|
||||
# Amazon SES has two notification formats, which are almost exactly the same:
|
||||
# - https://docs.aws.amazon.com/ses/latest/DeveloperGuide/event-publishing-retrieving-sns-contents.html
|
||||
# - https://docs.aws.amazon.com/ses/latest/DeveloperGuide/notification-contents.html
|
||||
# This code should handle either.
|
||||
ses_event_type = getfirst(ses_event, ["eventType", "notificationType"], "<<type missing>>")
|
||||
if ses_event_type == "Received":
|
||||
# This is an inbound event
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set an Amazon SES *inbound* receipt rule to publish "
|
||||
"to an SNS Topic that posts to Anymail's *tracking* webhook URL. "
|
||||
"(SNS TopicArn %s)" % sns_message.get("TopicArn"))
|
||||
|
||||
event_id = sns_message.get("MessageId") # unique to the SNS notification
|
||||
try:
|
||||
timestamp = parse_datetime(sns_message["Timestamp"])
|
||||
except (KeyError, ValueError):
|
||||
timestamp = None
|
||||
|
||||
mail_object = ses_event.get("mail", {})
|
||||
message_id = mail_object.get("messageId") # same as MessageId in SendRawEmail response
|
||||
all_recipients = mail_object.get("destination", [])
|
||||
|
||||
# Recover tags and metadata from custom headers
|
||||
metadata = {}
|
||||
tags = []
|
||||
for header in mail_object.get("headers", []):
|
||||
name = header["name"].lower()
|
||||
if name == "x-tag":
|
||||
tags.append(header["value"])
|
||||
elif name == "x-metadata":
|
||||
try:
|
||||
metadata = json.loads(header["value"])
|
||||
except (ValueError, TypeError, KeyError):
|
||||
pass
|
||||
|
||||
common_props = dict( # AnymailTrackingEvent props for all recipients
|
||||
esp_event=ses_event,
|
||||
event_id=event_id,
|
||||
message_id=message_id,
|
||||
metadata=metadata,
|
||||
tags=tags,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
per_recipient_props = [ # generate individual events for each of these
|
||||
dict(recipient=email_address)
|
||||
for email_address in all_recipients
|
||||
]
|
||||
|
||||
event_object = ses_event.get(ses_event_type.lower(), {}) # e.g., ses_event["bounce"]
|
||||
|
||||
if ses_event_type == "Bounce":
|
||||
common_props.update(
|
||||
event_type=EventType.BOUNCED,
|
||||
description="{bounceType}: {bounceSubType}".format(**event_object),
|
||||
reject_reason=RejectReason.BOUNCED,
|
||||
)
|
||||
per_recipient_props = [dict(
|
||||
recipient=recipient["emailAddress"],
|
||||
mta_response=recipient.get("diagnosticCode"),
|
||||
) for recipient in event_object["bouncedRecipients"]]
|
||||
elif ses_event_type == "Complaint":
|
||||
common_props.update(
|
||||
event_type=EventType.COMPLAINED,
|
||||
description=event_object.get("complaintFeedbackType"),
|
||||
reject_reason=RejectReason.SPAM,
|
||||
user_agent=event_object.get("userAgent"),
|
||||
)
|
||||
per_recipient_props = [dict(
|
||||
recipient=recipient["emailAddress"],
|
||||
) for recipient in event_object["complainedRecipients"]]
|
||||
elif ses_event_type == "Delivery":
|
||||
common_props.update(
|
||||
event_type=EventType.DELIVERED,
|
||||
mta_response=event_object.get("smtpResponse"),
|
||||
)
|
||||
per_recipient_props = [dict(
|
||||
recipient=recipient,
|
||||
) for recipient in event_object["recipients"]]
|
||||
elif ses_event_type == "Send":
|
||||
common_props.update(
|
||||
event_type=EventType.SENT,
|
||||
)
|
||||
elif ses_event_type == "Reject":
|
||||
common_props.update(
|
||||
event_type=EventType.REJECTED,
|
||||
description=event_object["reason"],
|
||||
reject_reason=RejectReason.BLOCKED,
|
||||
)
|
||||
elif ses_event_type == "Open":
|
||||
# SES doesn't report which recipient opened the message (it doesn't
|
||||
# track them separately), so just report it for all_recipients
|
||||
common_props.update(
|
||||
event_type=EventType.OPENED,
|
||||
user_agent=event_object.get("userAgent"),
|
||||
)
|
||||
elif ses_event_type == "Click":
|
||||
# SES doesn't report which recipient clicked the message (it doesn't
|
||||
# track them separately), so just report it for all_recipients
|
||||
common_props.update(
|
||||
event_type=EventType.CLICKED,
|
||||
user_agent=event_object.get("userAgent"),
|
||||
click_url=event_object.get("link"),
|
||||
)
|
||||
elif ses_event_type == "Rendering Failure":
|
||||
event_object = ses_event["failure"] # rather than ses_event["rendering failure"]
|
||||
common_props.update(
|
||||
event_type=EventType.FAILED,
|
||||
description=event_object["errorMessage"],
|
||||
)
|
||||
else:
|
||||
# Umm... new event type?
|
||||
common_props.update(
|
||||
event_type=EventType.UNKNOWN,
|
||||
description="Unknown SES eventType '%s'" % ses_event_type,
|
||||
)
|
||||
|
||||
return [
|
||||
# AnymailTrackingEvent(**common_props, **recipient_props) # Python 3.5+ (PEP-448 syntax)
|
||||
AnymailTrackingEvent(**combine(common_props, recipient_props))
|
||||
for recipient_props in per_recipient_props
|
||||
]
|
||||
|
||||
|
||||
class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView):
|
||||
"""Handler for Amazon SES inbound notifications"""
|
||||
|
||||
signal = inbound
|
||||
|
||||
def esp_to_anymail_events(self, ses_event, sns_message):
|
||||
ses_event_type = ses_event.get("notificationType")
|
||||
if ses_event_type != "Received":
|
||||
# This is not an inbound event
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set an Amazon SES *sending* event or notification "
|
||||
"to publish to an SNS Topic that posts to Anymail's *inbound* webhook URL. "
|
||||
"(SNS TopicArn %s)" % sns_message.get("TopicArn"))
|
||||
|
||||
receipt_object = ses_event.get("receipt", {})
|
||||
action_object = receipt_object.get("action", {})
|
||||
mail_object = ses_event.get("mail", {})
|
||||
|
||||
action_type = action_object.get("type")
|
||||
if action_type == "SNS":
|
||||
content = ses_event.get("content")
|
||||
if action_object.get("encoding") == "BASE64":
|
||||
content = b64decode(content.encode("ascii"))
|
||||
message = AnymailInboundMessage.parse_raw_mime_bytes(content)
|
||||
else:
|
||||
message = AnymailInboundMessage.parse_raw_mime(content)
|
||||
elif action_type == "S3":
|
||||
# download message from s3 into memory, then parse
|
||||
# (SNS has 15s limit for an http response; hope download doesn't take that long)
|
||||
bucket_name = action_object["bucketName"]
|
||||
object_key = action_object["objectKey"]
|
||||
s3 = boto3.session.Session(**self.session_params).client("s3", **self.client_params)
|
||||
content = io.BytesIO()
|
||||
try:
|
||||
s3.download_fileobj(bucket_name, object_key, content)
|
||||
content.seek(0)
|
||||
message = AnymailInboundMessage.parse_raw_mime_file(content)
|
||||
except botocore.exceptions.ClientError as err:
|
||||
# improve the botocore error message
|
||||
raise AnymailBotoClientAPIError(
|
||||
"Anymail AmazonSESInboundWebhookView couldn't download S3 object '{bucket_name}:{object_key}'"
|
||||
"".format(bucket_name=bucket_name, object_key=object_key),
|
||||
raised_from=err)
|
||||
finally:
|
||||
content.close()
|
||||
else:
|
||||
raise AnymailConfigurationError(
|
||||
"Anymail's Amazon SES inbound webhook works only with 'SNS' or 'S3' receipt rule actions, "
|
||||
"not SNS notifications for {action_type!s} actions. (SNS TopicArn {topic_arn!s})"
|
||||
"".format(action_type=action_type, topic_arn=sns_message.get("TopicArn")))
|
||||
|
||||
message.envelope_sender = mail_object.get("source") # "the envelope MAIL FROM address"
|
||||
try:
|
||||
# "recipients that were matched by the active receipt rule"
|
||||
message.envelope_recipient = receipt_object["recipients"][0]
|
||||
except (KeyError, TypeError, IndexError):
|
||||
pass
|
||||
spam_status = receipt_object.get("spamVerdict", {}).get("status", "").upper()
|
||||
message.spam_detected = {"PASS": False, "FAIL": True}.get(spam_status) # else None if unsure
|
||||
|
||||
event_id = mail_object.get("messageId") # "unique ID assigned to the email by Amazon SES"
|
||||
try:
|
||||
timestamp = parse_datetime(mail_object["timestamp"]) # "time at which the email was received"
|
||||
except (KeyError, ValueError):
|
||||
timestamp = None
|
||||
|
||||
return [AnymailInboundEvent(
|
||||
event_type=EventType.INBOUND,
|
||||
event_id=event_id,
|
||||
message=message,
|
||||
timestamp=timestamp,
|
||||
esp_event=ses_event,
|
||||
)]
|
||||
|
||||
|
||||
class AnymailBotoClientAPIError(AnymailAPIError, botocore.exceptions.ClientError):
|
||||
"""An AnymailAPIError that is also a Boto ClientError"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raised_from = kwargs.pop('raised_from')
|
||||
assert isinstance(raised_from, botocore.exceptions.ClientError)
|
||||
assert len(kwargs) == 0 # can't support other kwargs
|
||||
# init self as boto ClientError (which doesn't cooperatively subclass):
|
||||
super(AnymailBotoClientAPIError, self).__init__(
|
||||
error_response=raised_from.response, operation_name=raised_from.operation_name)
|
||||
# emulate AnymailError init:
|
||||
self.args = args
|
||||
self.raised_from = raised_from
|
||||
Reference in New Issue
Block a user