0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS Lambda でトラッキングピクセルを自作してメール開封検知の仕組みを理解する

0
Posted at

概要

SES の Open Tracking は開封検知をブラックボックスで処理します。「なぜ検知できるのか」「なぜ数字がブレるのか」は SES を使うだけでは見えてきません。

本記事では Lambda だけで同じ仕組みをゼロから再現し、内部動作レベルで理解することを目的とします。


仕組み

トラッキングピクセルが HTTP リクエストになるまで

HTML メールに <img> タグを記述すると、メールクライアントは描画時にその URL へ HTTP GET リクエストを送ります。

<img
  src="https://xxxxx.lambda-url.ap-northeast-1.on.aws/?mail_id=msg-001&user_id=usr-001"
  width="1" height="1" style="display:block;"
>

1×1 ピクセルなので受信者には見えませんが、この HTTP リクエストが Lambda に届きます。「HTTP リクエストが届いた事実」をログに記録することが開封検知の本質です。

重要:
検知しているのは「<img> タグの URL へのアクセス」であり「ユーザーが読んだかどうか」ではありません。

Lambda Function URL の内部動作

Lambda Function URL は HTTP リクエストを API Gateway ペイロード v2.0 形式のイベントに変換して関数を呼び出します。

// 
{
  "rawQueryString": "mail_id=msg-001&user_id=usr-001",
  "headers": { "user-agent": "GoogleImageProxy" },
  "queryStringParameters": { "mail_id": "msg-001", "user_id": "usr-001" },
  "requestContext": {
    "http": { "method": "GET", "sourceIp": "xx.xxx.xx.xx", "userAgent": "GoogleImageProxy" }
  }
}

実装上の注意点:

設定 理由
isBase64Encoded: True 1×1 GIF はバイナリ。省略すると Base64 文字列がそのまま返り、メールクライアントが「壊れた画像」と判定して画像読み込みイベントが発生しないケースがある
Cache-Control: no-store クライアントがキャッシュすると次回以降の開封で Lambda へのリクエストが発生しない。ただし Gmail プロキシには効かない場合がある

ヘッダーはすべて小文字に正規化されるため、User-Agent ではなく user-agent でアクセスします。


アクセスパターンと検知の限界

Lambda に届くアクセスは「ユーザーが開いた」だけではありません。

クライアント別アクセスパターン

パターン Lambda へのアクセス 実態
ユーザーが実際にメールを開いた あり ✅ 正しい開封
Gmail の GoogleImageProxy が取得した あり ⚠️ IP はユーザーのものではない
メールセキュリティ製品が事前スキャンした あり ❌ ユーザー未開封でも発生
画像の自動読み込みがオフ なし ❌ 検知不可
プロキシがキャッシュから返した なし ❌ 2回目以降は記録されない場合がある

「開封率 35%」の正確な意味は「画像 URL へのアクセスが発生した割合(プライバシー保護機能の影響を含む)」です。


使いどころと注意点

どんな場面で使うか

用途 概要
メールマーケティングの開封率計測 キャンペーンメールが読まれているかを把握し、件名や送信時刻の A/B テストに使う
トランザクションメールの到達確認 注文確認・パスワードリセットなどの重要メールが開封されたかを確認する
営業メールの開封タイミング把握 見込み顧客がメールを開いたタイミングでフォローアップのトリガーにする

使う前に知っておくべきこと

数字は参考値として扱う

開封率は「画像 URL へのアクセスが発生した割合」であり、「ユーザーが読んだ割合」ではありません。Gmail の GoogleImageProxy や Apple Mail Privacy Protection(MPP)の影響を受けるため、実態とズレが生じます。絶対値での比較より、同条件での相対比較(A/B テスト等)に使うのが適切です。

プライバシーへの配慮が必要

トラッキングピクセルはメール受信者に対して暗黙的に行動を記録します。GDPR・個人情報保護法の観点から、プライバシーポリシーへの記載と、必要に応じた同意取得が求められます。BtoC のマーケティングメールでは特に注意が必要です。

本番運用では Auth NONE は使わない

検証段階では問題ありませんが、本番では認証付きの設計が必要です。詳細は本番化のポイントを参照してください。


実装(検証構成)

サンプルコード一式は GitHub で公開しています:
aws-email-open-tracking-poc

AWS検証構成

AWS リソース

リソース 用途
Lambda トラッキングピクセルのリクエスト受信・ログ出力
Lambda Function URL 外部公開の HTTP エンドポイント(Auth: NONE)
CloudWatch Logs 開封イベントの記録・Logs Insights による集計
IAM Role Lambda 実行ロール(基本ポリシーのみ)

Lambda 実装

import base64, json, logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

PIXEL_GIF = base64.b64decode(
    "R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="
)

def lambda_handler(event, context):
    headers = event.get("headers", {})
    query = event.get("queryStringParameters") or {}

    logger.info(json.dumps({
        "event_type": "mail_open",
        "query": query,
        "user_agent": headers.get("user-agent"),
        "source_ip": event.get("requestContext", {}).get("http", {}).get("sourceIp")
    }, ensure_ascii=False))

    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": "image/gif",
            "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
        },
        "body": base64.b64encode(PIXEL_GIF).decode("utf-8"),
        "isBase64Encoded": True
    }

Terraform 構成

.
├── terraform/
│   ├── main.tf              # AWS プロバイダー設定
│   ├── variables.tf         # 変数定義(リージョン、関数名、ログ保持日数)
│   ├── outputs.tf           # Function URL 等の出力
│   ├── lambda.tf            # Lambda + Function URL + Lambda Permission + CloudWatch Log Group
│   ├── iam.tf               # IAM 実行ロール
│   └── src/
│       └── lambda_function.py
└── scripts/
    └── send_test_email.py   # SMTP 経由でトラッキングピクセル入りメールを送信

記事の内容と直結するリソースを抜粋します。

# Function URL を Auth: NONE で公開(検証用)
resource "aws_lambda_function_url" "tracker" {
  function_name      = aws_lambda_function.tracker.function_name
  authorization_type = "NONE"
}

# ログ保持期間を明示(デフォルトは無期限 → コスト注意)
resource "aws_cloudwatch_log_group" "tracker" {
  name              = "/aws/lambda/${aws_lambda_function.tracker.function_name}"
  retention_in_days = 7
}

検証手順

  1. terraform init && terraform apply で Lambda を作成する
  2. terraform output lambda_function_url で Function URL を取得する
  3. HTML メールの <img src="..."> に Function URL を埋め込んで送信する(Content-Type: text/html
    • GitHubリポジトリ内に検証用メール送信スクリプト(send_test_email.py)を用意しています。
  4. 受信側でメールを開封する
  5. CloudWatch Logs でログが出力されているか確認する:

CloudWatch Logs に出力されたトラッキングログ

Logs Insights でフィールドを絞って確認する場合:

fields @timestamp, query.mail_id, query.user_id, user_agent, source_ip
| sort @timestamp desc
| limit 20

切り分け

Lambda が呼ばれない(Invocations = 0)

Throttles = 0 の場合はリクエスト自体が届いていません。以下を確認します。

確認項目 確認場所
Function URL が有効か Lambda > 設定 > 関数 URL
Auth type が NONE か Function URL の認証タイプ
メール本文の img src URL が正しいか 送信済みメールのソース
画像の自動読み込みが有効か メールクライアントの設定

Throttles > 0 の場合はリクエストは届いているが同時実行上限で弾かれています。Invocations だけ見ても判断できません。

ログは出るがユーザーを識別できない

fields @timestamp, query.mail_id, query.user_id, user_agent, source_ip
| sort @timestamp desc
| limit 50
症状 原因
query.mail_id が空 img src URL に mail_id パラメータがない
source_ip が Google IP 帯のみ Gmail プロキシ経由のため実ユーザー IP は取れない
user_agentGoogleImageProxy のみ 実ユーザーからのリクエストが来ておらず MPP・Gmail プロキシのみアクセス

メトリクスと Logs Insights

確認する 4 つのメトリクス(AWS/Lambda ネームスペース)

メトリクスの状態 判断と次のアクション
Invocations = 0 かつ Throttles = 0 リクエスト未到達 → Function URL・メール本文・画像ブロック設定を確認
Invocations = 0 かつ Throttles > 0 全件スロットリング → 予約済み同時実行を確認。一斉配信中なら送信レートを下げる
Errors > 0 関数エラー → Logs でスタックトレースを確認(IAM 権限不足・タイムアウト等)
Duration が高い 処理遅延 → タイムアウト設定を確認(最大 900 秒)

Logs Insights クエリ集

-- User-Agent の内訳(どのクライアント・プロキシからのアクセスか)
fields user_agent
| stats count(*) as access_count by user_agent
| sort access_count desc
-- mail_id ごとの呼び出し回数
fields query.mail_id
| stats count(*) as invocation_count by query.mail_id
| sort invocation_count desc

mail_iduser_id"query": {"mail_id": "..."} のネスト構造で出力されます。Logs Insights ではドット記法 query.mail_id で参照します。


本番化のポイント

AWS本番構成

イベント保存と集計

CloudWatch Logs だけでは検索・集計が限定的です。

ステップ 対応 用途
1 DynamoDB に開封イベントを保存 重複排除・リアルタイム参照
2 S3 + Athena で集計 日次・週次レポート
3 QuickSight でダッシュボード化 マーケティング可視化

セキュリティ:opaque tracking ID パターン

Auth NONE の Function URL にクエリパラメータで生 ID を渡すと、URL 漏洩時に偽造リクエストのリスクがあります。

# 変更前(生 ID が露出)
?mail_id=msg-001&user_id=usr-001

# 変更後(opaque ID + .png 拡張子)
/open/a3f8c2d1e4b5009f.png

tracking_id は UUID または HMAC トークンにし、DynamoDB で mail_iduser_id に逆引きします。.png 拡張子はスパムフィルターへの誤検知防止になります。

個人情報保護の観点から、user_id にはメールアドレスを直接使わず内部識別子(UUID 等)を使用してください。


SES Open Tracking との比較

項目 Lambda Function URL 方式 SES Open Tracking
学習効果 高(内部動作を完全に把握できる) 中(抽象化されており仕組みが見えにくい)
イベント保存 自前(DynamoDB・S3 等) Kinesis Data Firehose / SNS 経由
カスタマイズ性 高(ログ構造・イベント種別を自由に定義) 低(SES 定義のスキーマに従う)
本番利用 △(認証・保存層の追加実装が必要) ○(AWS が管理する安定した仕組み)

本番で大規模運用するなら SES Open Tracking または専用 ESP(Mailgun / SendGrid 等)が現実的です。Lambda 方式は「仕組みを理解する」「小規模な PoC を素早く作る」用途に適しています。


まとめ

Lambda Function URL をトラッキングピクセルのエンドポイントとすることで、最小構成でメール開封イベントを取得できます。

ポイント 内容
検知の本質 「画像取得」の記録であり「開封」の確認ではない
必須設定 isBase64Encoded: TrueCache-Control: no-store
メトリクスの読み方 Invocations = 0 だけ見ず Throttles を必ず合わせて確認
Logs Insights query.mail_id はドット記法で参照
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?