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?

SendGrid delivery audit eyecatch

SendGrid APIが 202 Accepted を返しても、ユーザーのメールボックスに届いたことにはならない。この前提を実装に反映していないシステムは、障害時に「送ったはずなのに届いていない」という最も切り分けにくい状態に陥る。

本記事では、実際にSendGrid試用環境を使って構築したEvent Webhookレシーバーと、DNS/DMARC設定の確認フロー、30分で障害を切り分けるRunbookを記録する。実装コードは tmp/sendgrid-mail-observability/ に再現可能な形で置いてある。


目次

  1. なぜ202が「届いた」ではないのか
  2. インシデント例: パスワードリセットメールが届かない
  3. 全体アーキテクチャ
  4. DNS認証: SPF/DKIM/DMARCの設定と確認
  5. 送信APIの最小実装
  6. Event Webhookレシーバー実装
  7. 署名検証と冪等性処理
  8. イベントトリアージテーブル
  9. 失敗パターン集
  10. 30分インシデントRunbook
  11. 運用後の変化
  12. 参考リンク

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 / categoriesPII(メールアドレス、ユーザーID、氏名)を入れない。SendGridのアクティビティデータとして長期保存され、データ保護規制上の問題になる。
  • x-message-idcorrelation_id と一緒にアプリDB に保存し、後でEvent Webhookのイベントと突合する。

6. Event Webhookレシーバー実装

6-1. SendGrid側の設定

SendGridダッシュボード → Settings → Mail Settings → Event Webhooks で:

  1. HTTP Post URL: https://your-service.example.com/sendgrid/events
  2. Events to be POSTed: 全てチェック(processed, dropped, delivered, deferred, bounce, open, click, spamreport, unsubscribe)
  3. Signed Event Webhook を 有効化(公開鍵が表示される)
  4. 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. 参考リンク

この記事を書いた人✏️@YushiYamamoto
ITPRODX.com代表 / AIアーキテクト
Next.js / TypeScript / n8nを活用した自律型アーキテクチャ設計を専門としています。
日々の自動化の検証結果や、ビジネス側の視点(ROI等)に関するより深い考察は、以下の公式サイトおよびnoteで発信しています。

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?