Amazon SES: add webhook extension points; close webhook boto3 clients

In Amazon SES webhook views (tracking and inbound):
- Close boto3 clients after use. (Not strictly required, but doesn't hurt.
  Amazon SES backend was already doing this.)
- Break out some webhook functionality to simplify subclassing.
  (E.g., to handle S3 object encryption through outside tooling, as
  AWS hasn't released a Python version of their S3 encryption client.)
This commit is contained in:
Mike Edmunds
2024-09-08 17:49:06 -07:00
parent 1da9011f50
commit 063fb08a58

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
import io import io
import json import json
import typing
from base64 import b64decode from base64 import b64decode
from django.http import HttpResponse from django.http import HttpResponse
@@ -144,6 +147,21 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
def esp_to_anymail_events(self, ses_event, sns_message): def esp_to_anymail_events(self, ses_event, sns_message):
raise NotImplementedError() raise NotImplementedError()
def get_boto_client(self, service_name: str, **kwargs):
"""
Return a boto3 client for service_name, using session_params and
client_params from settings. Any kwargs are treated as additional
client_params (overriding settings values).
"""
if kwargs:
client_params = self.client_params.copy()
client_params.update(kwargs)
else:
client_params = self.client_params
return boto3.session.Session(**self.session_params).client(
service_name, **client_params
)
def auto_confirm_sns_subscription(self, sns_message): def auto_confirm_sns_subscription(self, sns_message):
""" """
Automatically accept a subscription to Amazon SNS topics, Automatically accept a subscription to Amazon SNS topics,
@@ -193,15 +211,14 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
raise ValueError( raise ValueError(
"Invalid ARN format '{topic_arn!s}'".format(topic_arn=topic_arn) "Invalid ARN format '{topic_arn!s}'".format(topic_arn=topic_arn)
) )
client_params = self.client_params.copy()
client_params["region_name"] = region
sns_client = boto3.session.Session(**self.session_params).client( sns_client = self.get_boto_client("sns", region_name=region)
"sns", **client_params try:
) sns_client.confirm_subscription(
sns_client.confirm_subscription( TopicArn=topic_arn, Token=token, AuthenticateOnUnsubscribe="true"
TopicArn=topic_arn, Token=token, AuthenticateOnUnsubscribe="true" )
) finally:
sns_client.close()
class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView): class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
@@ -371,28 +388,16 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView):
else: else:
message = AnymailInboundMessage.parse_raw_mime(content) message = AnymailInboundMessage.parse_raw_mime(content)
elif action_type == "S3": elif action_type == "S3":
# download message from s3 into memory, then parse. (SNS has 15s limit # Download message from s3 and parse. (SNS has 15s limit
# for an http response; hope download doesn't take that long) # for an http response; hope download doesn't take that long)
bucket_name = action_object["bucketName"] fp = self.download_s3_object(
object_key = action_object["objectKey"] bucket_name=action_object["bucketName"],
s3 = boto3.session.Session(**self.session_params).client( object_key=action_object["objectKey"],
"s3", **self.client_params
) )
content = io.BytesIO()
try: try:
s3.download_fileobj(bucket_name, object_key, content) message = AnymailInboundMessage.parse_raw_mime_file(fp)
content.seek(0)
message = AnymailInboundMessage.parse_raw_mime_file(content)
except 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),
client_error=err,
) from err
finally: finally:
content.close() fp.close()
else: else:
raise AnymailConfigurationError( raise AnymailConfigurationError(
"Anymail's Amazon SES inbound webhook works only with 'SNS' or 'S3'" "Anymail's Amazon SES inbound webhook works only with 'SNS' or 'S3'"
@@ -432,6 +437,30 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView):
) )
] ]
def download_s3_object(self, bucket_name: str, object_key: str) -> typing.IO:
"""
Download bucket_name/object_key from S3. Must return a file-like object
(bytes or text) opened for reading. Caller is responsible for closing it.
"""
s3_client = self.get_boto_client("s3")
bytesio = io.BytesIO()
try:
s3_client.download_fileobj(bucket_name, object_key, bytesio)
except ClientError as err:
bytesio.close()
# 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),
client_error=err,
) from err
else:
bytesio.seek(0)
return bytesio
finally:
s3_client.close()
class AnymailBotoClientAPIError(AnymailAPIError, ClientError): class AnymailBotoClientAPIError(AnymailAPIError, ClientError):
"""An AnymailAPIError that is also a Boto ClientError""" """An AnymailAPIError that is also a Boto ClientError"""