Amazon SESで開封イベントを追跡するシステムを構築してみた

Amazon SESで開封イベントを追跡するシステムを構築してみた

Clock Icon2025.05.27

はじめに

Amazon Simple Email Service(SES)では、イベント設定を行うことで、メッセージの開封イベントを取得できます。開封イベントを取得し、メールの開封状況を管理するシステムを検討する機会があったので、実際にやってみました。

開封イベントの仕組み

開封イベントは、トラッキングピクセルという技術を利用して取得されます。トラッキングピクセルとは、メール本文に埋め込まれた1x1ピクセルの透明な画像のことです。メールを開封するとこの画像が読み込まれて、サーバにリクエストが送信されます。

ただし、これにはいくつかの制限があります。

  • クライアント側がHTMLメールを許可していない場合、トラッキングピクセルは表示されないため、開封イベントを取得できない
  • 取得できる開封イベントは必ずしも「初回の開封」とは限らない(クライアントがキャッシュしないようになっている場合、何度も開封イベントが送信されることがあるため)

「同じメールから何度も開封イベントが飛んでくる」ことに最初は混乱しましたが、仕組みを考えれば納得できます。2回目以降の開封イベントはあまり意味を持たないことも多いかと思うので、今回は最初に受け取った開封イベントを受信したときにのみ、開封状況を更新する実装をしてみました。

今回構築するシステム

今回は以下のようなシステムを構築します。

  1. Amazon SESのSendTemplatedEmailを使ってメールを送信
  2. メッセージIDと宛先をAmazon DynamoDBに保存
  3. 開封イベントをAmazon EventBridgeで受け取り
  4. AWS Lambdaで処理して初回開封時のみDynamoDBを更新

EventBridgeへのイベント送信設定については、以下の記事を参考にしてください。ここでは、設定セットとイベント送信の設定は完了しているものとして説明を進めます。

https://dev.classmethod.jp/articles/ses-publishes-email-sending-events-eventbridge/

1. メールテンプレートの作成

まず、メールテンプレートを作成します。以下の内容をtemplate.jsonファイルとして保存します。

{
  "Template": {
    "TemplateName": "TestTemplate",
    "SubjectPart": "{{userName}} さんへのお知らせ",
    "TextPart": "{{userName}} さん、こんにちは!\nこれはテストメールです。",
    "HtmlPart": "<html><body><h1>{{userName}} さん、こんにちは!</h1><p>これはテストメールです。</p></body></html>"
  }
}

AWS CLIを使ってテンプレートを登録します。

aws ses create-template --cli-input-json file://template.json

2. テンプレートを使ったメール送信

作成したテンプレートを使ってメールを送信します。

aws ses send-templated-email \
  --source "noreply@example.com" \
  --destination "ToAddresses=dest@example.com" \
  --template "TestTemplate" \
  --template-data '{"userName":"miyan"}' \
  --configuration-set-name "test-configuration-set"

このコマンドを実行すると、以下のようなレスポンスが返ってきます。

{
  "MessageId": "TEST0196f09d60dd-7845edf1-c74f-4f3b-ad1e-a65294de0374-000000"
}

3. 送信情報のDynamoDB保存

メール送信後に返されるメッセージIDを識別子として、送信先メールアドレスと開封状況をDynamoDBに保存するようにします。

事前にテーブルを作成しておきます。

aws dynamodb create-table \
  --table-name EmailTracking \
  --attribute-definitions AttributeName=MessageId,AttributeType=S \
  --key-schema AttributeName=MessageId,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --table-class STANDARD

メール送信時に取得したメッセージIDと送信先メールアドレスをこのテーブルに保存します。

aws dynamodb put-item \
  --table-name EmailTracking \
  --item '{"MessageId": {"S": "TEST0196f09d60dd-7845edf1-c74f-4f3b-ad1e-a65294de0374-000000"}, "Email": {"S": "dest@example.com"}, "OpenStatus": {"BOOL": false}, "SentAt": {"S": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'"}}'

今回はメール送信後にDynamoDBにアイテムを追加しましたが、実際のシステムでは、配信対象リストをあらかじめデータベースで管理しておくのが実用的かなと思います。

4. 開封イベントの受信と処理

クライアントがメールを開封すると、EventBridgeに以下のような開封イベントが送信されます。

{
    "version": "0",
    "id": "33b3e315-3e16-9533-f6d8-3e91e82c22c7",
    "detail-type": "Email Opened",
    "source": "aws.ses",
    "account": "111111111111",
    "time": "2025-05-21T02:30:25Z",
    "region": "ap-northeast-1",
    "resources": [
        "arn:aws:ses:ap-northeast-1:111111111111:configuration-set/test-configuration-set"
    ],
    "detail": {
        "eventType": "Open",
        "mail": {
            "timestamp": "2025-05-21T02:12:47.965Z",
            "source": "noreply@example.com",
            "sendingAccountId": "111111111111",
            "messageId": "TEST0196f09d60dd-7845edf1-c74f-4f3b-ad1e-a65294de0374-000000",
            "destination": [
                "dest@example.com"
            ],
            "headersTruncated": false,
            "headers": [
                {
                    "name": "Date",
                    "value": "Wed, 21 May 2025 02:12:47 +0000"
                },
                {
                    "name": "From",
                    "value": "noreply@example.com"
                },
                {
                    "name": "To",
                    "value": "dest@example.com"
                },
                {
                    "name": "Subject",
                    "value": "miyan さんへのお知らせ"
                },
                {
                    "name": "MIME-Version",
                    "value": "1.0"
                },
                {
                    "name": "Content-Type",
                    "value": "multipart/alternative;  boundary=\"----=_Part_241272_72141963.1747793568063\""
                }
            ],
            "commonHeaders": {
                "from": [
                    "noreply@example.com"
                ],
                "date": "Wed, 21 May 2025 02:12:47 +0000",
                "to": [
                    "dest@example.com"
                ],
                "messageId": "TEST0196f09d60dd-7845edf1-c74f-4f3b-ad1e-a65294de0374-000000",
                "subject": "miyan さんへのお知らせ"
            },
            "tags": {
                "ses:source-tls-version": [
                    "TLSv1.3"
                ],
                "ses:operation": [
                    "SendTemplatedEmail"
                ],
                "ses:configuration-set": [
                    "test-configuration-set"
                ],
                "ses:source-ip": [
                    "xxx.xxx.xxx.xxx"
                ],
                "ses:from-domain": [
                    "example.com"
                ],
                "ses:caller-identity": [
                    "demo_user"
                ]
            }
        },
        "open": {
            "timestamp": "2025-05-21T02:30:25.490Z",
            "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
            "ipAddress": "xxx.xxx.xxx.xxx"
        }
    }
}

このイベントをLambda関数で処理し、DynamoDBの該当レコードを更新します。前述した通り、「初回の開封時のみ更新する」ようにします。以下はPythonで実装したLambda関数のサンプルコードです。

import json
import boto3
import datetime
import os

# 設定
TABLE_NAME = os.environ.get("TABLE_NAME", "EmailTracking")
TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"


def lambda_handler(event, context):
    # DynamoDB接続
    dynamodb = boto3.resource("dynamodb")
    table = dynamodb.Table(TABLE_NAME)

    # イベントデータの抽出
    detail = event["detail"]
    message_id = detail["mail"]["messageId"]
    open_timestamp = detail["open"]["timestamp"]
    user_agent = detail["open"]["userAgent"]
    ip_address = detail["open"]["ipAddress"]

    # タイムスタンプの妥当性チェック
    datetime.datetime.strptime(open_timestamp, TIMESTAMP_FORMAT)

    # 既存レコードの確認
    response = table.get_item(Key={"MessageId": message_id})
    record = response.get("Item")

    # 開封状態のチェック
    if record and record.get("OpenStatus", False):
        return

    if not record:
        return

    # 開封情報の更新
    table.update_item(
        Key={"MessageId": message_id},
        UpdateExpression="SET OpenStatus = :status, OpenedAt = :time, OpenInfo = :info",
        ExpressionAttributeValues={
            ":status": True,
            ":time": open_timestamp,
            ":info": {"userAgent": user_agent, "ipAddress": ip_address},
        },
    )

    return

IAMポリシーは dynamodb:GetItemdynamodb:UpdateItem の権限を付与する必要があります。

このLambda関数では、開封イベントを受信したときに、まず該当するメッセージIDのレコードをDynamoDBから取得し、すでに開封済み(OpenStatus = true)でないことを確認してから更新を行っています。これにより、同じメールに対して複数の開封イベントが送信されても、最初の開封時のみが記録されるようになります。

上記コードはあくまでサンプルなので、エラーハンドリングやロギングなどの実装を忘れないようにしましょう。

実装時の注意点

ちなみに、実際にシステムを構築する際には、以下の点にも注意する必要があります。

  • スケーラビリティ
    • 大量のメール配信を行う場合は、DynamoDBのキャパシティ設定やLambdaの同時実行数に注意が必要
  • コスト管理
    • 開封イベントごとにLambdaが起動しDynamoDBへの読み取りが発生するため、大量のメールを送信する場合はコストに注意

個人的に、大量のメールを送信する場合は他ソリューションの検討も視野に入れるべきかなとは思います。特に、不特定多数にメール送信する(toC向けサービスにおけるマーケティングメールなど)場合などは、バウンス管理も重要になってくるので、その点も踏まえて検討すると良いかなと思います。

おわりに

やってみると思いのほか簡単に実装できることがわかりました。開封イベントの取得は、メールマーケティングの効果測定だけでなく、メール配信のモニタリングやメールの配信失敗の原因調査などにも活用できるので、ぜひ活用してみてください。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.