Skip to content

Commit

Permalink
Amazon SES: add webhook extension points; close webhook boto3 clients
Browse files Browse the repository at this point in the history
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.)
  • Loading branch information
medmunds committed Sep 9, 2024
1 parent 1da9011 commit 063fb08
Showing 1 changed file with 55 additions and 26 deletions.
81 changes: 55 additions & 26 deletions anymail/webhooks/amazon_ses.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import io
import json
import typing
from base64 import b64decode

from django.http import HttpResponse
Expand Down Expand Up @@ -144,6 +147,21 @@ def parse_events(self, request):
def esp_to_anymail_events(self, ses_event, sns_message):
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):
"""
Automatically accept a subscription to Amazon SNS topics,
Expand Down Expand Up @@ -193,15 +211,14 @@ def auto_confirm_sns_subscription(self, sns_message):
raise ValueError(
"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_params
)
sns_client.confirm_subscription(
TopicArn=topic_arn, Token=token, AuthenticateOnUnsubscribe="true"
)
sns_client = self.get_boto_client("sns", region_name=region)
try:
sns_client.confirm_subscription(
TopicArn=topic_arn, Token=token, AuthenticateOnUnsubscribe="true"
)
finally:
sns_client.close()


class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
Expand Down Expand Up @@ -371,28 +388,16 @@ def esp_to_anymail_events(self, ses_event, sns_message):
else:
message = AnymailInboundMessage.parse_raw_mime(content)
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)
bucket_name = action_object["bucketName"]
object_key = action_object["objectKey"]
s3 = boto3.session.Session(**self.session_params).client(
"s3", **self.client_params
fp = self.download_s3_object(
bucket_name=action_object["bucketName"],
object_key=action_object["objectKey"],
)
content = io.BytesIO()
try:
s3.download_fileobj(bucket_name, object_key, content)
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
message = AnymailInboundMessage.parse_raw_mime_file(fp)
finally:
content.close()
fp.close()
else:
raise AnymailConfigurationError(
"Anymail's Amazon SES inbound webhook works only with 'SNS' or 'S3'"
Expand Down Expand Up @@ -432,6 +437,30 @@ def esp_to_anymail_events(self, ses_event, sns_message):
)
]

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):
"""An AnymailAPIError that is also a Boto ClientError"""
Expand Down

0 comments on commit 063fb08

Please sign in to comment.