Skip to content

Commit 063fb08

Browse files
committed
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.)
1 parent 1da9011 commit 063fb08

File tree

1 file changed

+55
-26
lines changed

1 file changed

+55
-26
lines changed

anymail/webhooks/amazon_ses.py

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
from __future__ import annotations
2+
13
import io
24
import json
5+
import typing
36
from base64 import b64decode
47

58
from django.http import HttpResponse
@@ -144,6 +147,21 @@ def parse_events(self, request):
144147
def esp_to_anymail_events(self, ses_event, sns_message):
145148
raise NotImplementedError()
146149

150+
def get_boto_client(self, service_name: str, **kwargs):
151+
"""
152+
Return a boto3 client for service_name, using session_params and
153+
client_params from settings. Any kwargs are treated as additional
154+
client_params (overriding settings values).
155+
"""
156+
if kwargs:
157+
client_params = self.client_params.copy()
158+
client_params.update(kwargs)
159+
else:
160+
client_params = self.client_params
161+
return boto3.session.Session(**self.session_params).client(
162+
service_name, **client_params
163+
)
164+
147165
def auto_confirm_sns_subscription(self, sns_message):
148166
"""
149167
Automatically accept a subscription to Amazon SNS topics,
@@ -193,15 +211,14 @@ def auto_confirm_sns_subscription(self, sns_message):
193211
raise ValueError(
194212
"Invalid ARN format '{topic_arn!s}'".format(topic_arn=topic_arn)
195213
)
196-
client_params = self.client_params.copy()
197-
client_params["region_name"] = region
198214

199-
sns_client = boto3.session.Session(**self.session_params).client(
200-
"sns", **client_params
201-
)
202-
sns_client.confirm_subscription(
203-
TopicArn=topic_arn, Token=token, AuthenticateOnUnsubscribe="true"
204-
)
215+
sns_client = self.get_boto_client("sns", region_name=region)
216+
try:
217+
sns_client.confirm_subscription(
218+
TopicArn=topic_arn, Token=token, AuthenticateOnUnsubscribe="true"
219+
)
220+
finally:
221+
sns_client.close()
205222

206223

207224
class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
@@ -371,28 +388,16 @@ def esp_to_anymail_events(self, ses_event, sns_message):
371388
else:
372389
message = AnymailInboundMessage.parse_raw_mime(content)
373390
elif action_type == "S3":
374-
# download message from s3 into memory, then parse. (SNS has 15s limit
391+
# Download message from s3 and parse. (SNS has 15s limit
375392
# for an http response; hope download doesn't take that long)
376-
bucket_name = action_object["bucketName"]
377-
object_key = action_object["objectKey"]
378-
s3 = boto3.session.Session(**self.session_params).client(
379-
"s3", **self.client_params
393+
fp = self.download_s3_object(
394+
bucket_name=action_object["bucketName"],
395+
object_key=action_object["objectKey"],
380396
)
381-
content = io.BytesIO()
382397
try:
383-
s3.download_fileobj(bucket_name, object_key, content)
384-
content.seek(0)
385-
message = AnymailInboundMessage.parse_raw_mime_file(content)
386-
except ClientError as err:
387-
# improve the botocore error message
388-
raise AnymailBotoClientAPIError(
389-
"Anymail AmazonSESInboundWebhookView couldn't download"
390-
" S3 object '{bucket_name}:{object_key}'"
391-
"".format(bucket_name=bucket_name, object_key=object_key),
392-
client_error=err,
393-
) from err
398+
message = AnymailInboundMessage.parse_raw_mime_file(fp)
394399
finally:
395-
content.close()
400+
fp.close()
396401
else:
397402
raise AnymailConfigurationError(
398403
"Anymail's Amazon SES inbound webhook works only with 'SNS' or 'S3'"
@@ -432,6 +437,30 @@ def esp_to_anymail_events(self, ses_event, sns_message):
432437
)
433438
]
434439

440+
def download_s3_object(self, bucket_name: str, object_key: str) -> typing.IO:
441+
"""
442+
Download bucket_name/object_key from S3. Must return a file-like object
443+
(bytes or text) opened for reading. Caller is responsible for closing it.
444+
"""
445+
s3_client = self.get_boto_client("s3")
446+
bytesio = io.BytesIO()
447+
try:
448+
s3_client.download_fileobj(bucket_name, object_key, bytesio)
449+
except ClientError as err:
450+
bytesio.close()
451+
# improve the botocore error message
452+
raise AnymailBotoClientAPIError(
453+
"Anymail AmazonSESInboundWebhookView couldn't download"
454+
" S3 object '{bucket_name}:{object_key}'"
455+
"".format(bucket_name=bucket_name, object_key=object_key),
456+
client_error=err,
457+
) from err
458+
else:
459+
bytesio.seek(0)
460+
return bytesio
461+
finally:
462+
s3_client.close()
463+
435464

436465
class AnymailBotoClientAPIError(AnymailAPIError, ClientError):
437466
"""An AnymailAPIError that is also a Boto ClientError"""

0 commit comments

Comments
 (0)