Amazon SESのSendBulkTemplatedEmailで一括送信の開封イベントを追跡してみた
はじめに
下記の記事では、Amazon SESのSendTemplatedEmailを使って個別メール送信時の開封イベントを追跡するシステムを構築しました。
しかし、実際には数百から数千件の一括送信が必要になることがよくあります。例えば、マーケティングメールの送信だったり、重要なお知らせの送信だったり、といったケースです。そこで今回は、SendBulkTemplatedEmailを使った一括送信時の開封イベントトラッキングシステムを構築してみました。
SendBulkTemplatedEmailについて
SendBulkTemplatedEmailは1回のAPIリクエストで複数の宛先にメールを送信できます。留意点などは以下の記事もご参照ください。
このAPIですが、メッセージIDと送信先メールアドレスの紐付けができないことに注意が必要です。というのも、SendBulkTemplatedEmailのレスポンスは以下のようになっているからです。
aws ses send-bulk-templated-email \
--source "noreply@example.com" \
--template "TestTemplate" \
--default-template-data '{}' \
--destinations '[
{
"Destination": {"ToAddresses": ["user1@example.com"]}
},
{
"Destination": {"ToAddresses": ["user2@example.com"]}
}
]' \
--configuration-set-name "test-configuration-set"
{
"Status": [
{
"Status": "Success",
"MessageId": "TEST01970ee77755-b7f884ec-92d3-4452-b973-ac048bd3423e-000000"
},
{
"Status": "Success",
"MessageId": "TEST01970ee77757-c0f22f6b-98ad-43f0-a59a-e6a49d5cbd6e-000000"
}
]
}
このように、これではどちらのメッセージIDがどちらの送信先に対して送信したものなのかがわかりません。そこで、SendBulkTemplatedEmailの引数であるReplacementTagsにユーザ識別子を組み込むことにしました。
今回構築するシステム
今回は以下のようなシステムを構築します。
- Amazon SESのSendBulkTemplatedEmailを使って一括メール送信
- ユーザ情報をAmazon DynamoDBに保存
- 開封イベントをAmazon EventBridgeで受け取り
- AWS Lambdaで処理して送信先メールアドレスをキーにしてDynamoDBを更新
EventBridgeへのイベント送信設定については、以下の記事を参考にしてください。ここでは、設定セットとイベント送信の設定は完了しているものとして説明を進めます。
1. メールテンプレートを作成
事前準備として、メールテンプレートを作成しておきます。メールテンプレート名は重複しないようにUUID形式にします。
{
"Template": {
"TemplateName": "00000000-0000-0000-0000-000000000000",
"SubjectPart": "2025年6月のキャンペーンについて",
"TextPart": "{{userName}} さん、こんにちは!\nキャンペーンのお知らせです!",
"HtmlPart": "<html><body><h1>{{userName}} さん、こんにちは!</h1><p>キャンペーンのお知らせです!</p></body></html>"
}
}
上記をtemplate.jsonとして保存し、AWS CLIを使ってテンプレートを登録します。
aws ses create-template --cli-input-json file://template.json
2. DynamoDBのテーブル作成
続いてDynamoDBのテーブルを作成します。パーティションキーをユーザID(UUIDなど)に、ソートキーをメールテンプレート名にします。
aws dynamodb create-table \
--table-name BulkEmailTracking \
--attribute-definitions AttributeName=UserId,AttributeType=S AttributeName=TemplateName,AttributeType=S \
--key-schema AttributeName=UserId,KeyType=HASH AttributeName=TemplateName,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--table-class STANDARD
テーブルが作成できたらメール送信対象リストをDynamoDBに登録しておきます。2025年5月21日に送信予定、というシナリオを想定したデータです。
aws dynamodb put-item \
--table-name BulkEmailTracking \
--item '{"UserId": {"S": "11111111-1111-1111-1111-111111111111"}, "TemplateName": {"S": "00000000-0000-0000-0000-000000000000"}, "Email": {"S": "user1@example.com"}, "UserName": {"S": "山田太郎"}, "OpenStatus": {"BOOL": false}, "SentAt": {"S": "2025-05-21"}}'
aws dynamodb put-item \
--table-name BulkEmailTracking \
--item '{"UserId": {"S": "22222222-2222-2222-2222-222222222222"}, "TemplateName": {"S": "00000000-0000-0000-0000-000000000000"}, "Email": {"S": "user2@example.com"}, "UserName": {"S": "佐藤花子"}, "OpenStatus": {"BOOL": false}, "SentAt": {"S": "2025-05-21"}}'
3. 一括メール送信の実行
実際にはアプリケーション側でメール送信対象リストを取得して、SendBulkTemplatedEmailを使って一括送信、というようなフローになると思います。が、今回はAWS CLIを使って一括送信を実行します。
ReplacementTagsには、ユーザIDとメールテンプレート名を指定します。開封イベントの処理ではこの情報を使ってDynamoDBのアイテムを更新することになります。
aws ses send-bulk-templated-email \
--source "noreply@example.com" \
--template "00000000-0000-0000-0000-000000000000" \
--destinations '[
{
"Destination": {"ToAddresses": ["user1@example.com"]},
"ReplacementTags": "{\"userId\": \"11111111-1111-1111-1111-111111111111\", \"templateName\": \"00000000-0000-0000-0000-000000000000\"}",
"ReplacementTemplateData": "{\"userName\": \"山田太郎\"}"
},
{
"Destination": {"ToAddresses": ["user2@example.com"]},
"ReplacementTags": "{\"userId\": \"22222222-2222-2222-2222-222222222222\", \"templateName\": \"00000000-0000-0000-0000-000000000000\"}",
"ReplacementTemplateData": "{\"userName\": \"佐藤花子\"}"
}
]' \
--configuration-set-name "test-configuration-set"
4. 開封イベントの受信と処理
メールが開封されると、EventBridgeに開封イベントが送信されます。前回のイベントとの違いは、tags
にReplacementTagsで指定したuserId
とtemplateName
が含まれていることです。
{
"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": [
"user1@example.com"
],
"tags": {
"ses:source-tls-version": [
"TLSv1.3"
],
"ses:operation": [
"SendBulkTemplatedEmail"
],
"ses:configuration-set": [
"test-configuration-set"
],
"ses:source-ip": [
"xxx.xxx.xxx.xxx"
],
"ses:from-domain": [
"example.com"
],
"ses:caller-identity": [
"demo_user"
],
"userId": [
"11111111-1111-1111-1111-111111111111"
],
"templateName": [
"00000000-0000-0000-0000-000000000000"
]
}
},
"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 3.13になります。
import json
import boto3
import datetime
import os
# 設定
TABLE_NAME = os.environ.get("TABLE_NAME", "BulkEmailTracking")
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"]
tags = detail["mail"]["tags"]
# ユーザIDを取得
user_id_list = tags.get("userId", [])
if not user_id_list:
return
user_id = user_id_list[0]
# メールテンプレート名を取得
template_name_list = tags.get("templateName", [])
if not template_name_list:
return
template_name = template_name_list[0]
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={"UserId": user_id, "TemplateName": template_name})
record = response.get("Item")
# 開封状態のチェック
if record and record.get("OpenStatus", False):
return
if not record:
return
# 開封情報の更新
table.update_item(
Key={"UserId": user_id, "TemplateName": template_name},
UpdateExpression="SET OpenStatus = :status, OpenedAt = :time, OpenInfo = :info",
ExpressionAttributeValues={
":status": True,
":time": open_timestamp,
":info": {"userAgent": user_agent, "ipAddress": ip_address},
},
)
return
ロギングやエラーハンドリングは省略していますが、実運用では適切に実装してください。
おわりに
SendBulkTemplatedEmailを使った一括送信の開封追跡は、個別送信とは異なる工夫が必要でしたが、ReplacementTagsを活用することで実現できました。若干トリッキーな実装ですが、一括送信の開封追跡が必要な場合はぜひ試してみてください。