エンジニア

SESのバウンスや苦情のログをDynamoDBに保存するようにしてみた話

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通知の受信

一覧に戻る