概要
Amazon SES のバウンスメールについて、インフラ観点の対策を3つ取り上げ、設定方法を紹介します。
また、AWS 側で実施している対策についても軽く紹介します。
<目次>
- 前提情報
- 実施したバウンス対策
- Slack 通知
- DynamoDB ログ格納
- Datadog モニター
- AWS による対策
- サプレッションリストによるバウンス抑制
- ブラックリスト (DNSBL) 対策
前提情報
前提情報が不要な方はスキップしてください。
バウンスとは
バウンスメールとは、何らかの原因でメールが相手まで到達できなかったメールのことです。 バウンスメールには以下の2種類があります。
一時的なエラーによるソフトバウンス(例:メールボックスの容量がいっぱいだったなど)
恒久的なエラーによるハードバウンス(例:メールアドレスが存在しないなど)
https://tech.connehito.com/entry/2020/09/17/152126
AWSによる送信停止
最良の結果を得るには、バウンス率を 2% 未満に維持する必要があります。これより高いバウンス率は、E メールの配信に影響する可能性があります。
バウンス率が 5% 以上になると、アカウントはレビュー対象になります。バウンス率が 10% 以上の場合は、高いバウンス率の原因となった問題が解決するまで、以後の E メール送信を一時停止することがあります。
https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/faqs-enforcement.html
実施したバウンス対策
「即時検知」「傾向監視」「ログ調査」という目的別に3つの対策を施しています。
即時検知 → Slack 通知
傾向監視 → Datadog モニター
ログ調査 → DynamoDB ログ格納
全体像
SES は、イベント発生時に SNS トピックに通知できます。
通知するイベントには、Bounces
, Complaints
, Deliveries
が選択可能です。
SNS トピックは通知を受けたら、後段の2つの Lambda をトリガーします。
本稿では、SNS、Lambda、DynamoDB は CloudFormation で作成しています。
テンプレートと設定手順については後述します。
Slack 通知
手動で設定する場合は、こちらの手順が参考になります。
CloudFormation で作成する場合は、後述のテンプレートを参考にしてください。
下記は、Lambda にデプロイするソースです。
import os
import json
import urllib.request
MESSAGE_FORMAT = """
<!here> *{} happened*
*Destination email:*```{}```
*Source email:*```{}```
*Message json:*```{}```
"""
def lambda_handler(event, context):
if event['Records'][0]['EventSource'] != 'aws:sns' :
return
message_json = json.loads(event['Records'][0]['Sns']['Message'])
notification_type = message_json['notificationType']
if notification_type == 'Bounce':
destination_email = message_json['bounce']['bouncedRecipients'][0]['emailAddress']
source_email = message_json['mail']['source']
elif notification_type == 'Complaint':
destination_email = message_json['complaint']['complainedRecipients'][0]['emailAddress']
source_email = message_json['mail']['source']
else:
return
message = MESSAGE_FORMAT.format(notification_type, destination_email, source_email, json.dumps(message_json))
send_data = {
'text': message,
}
send_text = ('payload=' + json.dumps(send_data)).encode('utf-8')
request = urllib.request.Request(
os.environ['SLACK_URL'],
data=send_text,
method='POST'
)
with urllib.request.urlopen(request) as response:
response_body = response.read().decode('utf-8')
print(response_body)
return
デプロイ完了後、メールボックスシュミレータを使ってテストします。
すると、こちらのように通知されます。
DynamoDB ログ格納
手動で設定する場合は、公式の手順が参考になります。
CloudFormation で作成する場合は、後述のテンプレートを参考にしてください。
下記は公式の手順を元に一部改変したソースになります。
改変した箇所は下記の2点です。
・Delivery
ログは格納不要であるため削除
・テーブル名やカラム名が冗長であったため修正
console.log("Loading event");
var aws = require("aws-sdk");
var ddb = new aws.DynamoDB({ params: { TableName: "SesEventMessages" } });
exports.handler = function (event, context, callback) {
console.log("Received event:", JSON.stringify(event, null, 2));
var timeStamp = event.Records[0].Sns.Timestamp;
var topicArn = event.Records[0].Sns.TopicArn;
var message = event.Records[0].Sns.Message;
message = JSON.parse(message);
var notificationType = message.notificationType;
var messageId = message.mail.messageId;
var destinationAddress = message.mail.destination.toString();
var lambdaReceiveTime = new Date().toString();
if (notificationType == "Bounce") {
var reportingMTA = message.bounce.reportingMTA;
var bouncedRecipients = JSON.stringify(message.bounce.bouncedRecipients);
var itemParams = {
Item: {
messageId: { S: messageId },
timeStamp: { S: timeStamp },
reportingMTA: { S: reportingMTA },
destinationAddress: { S: destinationAddress },
bouncedRecipients: { S: bouncedRecipients },
notificationType: { S: notificationType },
},
};
ddb.putItem(itemParams, function (err, data) {
if (err) {
callback(err)
} else {
console.log(data);
callback(null,'')
}
});
} else if (notificationType == "Complaint") {
var complaintFeedbackType = message.complaint.complaintFeedbackType;
var feedbackId = message.complaint.feedbackId;
var itemParamscomp = {
Item: {
messageId: { S: messageId },
timeStamp: { S: timeStamp },
complaintFeedbackType: { S: complaintFeedbackType },
feedbackId: { S: feedbackId },
destinationAddress: { S: destinationAddress },
notificationType: { S: notificationType },
},
};
ddb.putItem(itemParamscomp, function (err, data) {
if (err) {
callback(err)
} else {
console.log(data);
callback(null,'')
}
});
}
};
デプロイ完了すると、スクショのように DynamoDB のコンソールからログ詳細を確認したり、時刻やアドレスでのフィルタリングが可能になります。
CloudFormation によるデプロイ
「Slack 通知」と「DynamoDB ログ格納」を CloudFormation を利用してデプロイする手順になります。
まず、下記のテンプレートファイルを手元に用意します。
AWSTemplateFormatVersion: 2010-09-09
Parameters:
GlobalPrefix:
Type: 'String'
Default: 'ses-bounce'
GlobalEnvironment:
Type: 'String'
Default: 'stg'
AllowedValues: ['stg','prd']
S3BucketInfra:
Type: String
Default: 'global-stg-infra'
AllowedValues: ['global-stg-infra','global-prd-infra']
SlackUrlNotifyBounce:
Type: String
NoEcho: true
Default: 'Enter Incomming Webhooks URL'
Resources:
# トピックとSESドメインとの紐付けはコンソールから手動で実施
TopicForBounce:
Type: AWS::SNS::Topic
Properties:
TopicName: !Sub '${GlobalPrefix}-topic-${GlobalEnvironment}'
DisplayName: !Sub '${GlobalPrefix}-topic-${GlobalEnvironment}'
# Slackに通知するLambda関数をSNSトピックに紐付け
SubscriptionNotifyBounce:
Type: AWS::SNS::Subscription
Properties:
TopicArn: !Ref TopicForBounce
Protocol: 'lambda'
Endpoint: !GetAtt LambdaNotifyBounce.Arn
# DynamoDBにデータを格納するLambda関数をSNSトピックに紐付け
SubscriptionStoreBounce:
Type: AWS::SNS::Subscription
Properties:
TopicArn: !Ref TopicForBounce
Protocol: 'lambda'
Endpoint: !GetAtt LambdaStoreBounce.Arn
IAMRoleLambdaForBounce:
Type: 'AWS::IAM::Role'
Properties:
RoleName: !Sub '${GlobalPrefix}-lambda-role-${GlobalEnvironment}'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: 'Allow'
Principal:
Service: 'lambda.amazonaws.com'
Action: 'sts:AssumeRole'
ManagedPolicyArns:
- 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
- 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess'
Policies:
- PolicyDocument:
Statement:
- Action:
- 'ses:SendBounce'
Effect: 'Allow'
Resource: '*'
PolicyName: !Sub '${GlobalPrefix}-send-bounce-${GlobalEnvironment}'
- PolicyDocument:
Statement:
- Action:
- 'dynamodb:PutItem'
Effect: 'Allow'
Resource: !GetAtt DDBTableForBounce.Arn
PolicyName: !Sub '${GlobalPrefix}-dynamodb-putitem-${GlobalEnvironment}'
# Slackに通知するLambda関数
LambdaNotifyBounce:
Type: 'AWS::Lambda::Function'
Properties:
FunctionName: !Sub '${GlobalPrefix}-notification-${GlobalEnvironment}'
Description: 'Slack Notification for SES Bounce and Complaint'
Code:
# CI/CD等でlambda_function.pyをzipにして配置
S3Bucket: !Ref S3BucketInfra
S3Key: 'lambda/ses_bounce_notice/lambda_function.zip'
Runtime: 'python3.9'
Handler: 'lambda_function.lambda_handler'
Role: !GetAtt IAMRoleLambdaForBounce.Arn
MemorySize: '512'
Timeout: '600'
Environment:
Variables:
SLACK_URL: !Ref SlackUrlNotifyBounce # Incomming Webhooksで生成されるURL
LambdaInvokePermissionNotifyBounce:
Type: 'AWS::Lambda::Permission'
Properties:
FunctionName: !Ref LambdaNotifyBounce
Action: 'lambda:InvokeFunction'
Principal: 'sns.amazonaws.com'
SourceAccount: !Ref 'AWS::AccountId'
SourceArn: !Ref TopicForBounce
# DynamoDBにデータを格納するLambda関数
LambdaStoreBounce:
Type: 'AWS::Lambda::Function'
Properties:
FunctionName: !Sub '${GlobalPrefix}-storage-${GlobalEnvironment}'
Description: 'Store SES Bounce and Complaint to DynamoDB'
Code:
# CI/CD等でlambda_function.jsをzipにして配置
S3Bucket: !Ref S3BucketInfra
S3Key: 'lambda/ses_bounce_store/lambda_function.zip'
Runtime: 'nodejs14.x'
Handler: 'lambda_function.handler'
Role: !GetAtt IAMRoleLambdaForBounce.Arn
MemorySize: '512'
Timeout: '600'
LambdaInvokePermissionStoreBounce:
Type: 'AWS::Lambda::Permission'
Properties:
FunctionName: !Ref LambdaStoreBounce
Action: 'lambda:InvokeFunction'
Principal: 'sns.amazonaws.com'
SourceAccount: !Ref 'AWS::AccountId'
SourceArn: !Ref TopicForBounce
DDBTableForBounce:
Type: 'AWS::DynamoDB::Table'
Properties:
TableName: "SesEventMessages"
AttributeDefinitions:
-
AttributeName: "messageId"
AttributeType: "S"
-
AttributeName: "timeStamp"
AttributeType: "S"
-
AttributeName: "notificationType"
AttributeType: "S"
-
AttributeName: "complaintFeedbackType"
AttributeType: "S"
KeySchema:
-
AttributeName: "messageId"
KeyType: "HASH" # partition key
-
AttributeName: "timeStamp"
KeyType: "RANGE" # sort key
GlobalSecondaryIndexes:
# SecondaryIndex名の命名規則は下記とする
# AttributeName1 + AttributeName2 + ... + -Index
-
IndexName: "notificationType-timeStamp-Index"
KeySchema:
-
AttributeName: "notificationType"
KeyType: "HASH"
-
AttributeName: "timeStamp"
KeyType: "RANGE"
Projection:
ProjectionType: "KEYS_ONLY"
-
IndexName: "complaintFeedbackType-timeStamp-Index"
KeySchema:
-
AttributeName: "complaintFeedbackType"
KeyType: "HASH"
-
AttributeName: "timeStamp"
KeyType: "RANGE"
Projection:
ProjectionType: "KEYS_ONLY"
BillingMode: "PAY_PER_REQUEST" # 発生頻度が予測不可なためキャパシティプロビジョニングは行わない
上述の Slack 通知用ソース (lambda_function.py
) と DynamoDB 格納用ソース (lambda_function.js
) を適当な S3 に zip で格納し、CloudFormation でスタックを作成します。
最後に SES と SNS トピックを紐付けます。
SES コンソール画面の Verified identities
> 対象ドメイン
> Notifications
> Feedback notifications
から通知したい Type と宛先の SNS トピックを選択します。
これで Slack 通知と DynamoDB ログ格納の設定は完了です。
Datadog モニター
SES はデフォルトでバウンス率や苦情率のメトリクスを CloudWatch に連携しています。
AWS によると バウンス率は 5% 未満、苦情率は 0.1% 未満を推奨しています。(参考)
よって、AWS 推奨値を超えたら Alert 、推奨値の半分を超えたら Warning を発報するように設定しました。
CloudWatch メトリクス名 | Datadog メトリクス名 | warning | alert | |
---|---|---|---|---|
バウンス率 | Reputation.BounceRate | aws.ses.reputation_bounce_rate | 2.5% | 5% |
苦情率 | Reputation.ComplaintRate | aws.ses.reputation_complaint_rate | 0.05% | 0.1% |
なお、CloudWatch メトリクスを Datadog に収集していたため今回は Datadog Monitor を利用して設定しました。バウンスが発生しないとメトリクスが送られてこないため、デフォルトゼロ補間を使ったクエリを設定しています。(参考)
avg(last_10m):default_zero(avg:aws.ses.reputation_bounce_rate{aws_account:${AccountID}}) > 5
AWS による対策
サプレッションリストによるバウンス抑制
サプレッションリストは、宛先アドレスのNGリストのようなものになります。
バウンスや苦情が発生したメールアドレスは、サプレッションリストに自動的に追加されます。
サプレッションリストに追加されたアドレスにもう一度送信しようとしても SES はメールを送信しません。
これにより、存在しないアドレスにを送信し続けることによるバウンス率の上昇を防げます。
なお、サプレッションリストのアドレスは手動で削除するまで残り続けます。
サプレッションリストには、グローバルとアカウントレベルの2種類あります。
上述のサプレッションリストは全てアカウントレベルを指しています。
ブラックリスト (DNSBL) 対策
DNSBL と呼ばれる IP アドレスやドメインに対する評価情報を提供する仕組みがあります。
つまりはブラックリストのことで、迷惑メールや不正コンテンツを配信する送信元を公開しています。
SES はクラウドサービスなので IP アドレスは他ユーザと共有になります。
心配なのは、一部の悪徳ユーザのせいで IP アドレスが DNSBL に追加され影響を受けることです。
これに対する AWS の回答はこちらのFAQに記載されています。
要約すると「頑張って追加されないようにして、追加されたら頑張って消す」とのことです。
まとめ
Amazon SES のバウンスメール対策を3点述べました。
バウンス率をどうしても下げられず停止されてしまったという話も聞くので、できる限りの対応はしておいた方がよさそうです。
ちなみに、バウンスを発生させてしまったら、それを打ち消すように正常な送信を行ってバウンス率を調整する力技もあるようです。