mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
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:
@@ -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"""
|
||||||
|
|||||||
Reference in New Issue
Block a user