Amazon SES バウンスメール対策（通知、収集、監視）

# 概要
Amazon SES のバウンスメールについて、インフラ観点の対策を3つ取り上げ、設定方法を紹介します。
また、AWS 側で実施している対策についても軽く紹介します。

＜目次＞

* 前提情報
* 実施したバウンス対策
  * Slack 通知
  * DynamoDB ログ格納
  * Datadog モニター
* AWS による対策
  * サプレッションリストによるバウンス抑制
  * ブラックリスト (DNSBL) 対策

# 前提情報
前提情報が不要な方はスキップしてください。

### バウンスとは
> バウンスメールとは、何らかの原因でメールが相手まで到達できなかったメールのことです。 バウンスメールには以下の２種類があります。
一時的なエラーによるソフトバウンス（例：メールボックスの容量がいっぱいだったなど）
恒久的なエラーによるハードバウンス（例：メールアドレスが存在しないなど）
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 をトリガーします。

![amazon_ses.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/619762/f0d64e6d-ef3b-375b-22b6-f3c9ccc60205.png)

本稿では、SNS、Lambda、DynamoDB は CloudFormation で作成しています。
テンプレートと設定手順については後述します。

### Slack 通知

手動で設定する場合は、[こちら](https://qiita.com/kenjiszk/items/77895098a9169220b529)の手順が参考になります。
CloudFormation で作成する場合は、後述のテンプレートを参考にしてください。

下記は、Lambda にデプロイするソースです。

```lambda_function.py
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

```

デプロイ完了後、[メールボックスシュミレータ](https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/send-email-simulator.html)を使ってテストします。
すると、こちらのように通知されます。
![Amazon SES 77 1423.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/619762/042ba6ff-7125-faf3-bddb-06b09cdcc81d.png)

### DynamoDB ログ格納
手動で設定する場合は、[公式の手順](https://aws.amazon.com/jp/premiumsupport/knowledge-center/lambda-sns-ses-dynamodb/)が参考になります。
CloudFormation で作成する場合は、後述のテンプレートを参考にしてください。

下記は公式の手順を元に一部改変したソースになります。
改変した箇所は下記の2点です。
・`Delivery` ログは格納不要であるため削除
・テーブル名やカラム名が冗長であったため修正

```lambda_function.js
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 のコンソールからログ詳細を確認したり、時刻やアドレスでのフィルタリングが可能になります。

![image2021-11-5_14-26-47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/619762/f39ebd44-44e9-3f17-54d6-e04cb0e19d42.png)


### CloudFormation によるデプロイ
「Slack 通知」と「DynamoDB ログ格納」を CloudFormation を利用してデプロイする手順になります。

まず、下記のテンプレートファイルを手元に用意します。

```template.yaml
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 トピックを選択します。
![Feedback notifications Info.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/619762/aa9490f5-ba05-79df-8e86-3ad92579baf4.png)

これで Slack 通知と DynamoDB ログ格納の設定は完了です。

### Datadog モニター

SES はデフォルトでバウンス率や苦情率のメトリクスを CloudWatch に連携しています。
AWS によると バウンス率は 5% 未満、苦情率は 0.1% 未満を推奨しています。（[参考](https://aws.amazon.com/jp/premiumsupport/knowledge-center/ses-reputation-dashboard-bounce-rate/)）

よって、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 を利用して設定しました。バウンスが発生しないとメトリクスが送られてこないため、[デフォルトゼロ補間](https://docs.datadoghq.com/ja/dashboards/functions/interpolation/)を使ったクエリを設定しています。（[参考](https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/reputationdashboard-cloudwatch-alarm.html)）

```
avg(last_10m):default_zero(avg:aws.ses.reputation_bounce_rate{aws_account:${AccountID}}) > 5
```

# AWS による対策

### サプレッションリストによるバウンス抑制

サプレッションリストは、宛先アドレスのNGリストのようなものになります。
バウンスや苦情が発生したメールアドレスは、サプレッションリストに自動的に追加されます。
サプレッションリストに追加されたアドレスにもう一度送信しようとしても SES はメールを送信しません。

これにより、存在しないアドレスにを送信し続けることによるバウンス率の上昇を防げます。
なお、サプレッションリストのアドレスは手動で削除するまで残り続けます。

サプレッションリストには、[グローバル](https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/sending-email-global-suppression-list.html)と[アカウントレベル](https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/sending-email-suppression-list.html)の2種類あります。
上述のサプレッションリストは全てアカウントレベルを指しています。

### ブラックリスト (DNSBL) 対策

DNSBL と呼ばれる IP アドレスやドメインに対する評価情報を提供する仕組みがあります。
つまりはブラックリストのことで、迷惑メールや不正コンテンツを配信する送信元を公開しています。

SES はクラウドサービスなので IP アドレスは他ユーザと共有になります。
心配なのは、一部の悪徳ユーザのせいで IP アドレスが DNSBL に追加され影響を受けることです。

これに対する AWS の回答は[こちらのFAQ](https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/faqs-dnsbls.html)に記載されています。
要約すると「頑張って追加されないようにして、追加されたら頑張って消す」とのことです。

# まとめ
Amazon SES のバウンスメール対策を3点述べました。
バウンス率をどうしても下げられず停止されてしまったという話も聞くので、できる限りの対応はしておいた方がよさそうです。

ちなみに、バウンスを発生させてしまったら、それを打ち消すように正常な送信を行ってバウンス率を調整する力技もあるようです。

https://aws.amazon.com/jp/blogs/messaging-and-targeting/never-send-to-old-addresses-but-what-if-you-have-to/