SESのバウンスや苦情のログをDynamoDBに保存するようにしてみた話
ごきげんよう!ハンズラボの小川です。
Amazon SESを利用する際、避けては通れないのがバウンスや苦情への対策です。
バウンス率・苦情率が高いと最悪の場合SESでのメール送信ができない状態になってしまいます。
そうならないよう、バウンス率・苦情率や原因を把握しておくことは大切ですね。
というわけで、今回は対策の一環として、バウンスや苦情の発生時にログをDynamoDBに保存する仕組みを作ってみました!いざバウンス率や苦情率が高くなった時、発生タイミングや送信先アドレス、発生原因の把握にきっと一役買ってくれることでしょう。
環境
- macOS Monterey
- Framework Core: 3.27.0
Plugin: 6.2.3
SDK: 4.3.2
- serverless-python-requirements インストール済み
実現したいこと
SESには、バウンスや苦情が発生した際にSNSトピックに通知を飛ばす「フィードバック通知」の仕組みがあります。今回はこのフィードバック通知を利用して、SNSからLambda関数を呼び出し、そのLambda関数で通知内容をDynamoDBに保存する、という流れにします。
リソースの準備
では早速、Serverless Frameworkを使用してリソースを作成するための準備を行いましょう!
(注意) ・今回ご紹介するのはバウンス・苦情発生時のログの保管の方法だけです。 バウンス率や苦情率の監視は別途ご用意いただく必要があります ・メール送信のためのドメインIDは設定できていることを前提とします ・各種パラメーターはお使いの環境に合わせて適宜修正してください ・コードに拙い部分があってもご容赦ください。あくまで参考として見ていただければと思います
ディレクトリ構成(必要部分だけ抜粋)
.
├── Pipfile
├── functions
│ └── ses_feedback_notifications.py
├── resource
│ └── ses_notification.yml
└── serverless.yml
Pipfile
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
boto3 = "*"
[dev-packages]
pytest = "*"
flake8 = "*"
autopep8 = "*"
[requires]
python_version = "3.9"
Lambda関数(functions/ses_feedback_notifications.py)
SNSに呼び出されるLambda関数です。
SNSから受け取ったeventからログとして必要な部分を抜粋し、DynamoDBに保管します。
eventの内容は以下の公式サイトに記載されています。
ぜひご参照いただいて、DynamoDBに保管したい情報を取捨選択してください。
Amazon SES の Amazon SNS 通知コンテンツ
また、ログを永遠に残す必要もないかな?と思ったので、保存期間を90日間としています。
ずっと残したい!という方は、expired_at_unix_timeまわりは削除しちゃってください。
import datetime
import json
import logging
import boto3
dynamodb = boto3.client('dynamodb')
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
def handler(event, context):
"""
SESでバウンスや苦情が発生した際に、SNS経由で発火するLambda。
DynamoDBに通知内容を抜粋して保管する。
"""
logger.info(event)
# テーブル定義
table_name = 'SesFeedbackNotificationsTable'
# ログの保管を90日とする
expired_at = datetime.datetime.now() + datetime.timedelta(days=90)
expired_at_unix_time = int(expired_at.timestamp())
for record in event['Records']:
message = json.loads(record['Sns']['Message'])
notification_type = message['notificationType']
# バウンスの場合
if notification_type == 'Bounce':
source = message['mail']['source']
bounce_type = message['bounce']['bounceType']
bounce_sub_type = message['bounce']['bounceSubType']
timestamp = message['bounce']['timestamp']
for recipient in message['bounce']['bouncedRecipients']:
# 一つのタイムスタンプで複数の受取人情報を含むメッセージを受け取る可能性があるため、
# メッセージ中のタイムスタンプではなく現在時刻をマイクロ秒まで取得してキーとして利用する
created_at_jst = str(datetime.datetime.now())
# DynamoDBに書き込み
item = {
'notification_type': {'S': notification_type},
'created_at_jst': {'S': created_at_jst},
'timestamp': {'S': timestamp},
'bounce_type': {'S': bounce_type},
'bounce_sub_type': {'S': bounce_sub_type},
'recipient_address': {'S': recipient['emailAddress']},
'source': {'S': source},
'expired_at_unix_time': {'N': str(expired_at_unix_time)},
}
try:
dynamodb.put_item(TableName=table_name, Item=item)
except Exception as error:
logger.error(error)
raise
# 苦情の場合
elif notification_type == "Complaint":
source = message['mail']['source']
complaint_feedback_type = message['complaint']['complaintFeedbackType']
timestamp = message['bounce']['timestamp']
for recipient in message['complaint']['complainedRecipients']:
# 一つのタイムスタンプで複数の受取人情報を含むメッセージを受け取る可能性があるため、
# メッセージ中のタイムスタンプではなく現在時刻をマイクロ秒まで取得してキーとして利用する
created_at_jst = str(datetime.datetime.now())
# DynamoDBに書き込み
item = {
'notification_type': {'S': notification_type},
'created_at_jst': {'S': created_at_jst},
'timestamp': {'S': timestamp},
'complaint_feedback_type': {'S': complaint_feedback_type},
'recipient_address': {'S': recipient['emailAddress']},
'source': {'S': source},
'expired_at_unix_time': {'N': str(expired_at_unix_time)},
}
try:
dynamodb.put_item(TableName=table_name, Item=item)
except Exception as error:
logger.error(error)
raise
return
リソース(resource/ses_notification.yml)
SNSトピックとログ保管用DynamoDBテーブルを作成します。
Lambda関数のところでも説明したとおりログに保存期間を設けたいので、TTLを設定しました。
ログをずっと残したい!という方は、TTL設定は外してくださいね。
Resources:
# バウンス・苦情発生時に通知する用のSNSトピック
SesFeedbackNotificationsTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: ses-feedback-notifications-topic
SesFeedbackNotificationsTopicPolicy:
Type: AWS::SNS::TopicPolicy
Properties:
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ses.amazonaws.com
Action:
- SNS:Publish
Resource: !Ref SesFeedbackNotificationsTopic
Topics:
- !Ref SesFeedbackNotificationsTopic
# バウンス・苦情発生時にログを保管しておくDynamoDB
SesFeedbackNotificationsTable:
# テーブル定義 --------------------------------------------------------------------
# notification_type | String | PK | BounceまたはComplaint
# created_at_jst | String | SK | アイテムを登録した時刻(JST)
# expired_at_unix_time | Number | | 有効期限
# -------------------------------------------------------------------------------
Type: AWS::DynamoDB::Table
Properties:
KeySchema:
-
AttributeName: notification_type
KeyType: HASH
-
AttributeName: created_at_jst
KeyType: RANGE
AttributeDefinitions:
-
AttributeName: notification_type
AttributeType: 'S'
-
AttributeName: created_at_jst
AttributeType: 'S'
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
SSESpecification:
SSEEnabled: true
SSEType: KMS
TableName: SesFeedbackNotificationsTable
TimeToLiveSpecification:
AttributeName: expired_at_unix_time
Enabled: true
serverless.yml
Lambda関数にDynamoDBへのPutItem権限をつけるのを忘れないようにします。
service: service-name
frameworkVersion: ">=3.27.0 <4.0.0"
plugins:
- serverless-python-requirements
custom:
defaultStage: dev
pythonRequirements:
usePipenv: true
layer: true
dockerizePip: true
dockerImage: public.ecr.aws/sam/build-python3.9:latest
provider:
name: aws
runtime: python3.9
region: ap-northeast-1
profile: ${opt:profile, ''}
stage: ${opt:stage, self:custom.defaultStage}
environment:
TZ: Asia/Tokyo
REGION: ${self:provider.region}
STAGE: ${self:provider.stage}
iam:
role:
statements:
- Effect: Allow
Action:
- logs:DescribeLogGroups
- logs:DescribeSubscriptionFilters
- logs:PutSubscriptionFilter
- logs:DeleteSubscriptionFilter
Resource:
- arn:aws:logs:${aws:region}:${aws:accountId}:log-group:*
- Effect: Allow
Action:
- dynamodb:PutItem
Resource:
- arn:aws:dynamodb:${aws:region}:${aws:accountId}:table/SesFeedbackNotificationsTable
logRetentionInDays: 14
versionFunctions: false
package:
patterns:
- '!node_modules/**'
- '!.vscode/**'
- '!.venv/**'
- '!.flake8'
- '!.gitignore'
- '!Pipfile'
- '!Pipfile.lock'
- '!README.md'
- '!package.json'
- '!package-lock.json'
functions:
SesFeedbackNotifications:
handler: functions/ses_feedback_notifications.handler
layers:
- !Ref PythonRequirementsLambdaLayer
memorySize: 256
timeout: 300
events:
- sns:
arn: !Ref SesFeedbackNotificationsTopic
topicName: ses-feedback-notifications-topic
resources:
- ${file(./resource/ses_notification.yml)}
デプロイ
必要なファイルが用意できたら、さくっとデプロイしちゃいましょう。
(stageの値はお好みに合わせて変更してくださいね)
$ sls deploy --stage dev --config serverless.yml
デプロイ後の作業
さて!デプロイ後、マネジメントコンソールで少し作業が必要です。
まずはSESのコンソールに接続して、フィードバック通知を設定したいドメインIDまたはメールアドレスをクリックします。
[通知]タブを選択後、「フィードバック通知」部分の[編集]をクリックします。
「バウンスフィードバック」と「苦情のフィードバック」に先ほど作成したSNSトピックを設定して[変更の保存]をクリック。
「Bounce」と「Complaint」のSNSトピックが設定されていれば作業完了です!
動作確認
設定が終わったら動作確認をしてみましょう。マネジメントコンソールから簡単にテストできます。
SESのコンソールから、動作確認したいドメインIDまたはメールアドレスにチェックを入れて[テストEメールの送信]をクリックします。
「メッセージの詳細」部分を設定して、[テストEメールの送信]をクリックします。
大切なのは「シナリオ」部分です。今回はバウンスのテストを行なってみましょう。
無事に処理が動いていれば、DynamoDBにログが保存されているはずです。
どれどれ、見てみましょう…。
無事、保存されていますね!
さいごに
幸いなことに今のところバウンス率や苦情率が問題になったケースは無いのですが、ログを保存しておくことで「万が一問題が発生しても、わたしたちにはログがある!」と思えるのでおすすめです。
この記事がどなたかの参考になれば幸いです。ではまた!
参考:
Amazon SNSを使用したAmazon SES通知の受信