SendGrid APIが 202 Accepted を返しても、ユーザーのメールボックスに届いたことにはならない。この前提を実装に反映していないシステムは、障害時に「送ったはずなのに届いていない」という最も切り分けにくい状態に陥る。
本記事では、実際にSendGrid試用環境を使って構築したEvent Webhookレシーバーと、DNS/DMARC設定の確認フロー、30分で障害を切り分けるRunbookを記録する。実装コードは tmp/sendgrid-mail-observability/ に再現可能な形で置いてある。
目次
- なぜ202が「届いた」ではないのか
- インシデント例: パスワードリセットメールが届かない
- 全体アーキテクチャ
- DNS認証: SPF/DKIM/DMARCの設定と確認
- 送信APIの最小実装
- Event Webhookレシーバー実装
- 署名検証と冪等性処理
- イベントトリアージテーブル
- 失敗パターン集
- 30分インシデントRunbook
- 運用後の変化
- 参考リンク
1. なぜ202が「届いた」ではないのか
SendGrid Mail Send APIは、メールをSendGridのキューに受け付けたことを 202 Accepted で返す。この時点でSendGridはまだ宛先のメールサーバーに配送を試みていない。
App → [POST /v3/mail/send] → SendGrid API → 202 Accepted
↓
SendGrid MTA
↓
宛先メールサーバーへ配送試行
(delivered / deferred / bounce)
実際の配送結果は非同期で決まる。配送が成功したかどうかを知る唯一の方法は Event Webhook を受信することか、アクティビティフィードをAPIで取得することだ。
202を見て「送信成功」とアプリケーションログに書くと、障害発生時に「アプリは成功しているのに届かない」という混乱が生まれる。正確な表現は「SendGridに受付された」だ。
2. インシデント例: パスワードリセットメールが届かない
[障害報告]
ユーザー: パスワードリセットを3回押したが、メールが来ない
担当者: アプリログを確認 → "mail sent" ログあり → SendGrid API: 202
担当者: どこで詰まっているか不明
この状態で確認できる手段がない場合、次の原因を一つずつ推測して試すことになる。
- メールが迷惑メールに入った?
- DKIM/SPFが通っていない?
- 受信者のメールサーバーが拒否した?
- SendGridのアカウントが制限状態?
- ウェブフックの設定が抜けていて見えていないだけ?
Event Webhookが設定されていれば、どのステップで何が起きたかが5分以内に判明する。設定されていなければ、Activityログを手動で開いて調べることになる。
3. 全体アーキテクチャ
[本番アプリ]
│ POST /v3/mail/send
│ custom_args: { correlation_id: "uuid" }
▼
[SendGrid API]
│ 202 Accepted
│
├─── [MTA] ──────────────────────────────────────────────────────┐
│ │ 宛先メールサーバーへ配送 │
│ └──> processed → delivered / deferred / bounce / dropped │
│ │
└─── [Event Webhook] ─────────────────────────────────────────── │
│ POST /sendgrid/events │
│ (バッチ形式、1リクエストに複数イベント) │
▼ │
[Webhook Receiver] │
│ 1. 署名検証 (ECDSA P-256) │
│ 2. 冪等性チェック (sg_event_id) │
│ 3. イベント正規化・保存 (JSONL / SQLite) │
│ 4. アラートルール評価 │
▼ │
[アラート / Runbook トリガー] │
│
[DMARC Report] ←── (受信者ドメインのDMARCポリシーに基づく集計) ──┘
4. DNS認証: SPF/DKIM/DMARCの設定と確認
4-1. Single Sender VerificationとDomain Authenticationの違い
| 項目 | Single Sender | Domain Authentication |
|---|---|---|
| 設定難易度 | 低(メアドのみ) | 中(DNS CNAME追加) |
| SPF/DKIM | なし(SendGridのデフォルトドメイン) | 自ドメイン経由 |
| DMARC整合 | 不整合になりやすい | 整合可能 |
| 本番推奨 | テスト用のみ | 必須 |
| 到達率 | 低い場合がある | 高い |
本番環境でSingle Sender Verificationのみを使っている場合、SPFは sendgrid.net のIPが通るが、Fromヘッダーのドメインと整合しないため DMARCアライメント失敗 が起きる場合がある。
4-2. Domain Authentication 設定手順
SendGridダッシュボード → Settings → Sender Authentication → Domain Authentication で、使用するドメインを登録する。
登録後に表示される3つのCNAMEをDNSに追加する(実際のドメインを伏せた形で記録):
; SendGrid Domain Authentication (redacted domain: mail.example.com)
em1234.mail.example.com. CNAME u12345678.wl012.sendgrid.net.
s1._domainkey.mail.example.com. CNAME s1.domainkey.u12345678.wl012.sendgrid.net.
s2._domainkey.mail.example.com. CNAME s2.domainkey.u12345678.wl012.sendgrid.net.
DNS反映後、SendGridダッシュボードで「Verify」を実行し、全てのレコードが ✓ Verified になることを確認する。
4-3. DMARCレコードの設定
; _dmarc.example.com
_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com; ruf=mailto:dmarc-forensic@example.com; pct=100"
段階的なポリシー移行が重要:
| フェーズ | ポリシー | 意味 |
|---|---|---|
| 監視期 (最低2週間) | p=none |
違反レポートを受け取るだけで配信は止めない |
| 強化期 | p=quarantine |
違反メールを迷惑メールフォルダへ |
| 完全施行 | p=reject |
違反メールを拒否 |
p=reject を即座に設定すると、DMARC整合が取れていない正規の送信経路(社内通知システム、SaaSサービスからの代理送信など)が全て拒否される。
4-4. 受信後のヘッダー確認
Gmailで「メッセージのソースを表示」し、以下を確認:
Authentication-Results: mx.google.com;
dkim=pass header.i=@mail.example.com header.s=s1 header.b=XXXXXXXXXXXX;
spf=pass (google.com: domain of bounces+XXXXXXXX@mail.example.com designates XX.XX.XX.XX as permitted sender) smtp.mailfrom=bounces+XXXXXXXX@mail.example.com;
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=example.com
dmarc=pass が確認できれば認証は正常。
4-5. DNSチェックリスト
- Domain Authentication が全レコード Verified
-
mail-tester.comなどでSPFスコア確認 -
Gmailで受信後、「メッセージのソースを表示」で
dkim=pass,spf=pass,dmarc=passを確認 -
DMARCポリシーを
p=noneで2週間以上監視してからquarantineに移行 - RUAレポートの受信先メールアドレスが有効か確認
5. 送信APIの最小実装
// src/send.ts
import sgMail from "@sendgrid/mail";
const SENDGRID_API_KEY = process.env.SENDGRID_API_KEY;
if (!SENDGRID_API_KEY) throw new Error("SENDGRID_API_KEY is required");
sgMail.setApiKey(SENDGRID_API_KEY);
interface SendMailOptions {
to: string;
subject: string;
htmlContent: string;
correlationId: string; // PII不使用。内部トレースIDのみ
}
export async function sendTransactionalMail(opts: SendMailOptions): Promise<string> {
const [response] = await sgMail.send({
from: {
email: process.env.SENDGRID_FROM_EMAIL!,
name: process.env.SENDGRID_FROM_NAME ?? "No Reply",
},
to: opts.to,
subject: opts.subject,
html: opts.htmlContent,
customArgs: {
// PII禁止。UUIDや内部IDのみ。
correlation_id: opts.correlationId,
},
trackingSettings: {
clickTracking: { enable: true, enableText: false },
openTracking: { enable: true },
},
});
// 202 = SendGridキューに受け付けられた。配送完了ではない。
if (response.statusCode !== 202) {
throw new Error(`Unexpected status: ${response.statusCode}`);
}
const messageId = response.headers["x-message-id"] as string;
// ここでは「受付ID」を返す。「送信成功」ではない。
return messageId;
}
重要な制約:
-
customArgs/categoriesに PII(メールアドレス、ユーザーID、氏名)を入れない。SendGridのアクティビティデータとして長期保存され、データ保護規制上の問題になる。 -
x-message-idはcorrelation_idと一緒にアプリDB に保存し、後でEvent Webhookのイベントと突合する。
6. Event Webhookレシーバー実装
6-1. SendGrid側の設定
SendGridダッシュボード → Settings → Mail Settings → Event Webhooks で:
- HTTP Post URL:
https://your-service.example.com/sendgrid/events - Events to be POSTed: 全てチェック(processed, dropped, delivered, deferred, bounce, open, click, spamreport, unsubscribe)
- Signed Event Webhook を 有効化(公開鍵が表示される)
- Test your integration でテスト送信して動作確認
6-2. Webhookレシーバー (Fastify)
// src/server.ts
import Fastify from "fastify";
import { verifySignature } from "./verify-signature.js";
import { storeEvents } from "./store.js";
import { evaluateRules } from "./rules.js";
const app = Fastify({ logger: true });
// 署名検証のためにrawBodyが必要
app.addContentTypeParser(
"application/json",
{ parseAs: "buffer" },
(_req, body, done) => {
done(null, body);
}
);
app.post<{ Body: Buffer }>("/sendgrid/events", async (req, reply) => {
// 1. 署名検証
const signature = req.headers["x-twilio-email-event-webhook-signature"] as string;
const timestamp = req.headers["x-twilio-email-event-webhook-timestamp"] as string;
const rawBody = req.body;
if (!signature || !timestamp) {
req.log.warn("missing signature headers");
return reply.status(403).send({ error: "missing signature" });
}
const isValid = await verifySignature({ signature, timestamp, rawBody });
if (!isValid) {
req.log.error("invalid webhook signature");
return reply.status(403).send({ error: "invalid signature" });
}
// 2. SendGridは2xxを期待する。処理はnon-blockingで行う
reply.status(200).send({ ok: true });
// 3. 非同期でイベント処理
const events = JSON.parse(rawBody.toString("utf-8")) as SendGridEvent[];
await storeEvents(events);
await evaluateRules(events);
});
export { app };
6-3. 設計上のポイント
| ポイント | 理由 |
|---|---|
| rawBodyを保存してから署名検証 | Content-Type parserがBodyを変換すると署名が一致しない |
| 即座に200を返す | SendGridは2xx以外が返ると再送する。処理は非同期で |
| バッチ配列として処理 | 1リクエストに複数イベントが含まれる |
| ログに宛先メールを書かない | PII保護。correlation_idのみ記録 |
7. 署名検証と冪等性処理
7-1. Signed Event Webhook検証
SendGridはECDSA P-256を使って署名する。
// src/verify-signature.ts
import { createVerify } from "crypto";
const SENDGRID_PUBLIC_KEY = process.env.SENDGRID_WEBHOOK_PUBLIC_KEY;
interface VerifyOptions {
signature: string; // X-Twilio-Email-Event-Webhook-Signature (base64)
timestamp: string; // X-Twilio-Email-Event-Webhook-Timestamp (Unix timestamp)
rawBody: Buffer;
}
export async function verifySignature(opts: VerifyOptions): Promise<boolean> {
if (!SENDGRID_PUBLIC_KEY) {
throw new Error("SENDGRID_WEBHOOK_PUBLIC_KEY is not set");
}
try {
const payload = Buffer.concat([
Buffer.from(opts.timestamp, "utf-8"),
opts.rawBody,
]);
const verify = createVerify("SHA256");
verify.update(payload);
const publicKeyPem = formatPublicKey(SENDGRID_PUBLIC_KEY);
return verify.verify(
{ key: publicKeyPem, dsaEncoding: "ieee-p1363" },
Buffer.from(opts.signature, "base64")
);
} catch (err) {
return false;
}
}
function formatPublicKey(rawKey: string): string {
// SendGridが返す公開鍵はPEMヘッダーなしの場合がある
if (rawKey.startsWith("-----BEGIN")) return rawKey;
return `-----BEGIN PUBLIC KEY-----\n${rawKey}\n-----END PUBLIC KEY-----`;
}
7-2. 冪等性処理
SendGridはネットワーク障害やタイムアウト時にWebhookを再送する。sg_event_id が重複する場合がある。
// src/store.ts
import { appendFileSync, existsSync, readFileSync } from "fs";
const EVENTS_FILE = process.env.EVENTS_FILE ?? "data/events.jsonl";
const seenEventIds = new Set<string>();
// 起動時に既存ファイルから既出IDを読み込む
if (existsSync(EVENTS_FILE)) {
for (const line of readFileSync(EVENTS_FILE, "utf-8").split("\n")) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as SendGridEvent;
if (event.sg_event_id) seenEventIds.add(event.sg_event_id);
} catch {
// 破損行はスキップ
}
}
}
export async function storeEvents(events: SendGridEvent[]): Promise<void> {
const fresh = events.filter((e) => {
if (!e.sg_event_id) return true; // IDなしは通す
if (seenEventIds.has(e.sg_event_id)) return false; // 重複スキップ
seenEventIds.add(e.sg_event_id);
return true;
});
for (const event of fresh) {
// PII除去: email フィールドをハッシュ化して保存
const sanitized = sanitizeEvent(event);
appendFileSync(EVENTS_FILE, JSON.stringify(sanitized) + "\n", "utf-8");
}
}
function sanitizeEvent(event: SendGridEvent): Record<string, unknown> {
const { email, ...rest } = event as Record<string, unknown>;
return {
...rest,
email_hash: email
? require("crypto").createHash("sha256").update(String(email)).digest("hex").slice(0, 16)
: undefined,
};
}
8. イベントトリアージテーブル
Event Webhookが受信するイベントタイプと、それぞれの意味・最初に行うアクションを整理する。
| イベント | 意味 | 最初のアクション |
|---|---|---|
processed |
SendGridがメールを受理し、配送キューに追加した | 次の delivered または deferred/bounce を待つ |
delivered |
宛先メールサーバーが受理した | ユーザーが受信できていない場合は受信者側の問題(迷惑メール、フィルター) |
deferred |
宛先サーバーが一時拒否。SendGridが再試行する | SendGridの再試行ウィンドウ内は待機。長引く場合は受信者ドメインのレピュテーション確認 |
bounce (hard) |
宛先サーバーが恒久拒否。アドレス無効か存在しない | アドレスをサプレッションリストへ。絶対に再送しない |
bounce (soft) |
一時的な拒否。SendGridが再試行する |
deferred と同様に待機 |
dropped |
SendGridが送信しなかった | サプレッションリスト・スパム報告・不正なヘッダーを確認 |
open |
受信者がメールを開封(画像読み込みで検出) | プロキシ・メールクライアントのプリフェッチで誤検知がある。正確な指標ではない |
click |
受信者がリンクをクリック |
open より信頼度が高いが、セキュリティスキャナーが踏む場合もある |
spamreport |
受信者がスパム報告した | 即座にサプレッションリストへ追加。コンテンツとオプトイン状況を見直す |
unsubscribe |
SendGridのグローバル配信停止が発動した | 以後の送信を止める。サプレッションリストで管理 |
group_unsubscribe |
購読グループ単位の配信停止 | 対象グループのみ停止 |
processed のみが記録されている場合
processed 後に delivered, deferred, bounce, dropped のいずれも来ない場合、Event Webhookの設定不備か、送信が発生していない可能性がある。
# SendGrid Activity Feed APIで直接確認 (最新100件)
curl -s "https://api.sendgrid.com/v3/messages?limit=10&query=msg_id%3D%22MESSAGE_ID%22" \
-H "Authorization: Bearer $SENDGRID_API_KEY" | jq '.messages[] | {status, events}'
9. 失敗パターン集
パターン1: API 202を「配送完了」と誤認する
症状: アプリログに「mail sent」があるが届かない
原因: 202はキュー受付の確認。配送成功は delivered イベントを受信して初めて確認できる
対策: 送信ログを「accepted_by_sendgrid」に変更。Event Webhookで delivered を受信後に「delivered confirmed」を記録する
パターン2: DMARCアライメント失敗
症状: 一部の受信者(特にGmail, Outlook)でメールが届かない
原因: SendGridのDomain Authenticationが未設定で、SPF/DKIMのアライメントがFromヘッダーのドメインと一致しない
確認:
# Gmail受信後にヘッダー確認
dmarc=fail (p=REJECT ...) header.from=example.com
対策: Domain Authenticationを設定してDKIM/SPFをFromドメインに紐付ける
パターン3: Webhookをテスト後に設定変更して署名キーが変わった
症状: Webhook受信が突然403になり始める
原因: SendGridダッシュボードでWebhookを再設定すると公開鍵が再発行される
対策: 公開鍵をenv varで管理。ダッシュボード変更後に SENDGRID_WEBHOOK_PUBLIC_KEY を更新する
パターン4: 重複イベントを二重カウント
症状: 配信数の集計がおかしい
原因: SendGridはWebhook受信の確認が取れない場合に再送する。sg_event_id が重複する
対策: sg_event_id でupsert/deduplication処理を実装する(上記 store.ts 参照)
パターン5: PIIをcustomArgsに入れた
症状: 法務から「SendGridのログにメールアドレスが入っている」と指摘される
原因: customArgs に { user_email: "..." } などを入れると、SendGridのアクティビティデータとして保存される
対策: customArgs には内部UUID・相関IDのみ。PIIは入れない
パターン6: open追跡を「既読確認」として使う
症状: 「メールを開封した」という根拠として使ったが実態と乖離
原因: Apple Mail Privacy Protection、メールプロキシ、セキュリティスキャナーがプリフェッチで開封を記録する
対策: 開封は参考指標のみ。「実際にアクションした」の根拠には click を使う
パターン7: Event Webhookのタイムアウトでキューが詰まる
症状: Webhookが遅延し始め、イベントが大量に溜まる
原因: レシーバーの処理に時間がかかり、SendGridが再送を繰り返す
対策: レシーバーはbody受信後即座に200を返し、処理をキューに積む。処理時間をレシーバーのクリティカルパスに入れない
10. 30分インシデントRunbook
[ユーザー報告: メールが届かない]
Step 1 (0〜5分): 基本確認
├── アプリログを確認: SendGrid APIへのリクエストは発生したか?
│ YES → Step 2
│ NO → アプリ側の送信トリガーを確認。SendGridは無関係
│
├── SendGrid Activity Feed でメッセージを検索
│ URL: https://app.sendgrid.com/email_activity
│ 検索: メールアドレス または correlation_id
│
└── Activity Feedで該当メッセージのステータスを確認:
processed のみ → Step 3
delivered → Step 4
deferred → Step 5
bounce → Step 6
dropped → Step 7
---
Step 2 (5〜10分): Event Webhookのイベントを確認
├── Webhookレシーバーのログを確認
│ イベントなし → Webhookが設定されているか確認
│ (Settings → Mail Settings → Event Webhooks)
│ イベントあり → 最新のイベントタイプを確認 → 上記フロー
Step 3: processed のみ
├── SendGridがMTAキューに積んだが、宛先サーバーに未配信
├── 通常は数分以内に delivered または deferred になる
├── 10分以上 processed のままなら → deferred の可能性
└── SendGrid Status Page (status.sendgrid.com) で障害確認
Step 4: delivered
├── 宛先メールサーバーは受け取っている
├── ユーザーの受信ボックスではなくサーバー側の問題
│
├── 確認: 迷惑メールフォルダ
├── 確認: ヘッダーに dmarc=pass があるか
│ dmarc=fail なら → Domain Authentication と DMARCポリシーを見直す
├── 確認: X-Spam-Score が高くないか (コンテンツの問題)
└── 確認: 受信者のメールプロバイダーのブロックリスト
Step 5: deferred
├── 受信者サーバーが一時拒否(busy, greylisting, etc.)
├── SendGridが自動で再試行する (最大72時間)
├── 待機するか、緊急の場合は別経路での連絡を検討
└── 大量deferredなら → 送信ドメインのレピュテーション低下の可能性
Step 6: bounce (hard)
├── アドレスが無効か存在しない
├── サプレッションリストに追加されているはず
├── 該当ユーザーのメールアドレスが正しいか確認
├── 入力フォームでメールアドレスの確認送信を実施しているか確認
└── 絶対に同一アドレスに再送しない
Step 7: dropped
├── SendGridが送信しなかった
├── 確認: Suppression Lists (Bounces, Unsubscribes, Spam Reports, Blocks, Invalid Emails)
│ → 該当アドレスがリスト入りしていれば送信はskipされる
├── 確認: メールヘッダーの不正な文字列
└── 確認: From/To/Subject がUTF-8正常か
---
Step 8 (25〜30分): エスカレーション判断
├── 上記全てで原因不明 → SendGridサポートに sg_message_id を渡してエスカレーション
├── 同一ドメインに大量bounce → ドメインブロックされている可能性
└── 全ユーザーで一括障害 → SendGrid Statusページで障害確認 + 代替通知手段を起動
11. 運用後の変化
Event Webhookとトリアージテーブルを実装した後、以下の変化が確認できる。
障害切り分け時間:
- 以前: 「メールが届かない」報告から原因特定まで30〜60分(手動でActivity Feedを確認)
- 以後: 5分以内にイベントログから原因を特定できる
サプレッションリストの管理:
- hard bounceが発生した時点でアプリDBにも記録し、無効アドレスへの再送を防ぐ
DMARC移行:
-
p=noneで2週間監視 → レポートにalignment failureが0件になってからp=quarantineに移行 -
p=rejectは迷惑メール送信者への耐性が最大になるが、全送信経路をDomain Authenticationで整備してから
open追跡の扱い変更:
- ダッシュボードのopen率はApple MPP影響で水増しされているため、クリック率を主指標に変更
12. 参考リンク
- Qiita Tech Festa 2026: メールにまつわる実装や運用で得られたノウハウ・苦労話をシェアしよう!
- SendGrid Docs: Event Webhook
- SendGrid Docs: Signed Event Webhook
- SendGrid Docs: Domain Authentication
- SendGrid Docs: Suppressions
- RFC 7489: DMARC
- Google: Email sender guidelines
- 再現用コード: tmp/sendgrid-mail-observability/
この記事を書いた人✏️@YushiYamamoto
ITPRODX.com代表 / AIアーキテクト
Next.js / TypeScript / n8nを活用した自律型アーキテクチャ設計を専門としています。
日々の自動化の検証結果や、ビジネス側の視点(ROI等)に関するより深い考察は、以下の公式サイトおよびnoteで発信しています。
