mcpField
T
twilio-email

twilio-email

github.com/alphaonedev開発43 回インストール
リポジトリ

twilio-email について

twilio-email は、SendGrid を使ったトランザクションメールシステムを構築・運用するエンジニア向けの OpenClaw スキルです。送信、テンプレート、イベント Webhook、抑制リスト、ドメイン認証など、本番運用に関わる項目を扱います。

主な機能

  1. 01SendGrid v3 API によるメール送信
  2. 02ダイナミックテンプレートの安全な利用と管理
  3. 03イベント Webhook の署名検証と重複排除
  4. 04バウンスや配信停止の抑制リスト管理
  5. 05SPF、DKIM、DMARC、IP ウォーミングの設定

活用シーン

  1. 01パスワードリセットや領収書などのメール送信
  2. 02配信・バウンスイベントの処理と検証
  3. 03配信停止や苦情の状態を送信に反映
  4. 04送信ドメインのメール認証設定

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/alphaonedev/openclaw-graph --skill twilio-email

インストール後、Claude Code で/twilio-emailを入力して読み込まれたか確認します。

詳細

twilio-email

目的

OpenClaw が SendGrid(Twilio)のトランザクションメールを本番環境で実装、運用、トラブルシューティングできるようにします。

  • SendGrid v3 API を使って、適切な認証、再試行、冪等性、可観測性を備えたトランザクションメールを送信します。
  • Dynamic Templates(Handlebars)を安全に使用し(エスケープ、条件分岐、ループ)、テンプレートをバージョン管理して変更を段階的に展開します。
  • SendGrid Event Webhook(delivered/open/click/bounce/spamreport/dropped/deferred)を、署名検証とリプレイ保護を行って処理します。
  • 抑制リスト(バウンス、ブロック、スパム報告、配信停止)を管理し、list-unsubscribe を正しく実装します。
  • ドメイン認証(SPF/DKIM/DMARC)、専用 IP、IP ウォームアップ、バウンス/スパム処理を設定します。
  • Twilio のクラスター設計パターン(レート制限、Webhook の再試行ロジック、エラー分類、コスト/スループットのトレードオフ)と統合します。

このスキルは、信頼性の高いメール配信パイプラインを構築し、実際のトラフィック、コンプライアンス上の制約、到達性の要件のもとで運用するエンジニアを対象としています。


前提条件

アカウントとアクセス権
  • Mail Send が有効になっている Twilio SendGrid アカウント。
  • 最小限のスコープを持つ API キー:
    • mail.send(必須)
    • templates.read、templates.write(テンプレートを管理する場合)
    • suppression.read、suppression.write(抑制リストを管理する場合)
    • eventwebhook.read、eventwebhook.write(Webhook 設定を管理する場合)
  • 認証済みの送信者 ID または認証済みドメイン。
ランタイムのバージョン(テスト済み)
  • Node.js 20.11.1(LTS)+ npm 10.2.4
  • Python 3.11.7(または 3.12.1)+ pip 23.3.2
  • Go 1.22.1(Webhook 検証または高スループットの送信処理を実装する場合)
  • OpenSSL 3.0.2(Ubuntu 22.04)、3.2.1(macOS 14)、3.0.12(Fedora 39)
SDK/ライブラリ(推奨)
ネットワーク/インフラ
  • https://api.sendgrid.com への送信 HTTPS 接続(TCP 443)。
  • Event Webhook 用の受信 HTTPS エンドポイント(パブリックアクセス可能)に必要な条件:
    • TLS 1.2 以降(TLS 1.3 を推奨)
    • 安定したホスト名
    • バーストに対応できること(SendGrid はイベントをまとめて送信します)
認証の設定(正確な手順)
  1. API キーを作成します:
    • SendGrid Dashboard → Settings → API Keys → Create API Key
    • 名前:prod-mail-send-2026-02
    • 権限:Restricted Access → Mail Send(必要に応じてその他も)を有効にします
  2. シークレットをシークレットマネージャーに保存します:
    • AWS Secrets Manager:prod/sendgrid/api_key
    • GCP Secret Manager:prod-sendgrid-api-key
    • Vault:secret/data/prod/sendgrid
  3. テスト用にローカルでエクスポートします(コミットしないでください):
    export SENDGRID_API_KEY='SG.xxxxxx.yyyyyy'
    

基本概念

トランザクションメールとマーケティングメール
  • トランザクションメール:ユーザーまたはシステムの操作(パスワードリセット、領収書、アラート)をきっかけに送信されます。迅速かつ一貫して送信する必要があり、管轄地域や内容によっては、通常マーケティングの同意ルールの対象外です。
  • マーケティングメール:キャンペーンやニュースレターです。SendGrid Marketing Campaigns を使用します。コンプライアンス要件と抑制リストの扱いが異なります。

このスキルでは、/v3/mail/send と Dynamic Templates を使ったトランザクションメールに焦点を当てます。

メッセージモデル(SendGrid v3)

送信リクエストは、次の項目を含む JSON ペイロードです:

  • from(送信者が認証済みであるか、ドメイン認証されている必要があります)
  • personalizations[]:
    • to[]、cc[]、bcc[]
    • dynamic_template_data(ダイナミックテンプレート用)
    • custom_args(相関 ID 用。イベント Webhook に表示されます)
  • template_id(ダイナミックテンプレート)
  • categories[](分析データのグループ化用)
  • asm(配信停止グループ)
  • mail_settings、tracking_settings
動的テンプレート(Handlebars)
  • テンプレートはSendGridに保存し、template_idを参照します。
  • Handlebarsの機能:
    • {{var}}はデフォルトでHTMLエスケープされます
    • {{{var}}}はエスケープされません(危険なため、サニタイズしない限り使用しないでください)
    • {{#if}}、{{#each}}、{{else}}
  • バージョン管理:テンプレートにはバージョンがあり、バージョンを有効化できます。
イベントWebhook

SendGridは、次のようなイベントをまとめたJSONをエンドポイントにPOSTします。

  • delivered、open、click、bounce、dropped、deferred、spamreport、unsubscribe、group_unsubscribe、group_resubscribe、processed

本番環境での主な要件:

  • SendGridの公開鍵を使用して署名(ECDSA)を検証します。
  • 再試行に対応します(SendGridは2xx以外の場合に再試行します)。
  • 冪等性:イベントは重複することがあるため、(sg_event_id)または(sg_message_id, event, timestamp)で重複を排除します。
配信停止リスト

配信停止リストはメールの配信を防止します。

  • 全体の配信停止
  • グループ単位の配信停止(ASMグループ)
  • バウンス
  • ブロック
  • スパム報告

システムには、次の対応が必要です。

  • 配信停止を遵守します(状況に応じてCAN-SPAM、GDPR/PECRに準拠します)。
  • 適切な場合は、ワンクリック用のList-Unsubscribeヘッダーを設定します。
  • バウンス率とスパム報告率を監視し、不適切なアドレスへの送信を自動的に停止します。
到達性:SPF/DKIM/DMARC
  • SPF:ドメインで送信IPを許可します。
  • DKIM:暗号署名です。SendGridはドメイン認証用のCNAMEレコードを提供します。
  • DMARC:ポリシーによって整合性とレポートを管理します。まずはp=noneから始め、quarantine/rejectに移行します。
エラーの分類(SendGrid API)
  • 4xx:リクエスト/認証に関する問題です。無条件に再試行しないでください。
  • 429:レート制限です。バックオフして再試行します。
  • 5xx:一時的なエラーです。ジッターを加えて再試行します。

インストールとセットアップ

公式Python SDK — メール(SendGrid)

リポジトリ: https://github.com/twilio/twilio-python
PyPI: pip install twilio sendgrid · サポート対象: Python 3.7~3.13

# SendGrid is the email layer (separate package, same Twilio ecosystem)
import sendgrid
from sendgrid.helpers.mail import Mail
import os

sg = sendgrid.SendGridAPIClient(api_key=os.environ["SENDGRID_API_KEY"])

message = Mail(
    from_email="[email protected]",
    to_emails="[email protected]",
    subject="Hello from Python!",
    html_content="<strong>Hello!</strong>"
)
response = sg.send(message)
print(response.status_code)  # 202 = queued

ソース:sendgrid/sendgrid-python(公式SendGrid Python SDK。Twilioファミリーの一部)

Ubuntu 22.04 / 24.04

Node 20、Python 3.11、およびツールをインストールします。

sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg jq python3.11 python3.11-venv python3-pip

# NodeSource Node 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs

node -v   # v20.11.1 (or later 20.x)
npm -v    # 10.2.4 (or later)

プロジェクトの依存関係(Node):

mkdir -p services/email-sender
cd services/email-sender
npm init -y
npm install @sendgrid/[email protected] @sendgrid/[email protected] [email protected] [email protected]

Python仮想環境:

mkdir -p services/email-worker
cd services/email-worker
python3.11 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip==23.3.2
pip install sendgrid==6.11.0 fastapi==0.109.2 uvicorn==0.27.1 pydantic==2.6.1
Fedora 39 / 40
sudo dnf install -y nodejs-20.11.1 npm jq python3.11 python3.11-pip python3.11-virtualenv openssl
node -v
python3.11 -V
macOS 14(Intel + Apple Silicon)

Homebrewを使用する場合:

brew update
brew install node@20 jq [email protected] openssl@3
echo 'export PATH="/opt/homebrew/opt/node@20/bin:$PATH"' >> ~/.zshrc  # Apple Silicon
echo 'export PATH="/usr/local/opt/node@20/bin:$PATH"' >> ~/.zshrc     # Intel
source ~/.zshrc

node -v
python3.11 -V
環境変数(ローカル開発)

.envを作成します(コミットしないでください)。

パス:services/email-sender/.env

SENDGRID_API_KEY=SG.xxxxxx.yyyyyy
[email protected]
SENDGRID_FROM_NAME=Example Notifications
SENDGRID_TEMPLATE_PASSWORD_RESET=d-2f3c4b5a6d7e8f90123456789abcdeff
SENDGRID_TEMPLATE_RECEIPT=d-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
SENDGRID_EVENT_WEBHOOK_PUBLIC_KEY=MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
EMAIL_ENV=prod

読み込み(bash):

set -a
source .env
set +a

主な機能

トランザクションメールの送信(v3 Mail Send)

要件:

  • 一貫した表示のため、動的テンプレートを使用します。
  • custom_argsで相関IDを追加します。
  • 429/5xxのみを対象とする再試行ポリシーを実装します。
  • バウンスを減らすため、宛先ごとに検証します。

Nodeの送信者の例(本番環境向けの安全なひな形):

パス:services/email-sender/src/send.ts

import sgMail from "@sendgrid/mail";
import { z } from "zod";
import pino from "pino";
import crypto from "crypto";

const log = pino({ level: process.env.LOG_LEVEL ?? "info" });

const Env = z.object({
  SENDGRID_API_KEY: z.string().min(20),
  SENDGRID_FROM_EMAIL: z.string().email(),
  SENDGRID_FROM_NAME: z.string().min(1),
  EMAIL_ENV: z.enum(["dev", "staging", "prod"]).default("prod"),
});
const env = Env.parse(process.env);

sgMail.setApiKey(env.SENDGRID_API_KEY);

export type SendTemplateEmailInput = {
  to: string;
  templateId: string;
  dynamicTemplateData: Record<string, unknown>;
  categories?: string[];
  customArgs?: Record<string, string>;
};

function makeIdempotencyKey(input: SendTemplateEmailInput): string {
  // Stable key for same logical send (avoid duplicates on retries)
  const h = crypto.createHash("sha256");
  h.update(input.to);
  h.update(input.templateId);
  h.update(JSON.stringify(input.dynamicTemplateData));
  return h.digest("hex");
}

export async function sendTemplateEmail(input: SendTemplateEmailInput) {
  const idempotencyKey = makeIdempotencyKey(input);

  const msg = {
    to: input.to,
    from: { email: env.SENDGRID_FROM_EMAIL, name: env.SENDGRID_FROM_NAME },
    templateId: input.templateId,
    dynamicTemplateData: input.dynamicTemplateData,
    categories: input.categories ?? ["transactional"],
    customArgs: {
      ...input.customArgs,
      idempotency_key: idempotencyKey,
      env: env.EMAIL_ENV,
    },
    mailSettings: {
      sandboxMode: { enable: env.EMAIL_ENV !== "prod" },
    },
  };

  // SendGrid does not support an explicit idempotency header for /mail/send.
  // You must implement idempotency in your app (e.g., outbox table).
  try {
    const [resp] = await sgMail.send(msg as any, false);
    log.info(
      { statusCode: resp.statusCode, headers: resp.headers, to: input.to, templateId: input.templateId },
      "sendgrid mail.send accepted"
    );
    return { accepted: true, statusCode: resp.statusCode, idempotencyKey };
  } catch (err: any) {
    const statusCode = err?.code ?? err?.response?.statusCode;
    const body = err?.response?.body;
    log.error({ err, statusCode, body, to: input.to, templateId: input.templateId }, "sendgrid mail.send failed");
    throw err;
  }
}

運用上の注意:

  • mailSettings.sandboxMode.enable=trueにすると、実際の配信を防止できます(開発/ステージング環境で使用してください)。
  • 実際に冪等性を確保するには、(idempotency_key)をキーとするoutboxテーブルを使用し、一度だけ送信します。
動的テンプレート:安全なHandlebarsの使用方法

ルール:

  • ユーザー提供コンテンツには、{{var}}(エスケープあり)を優先して使用します。
  • コンテンツがサニタイズ済みのHTMLでない限り、{{{var}}}は使用しないでください。
  • テンプレートのロジックは最小限にとどめ、複雑な値はコードで計算します。

テンプレートデータの例となる契約:

{
  "app_name": "Example",
  "reset_url": "https://app.example.com/reset?token=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "support_email": "[email protected]",
  "expires_minutes": 30
}

Handlebarsのスニペット:

<p>Reset your {{app_name}} password:</p>
<p><a href="{{reset_url}}">Reset password</a></p>
<p>This link expires in {{expires_minutes}} minutes.</p>
イベントWebhook:検証と冪等な処理

SendGrid Event WebhookはECDSA署名を使用します。

  • ヘッダー:
    • X-Twilio-Email-Event-Webhook-Signature
    • X-Twilio-Email-Event-Webhook-Timestamp
  • 次の値に対して署名を検証します:timestamp + payload(リクエストボディの生データ)

FastAPIの例(生のボディ+検証):

パス:services/email-worker/app/webhook.py

import base64
import hashlib
from fastapi import APIRouter, Request, HTTPException
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.exceptions import InvalidSignature

router = APIRouter()

def verify_sendgrid_signature(public_key_pem: str, signature_b64: str, timestamp: str, payload: bytes) -> None:
    public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
    if not isinstance(public_key, ec.EllipticCurvePublicKey):
        raise ValueError("public key is not EC")

    signed = timestamp.encode("utf-8") + payload
    sig = base64.b64decode(signature_b64)

    try:
        public_key.verify(sig, signed, ec.ECDSA(hashes.SHA256()))
    except InvalidSignature as e:
        raise

@router.post("/webhooks/sendgrid/events")
async def sendgrid_events(request: Request):
    sig = request.headers.get("X-Twilio-Email-Event-Webhook-Signature")
    ts = request.headers.get("X-Twilio-Email-Event-Webhook-Timestamp")
    if not sig or not ts:
        raise HTTPException(status_code=400, detail="missing signature headers")

    raw = await request.body()

    public_key_pem = request.app.state.sendgrid_event_public_key_pem
    try:
        verify_sendgrid_signature(public_key_pem, sig, ts, raw)
    except Exception:
        raise HTTPException(status_code=401, detail="invalid signature")

    events = await request.json()
    # events is a list of dicts
    # Idempotency: dedupe by sg_event_id
    # Persist first, then ack 2xx.
    return {"ok": True, "count": len(events)}

リプレイ攻撃への対策:

  • 例として、5分より古いタイムスタンプを拒否します。
    • tsを現在時刻と比較し、わずかな時刻のずれは許容します。
  • (sg_event_id)を一意制約付きで保存し、重複は無視します。
抑制管理

抑制エンドポイントは次の用途に使用します。

  • 送信前にアドレスが抑制対象かどうかを確認します(任意ですが、コストがかかる場合があります)。
  • 明示的なユーザー操作とコンプライアンスレビューがある場合にのみ、抑制対象から削除します。

主なエンドポイント:

  • 全体の配信停止:/v3/asm/suppressions/global
  • グループの配信停止:/v3/asm/groups/{group_id}/suppressions
  • バウンス:/v3/suppression/bounces
  • ブロック:/v3/suppression/blocks
  • スパム報告:/v3/suppression/spam_reports
ドメイン認証(SPF/DKIM/DMARC)

本番環境の基本設定:

  • SendGrid のドメイン認証(CNAME ベースの DKIM)を使用します。
  • SPF:SendGrid の共有 IP を使用する場合は SendGrid を含めます。専用 IP の場合は、SendGrid のガイダンスに従ってください。
  • DMARC:
    • 開始時:v=DMARC1; p=none; rua=mailto:[email protected]; ruf=mailto:[email protected]; fo=1; adkim=s; aspf=s
    • 監視後に quarantine、続いて reject に移行します。
IP ウォームアップ(専用 IP)

専用 IP を使用する場合:

  • 送信量を数日から数週間かけて段階的に増やします。
  • 苦情率を低く保ち、急激な増加を避けます。
  • トラフィックをセグメント化し、最もエンゲージメントの高い受信者から開始します。

コマンドリファレンス

このセクションでは、**curl による SendGrid API(公式 SendGrid CLI は想定しません)**と、一般的な運用コマンドについて説明します。

認証ヘッダー

すべての API 呼び出し:

  • ヘッダー:Authorization: Bearer $SENDGRID_API_KEY
  • Content-Type:application/json
メール送信:POST /v3/mail/send
curl -sS -D /tmp/sg_headers.txt -o /tmp/sg_body.txt \
  -X POST "https://api.sendgrid.com/v3/mail/send" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "from": { "email": "[email protected]", "name": "Example Notifications" },
  "personalizations": [
    {
      "to": [{ "email": "[email protected]", "name": "Alice" }],
      "dynamic_template_data": {
        "app_name": "Example",
        "reset_url": "https://app.example.com/reset?token=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
        "expires_minutes": 30
      },
      "custom_args": {
        "user_id": "u_12345",
        "request_id": "req_01HPQ7ZK8Z7WQ2J9R8D2E6M3QK"
      }
    }
  ],
  "template_id": "d-2f3c4b5a6d7e8f90123456789abcdeff",
  "categories": ["password_reset", "transactional"],
  "tracking_settings": {
    "click_tracking": { "enable": true, "enable_text": true },
    "open_tracking": { "enable": true }
  }
}
JSON

関連する動作:

  • 成功:HTTP 202 Accepted が返され、本文は空です。
  • 失敗:HTTP 4xx/5xx が返され、JSON 形式のエラー詳細が含まれます。
テンプレート
テンプレート一覧:GET /v3/templates

フラグ:

  • ?generations=dynamic フィルター(アカウントでサポートされている場合)
  • ?page_size=... ページネーション
curl -sS "https://api.sendgrid.com/v3/templates?generations=dynamic&page_size=50" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" | jq .
テンプレート取得:GET /v3/templates/{template_id}
curl -sS "https://api.sendgrid.com/v3/templates/d-2f3c4b5a6d7e8f90123456789abcdeff" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" | jq .
テンプレート作成:POST /v3/templates
curl -sS -X POST "https://api.sendgrid.com/v3/templates" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"prod_password_reset","generation":"dynamic"}' | jq .
バージョン追加:POST /v3/templates/{template_id}/versions
curl -sS -X POST "https://api.sendgrid.com/v3/templates/d-2f3c4b5a6d7e8f90123456789abcdeff/versions" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON' | jq .
{
  "active": 0,
  "name": "v2026-02-21",
  "subject": "Reset your password",
  "html_content": "<p>Reset your password: <a href=\"{{reset_url}}\">Reset</a></p>",
  "plain_content": "Reset your password: {{reset_url}}"
}
JSON
バージョンを有効化:PATCH /v3/templates/{template_id}/versions/{version_id}
curl -sS -X PATCH "https://api.sendgrid.com/v3/templates/d-2f3c4b5a6d7e8f90123456789abcdeff/versions/3c1b2a9d-0f2a-4c7b-9d2a-1a2b3c4d5e6f" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"active":1}' | jq .
イベント Webhook の設定
設定の取得:GET /v3/user/webhooks/event/settings
curl -sS "https://api.sendgrid.com/v3/user/webhooks/event/settings" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" | jq .
設定の更新:PATCH /v3/user/webhooks/event/settings

主なフィールド:

  • enabled(ブール値)
  • url(文字列)
  • group_resubscribe、group_unsubscribe、spam_report、bounce、deferred、delivered、dropped、open、click、processed、unsubscribe(ブール値)
  • oauth_client_id、oauth_client_secret、oauth_token_url(OAuth を使用する場合。一般的ではありません)
curl -sS -X PATCH "https://api.sendgrid.com/v3/user/webhooks/event/settings" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON' | jq .
{
  "enabled": true,
  "url": "https://email-hooks.example.com/webhooks/sendgrid/events",
  "delivered": true,
  "bounce": true,
  "dropped": true,
  "deferred": true,
  "spam_report": true,
  "unsubscribe": true,
  "group_unsubscribe": true,
  "group_resubscribe": true,
  "open": true,
  "click": true,
  "processed": true
}
JSON
抑制
全体の配信停止:一覧:GET /v3/asm/suppressions/global

ページネーション:

  • ?offset=0&limit=500
curl -sS "https://api.sendgrid.com/v3/asm/suppressions/global?offset=0&limit=500" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" | jq .
全体の配信停止を追加:POST /v3/asm/suppressions/global
curl -sS -X POST "https://api.sendgrid.com/v3/asm/suppressions/global" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"recipient_emails":["[email protected]"]}' | jq .
全体の配信停止を削除:DELETE /v3/asm/suppressions/global/{email}
curl -sS -X DELETE "https://api.sendgrid.com/v3/asm/suppressions/global/alice%40example.net" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" -i
バウンス:GET /v3/suppression/bounces

フィルター:

curl -sS "https://api.sendgrid.com/v3/suppression/[email protected]" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" | jq .
バウンスを削除:DELETE /v3/suppression/bounces/{email}
curl -sS -X DELETE "https://api.sendgrid.com/v3/suppression/bounces/alice%40example.net" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" -i
診断
DNS レコードの確認(SPF/DKIM/DMARC)
dig +short TXT example.com
dig +short TXT _dmarc.example.com
dig +short CNAME s1._domainkey.mg.example.com
dig +short CNAME s2._domainkey.mg.example.com
TLS エンドポイントの確認(Webhook 受信側)
openssl s_client -connect email-hooks.example.com:443 -servername email-hooks.example.com -tls1_2 </dev/null 2>/dev/null | openssl x509 -noout -issuer -subject -dates

設定リファレンス

Node サービスの設定

パス:services/email-sender/config/email.config.toml

[sendgrid]
api_base_url = "https://api.sendgrid.com"
from_email = "[email protected]"
from_name = "Example Notifications"

[templates]
password_reset = "d-2f3c4b5a6d7e8f90123456789abcdeff"
receipt = "d-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

[webhook]
event_public_key_pem_path = "/etc/email-sender/sendgrid_event_webhook_public_key.pem"

[delivery]
categories = ["transactional"]
sandbox_mode = false

[retry]
max_attempts = 5
base_delay_ms = 200
max_delay_ms = 5000
retry_on_status = [429, 500, 502, 503, 504]
Systemd ユニット(Linux)

パス:/etc/systemd/system/email-sender.service

[Unit]
Description=Email Sender Service (SendGrid)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=email
Group=email
WorkingDirectory=/opt/email-sender
Environment=NODE_ENV=production
EnvironmentFile=/etc/email-sender/email-sender.env
ExecStart=/usr/bin/node /opt/email-sender/dist/index.js
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/email-sender
AmbientCapabilities=
CapabilityBoundingSet=
LockPersonality=true
MemoryDenyWriteExecute=true

[Install]
WantedBy=multi-user.target

パス:/etc/email-sender/email-sender.env

SENDGRID_API_KEY=SG.xxxxxx.yyyyyy
[email protected]
SENDGRID_FROM_NAME=Example Notifications
LOG_LEVEL=info
EMAIL_ENV=prod
Webhook 受信側の NGINX リバースプロキシ

パス:/etc/nginx/conf.d/sendgrid-webhook.conf

server {
  listen 443 ssl http2;
  server_name email-hooks.example.com;

  ssl_certificate     /etc/letsencrypt/live/email-hooks.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/email-hooks.example.com/privkey.pem;
  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_ciphers HIGH:!aNULL:!MD5;

  client_max_body_size 2m;

  location /webhooks/sendgrid/events {
    proxy_pass http://127.0.0.1:8080/webhooks/sendgrid/events;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_connect_timeout 2s;
    proxy_read_timeout 5s;
    proxy_send_timeout 5s;
  }
}

統合パターン

Outbox パターン(DB による冪等性)

メール送信が DB トランザクションによってトリガーされる場合に使用します。

パターン:

  1. ビジネスイベントと同じ DB トランザクション内で、email_outbox にレコードを挿入します。
  2. ワーカーが保留中の行を読み取り、SendGrid 経由で送信し、sent_at を設定して送信済みにし、provider_response を保存します。
  3. Outbox の行は論理的なメールごとに一意であるため、安全に再試行できます。

PostgreSQL スキーマ:

CREATE TABLE email_outbox (
  id bigserial PRIMARY KEY,
  idempotency_key text NOT NULL UNIQUE,
  to_email text NOT NULL,
  template_id text NOT NULL,
  dynamic_template_data jsonb NOT NULL,
  categories text[] NOT NULL DEFAULT ARRAY['transactional'],
  created_at timestamptz NOT NULL DEFAULT now(),
  sent_at timestamptz,
  last_error text,
  attempt_count int NOT NULL DEFAULT 0
);

CREATE INDEX email_outbox_pending_idx ON email_outbox (created_at) WHERE sent_at IS NULL;
Twilio クラスター全体での相関 ID
  • SMS/Voice/Verify と Email では、次の項目を標準化します。
    • request_id(受信時)
    • user_id
    • notification_id(論理通知)
  • SendGrid の場合:
    • これらを custom_args に格納すると、Event Webhook のペイロードに含まれます。
Webhook 取り込みパイプライン

推奨フロー:

  • パブリックエンドポイントが Webhook を受信 → 署名を検証 → 未加工のイベントを Kafka/SQS/PubSub にエンキュー → 非同期コンシューマーが DB/メトリクスを更新します。

メリット:

  • Webhook のタイムアウトを回避できます。
  • リプレイとバックフィルが可能になります。
  • 重複排除を一元化できます。

SQS エンキューの例(疑似コード):

  • HTTP ハンドラー:
    • 署名を検証
    • 未加工の JSON 行を使って SendMessageBatch を実行
    • すぐに 200 を返す
CI/CD によるテンプレートの昇格

テンプレートはコードとして扱います。

  • テンプレートのソースをリポジトリに保存します:templates/sendgrid/password_reset/v2026-02-21.html
  • CI ジョブ:
    • API を使って新しいバージョンを作成
    • レンダリングテストを実行(Handlebars のコンパイル+サンプルデータ)
    • 承認後にバージョンを有効化

エラー処理とトラブルシューティング

正確なエラーテキストと対処方法を記載します。

  1. 401 Unauthorized(API キーが無効)
    症状(curl):
HTTP/2 401
{"errors":[{"message":"Permission denied, wrong credentials","field":null,"help":null}]}

根本原因:

  • SENDGRID_API_KEY が無効または失効しているか、必要なスコープがありません。

対処方法:

  • mail.send を指定して API キーを再生成します。
  • ランタイムで正しいシークレットが読み込まれていることを確認します(環境変数の読み込み元、シークレットのマウントを確認)。
  1. 403 Forbidden(スコープが不足)
HTTP/2 403
{"errors":[{"message":"access forbidden","field":null,"help":null}]}

根本原因:

  • API キーにエンドポイントを利用する権限がありません(例:テンプレートの書き込み)。

対処方法:

  • API キーの権限を更新するか、テンプレート管理用の別のキーを作成します。
  1. 400 Bad Request:メールアドレスが無効
{"errors":[{"message":"The email address is invalid.","field":"personalizations.0.to.0.email","help":"http://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/errors.html#message.personalizations.to.email"}]}

根本原因:

  • to アドレスの形式が正しくありません。

対処方法:

  • エンキューする前にメールアドレスを検証します。
  • ユーザー入力に基づく場合は、検証と正規化を必須にします。
  1. 400 Bad Request:テンプレートが見つからない/テンプレート ID が間違っている
{"errors":[{"message":"The template_id is not a valid template ID.","field":"template_id","help":"http://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/errors.html"}]}

根本原因:

  • レガシーのテンプレート ID、または環境が異なるテンプレートを使用しています。

対処方法:

  • GET /v3/templates/{id} でテンプレートが存在することを確認します。
  • d- で始まる ダイナミックテンプレート ID を使用していることを確認します。
  1. 429 Too Many Requests
HTTP/2 429
{"errors":[{"message":"Too many requests","field":null,"help":null}]}

根本原因:

  • アカウントまたは IP のレート制限を超過しています。トラフィックが急増しています。

対処方法:

  • ジッターを加えた指数バックオフを実装します。
  • 可能な場合は送信をバッチ処理します。
  • 制限超過が続く場合は、上限の引き上げを申請するか、ウォームアップを行った複数の専用 IP を使用します。
  1. 413 Payload Too Large(Webhook 受信側)

NGINX:

413 Request Entity Too Large

根本原因:

  • SendGrid がイベントをバッチ送信しており、ペイロードが client_max_body_size を超えています。

対処方法:

  • client_max_body_size を増やします(例:2m または 5m)。
  • アプリケーションが大きな JSON 配列を効率よく解析できることを確認します。
  1. 401 invalid signature(Webhook 検証側)

サービスのログ:

HTTP 401 {"detail":"invalid signature"}

根本原因:

  • 署名検証に未加工の本文ではなく、解析済みの JSON を使用しています。
  • 公開鍵が間違っています(ローテーション済み、またはコピー時に誤りが発生)。
  • リプレイチェックを含めている場合、タイムスタンプが一致していません。

対処方法:

  • 受信したままのリクエストバイト列に対して署名を検証します。
  • SendGrid の Event Webhook 設定から正しい公開鍵を取得します。
  • 小さなクロックずれを許容し、NTP が動作していることを確認します。
  1. イベントのドロップ:抑制対象のアドレス

Event Webhook に含まれる内容:

{
  "event":"dropped",
  "reason":"Bounced Address",
  "email":"[email protected]"
}

根本原因:

  • 受信者がバウンス/ブロック/スパムの抑制リストに登録されています。

対処方法:

  • そのアドレスへの送信を停止します。
  • ユーザーが対処できるフローを用意します(メールアドレスの更新、アドレスの確認)。
  • 正当な理由とユーザーの確認がある場合に限り、抑制を解除します。
  1. 550 5.1.1 バウンス(メールボックスが存在しない)

Event Webhook のバウンス:

{
  "event":"bounce",
  "status":"5.1.1",
  "reason":"550 5.1.1 The email account that you tried to reach does not exist."
}

根本原因:

  • 受信者のアドレスが無効か、削除されています。

対処方法:

  • ユーザー DB でアドレスを無効としてマークします。
  • ユーザーにメールアドレスの更新を求め、再試行はしません。
  1. Deferred(一時的な失敗)
{
  "event":"deferred",
  "response":"451 4.7.1 Try again later",
  "attempt":"2"
}

根本原因:

  • 受信側サーバーが一時的にスロットリングを行っています。

対処方法:

  • 対応は不要です。SendGrid が再試行します。
  • 特定のドメインで継続する場合は、ドメインごとのスロットリングとコンテンツの確認を検討します。

セキュリティ強化

シークレットの取り扱い
  • SENDGRID_API_KEY はシークレットマネージャーのみに保存し、リポジトリやコンテナイメージには決して保存しないでください。
  • API キーは四半期ごと(またはインシデント発生時)にローテーションしてください。
  • 環境ごと、機能ごとに別のキーを使用してください。
    • prod-mail-send(mail.send のみ)
    • prod-templates-admin(templates.* のみ。CI に制限)
Webhook エンドポイントの強化
  • HTTPS を必須とし、HTTP から HTTPS へリダイレクトしてください。
  • ECDSA 署名とタイムスタンプを検証してください。
  • リクエストサイズの上限を設定してください(ただし、バッチを扱える十分な大きさにしてください)。
  • IP ごとにレート制限を設けてください(SendGrid は複数の IP を使用するため、許可リストは壊れやすい点に注意してください)。
  • PII のログ記録は最小限にし、可能な場合はログ内のメールアドレスをハッシュ化してください。
OS/ランタイムの強化(CIS に準拠)
  • Linux:
    • root 以外のユーザーとして実行します(User=email)。
    • systemd で NoNewPrivileges=true、ProtectSystem=strict、ProtectHome=true を設定します。
    • コンテナ化する場合は、ベースイメージを最小限にします(distroless または slim)。
  • TLS:
    • TLS 1.0/1.1 を無効にします(CIS は TLS 1.2 以降を推奨しています)。
  • 依存関係の衛生管理:
    • Node:CI で npm audit を実行し、ロックファイルでバージョンを固定します。
    • Python:本番環境では pip-audit を実行し、ハッシュで固定した依存関係を使用します。
メールコンテンツのセキュリティ
  • サニタイズされていない HTML をテンプレートに挿入しないでください。
  • URL にシークレットを含めないでください。短期間のみ有効なトークンを使用します。
  • 該当する場合は List-Unsubscribe を使用してください。トランザクションメールでも、プリファレンスセンターの導入を検討してください。

パフォーマンスの調整

スループット:バッチ処理と接続の再利用
  • SendGrid の /v3/mail/send は、1 回のリクエストで複数の personalization に対応しています。
  • 大量送信では、テンプレートとペイロードの形式ごとに受信者をまとめてください。

期待される効果:

  • 変更前:メール 1 通につき 1 リクエスト → オーバーヘッドが大きく、429 が増加します。
  • 変更後:コンテンツが許す場合、100 件の personalizations につき 1 リクエスト → リクエストが減り、レイテンシーのばらつきが小さくなります。

制約:

  • personalization データは受信者ごとに異なりますが、テンプレートが同じで、データが personalization ごとに設定されていれば、まとめて処理できます。
ワーカーの並行性
  • 自己誘発の 429 を避けるため、並行数に上限を設けてください(例:処理中のリクエストを 50 件までにする)。
  • 429 が発生した場合は、適応型バックオフを行ってください。
Webhook の取り込み
  • ペイロードが大きい場合は、ストリーミングで JSON を解析してください(言語によって異なります)。
  • キューへの登録後は速やかに応答し、インラインで負荷の高い DB 処理を行わないでください。
配信到達率の向上
  • アドレスの取得時に検証し、バウンスを減らしてください。
  • ドメイン認証を使用すると、受信トレイへの到達率が上がり、配信遅延も減少します。

高度なトピック

「open」イベントと「click」イベントの処理
  • 開封イベントの信頼性は高くありません(Apple Mail のプライバシー保護機能、画像プロキシなど)。
  • クリックイベントの信頼性はより高いものの、ボットによるスキャンの影響は受けます。
  • イベントは集計分析に使用し、ボットフィルタリングがない限り、ユーザーごとの厳密な状態判定には使用しないでください。
Custom Args と Categories
  • custom_args:キーと値のペアで、イベント Webhook に表示されます。相関 ID に最適です。
  • categories:文字列の配列で、SendGrid の統計/分析でのグループ化に使用されます。

本番環境での指針:

  • categories はカーディナリティの低い値(例:password_reset、receipt)に限定し、ユーザーごとの値は使用しないでください。
Inbound Parse Webhook(メールを受信する場合)

SendGrid Inbound Parse を使用する場合:

  • MX レコードを SendGrid の parse ホストに設定します。
  • エンドポイントは、from、to、subject、text、html、attachments などのフィールドを含む multipart form-data を受信します。

強化策:

  • SPF/DKIM/DMARC の結果が提供される場合は検証し、提供されない場合は信頼できない入力として扱ってください。
  • 添付ファイルのサイズと種類に上限を設けてください。
マルチテナント送信

複数のブランド/ドメインを代行して送信する場合:

  • 認証済みドメインと送信元アドレスを分けてください。
  • 分離のためにサブユーザー(SendGrid の機能)の使用を検討してください。
  • テナントごとの抑制設定への準拠を確保してください。
専用 IP プールと地域に関する考慮事項
  • 複数のリージョンで運用する場合は、レピュテーションを分離するため、リージョンごとに IP プールを分けることを検討してください。
  • 各プールを個別にウォームアップしてください。

使用例

1) パスワードリセットメール(ダイナミックテンプレート+アウトボックス)
  1. API サーバーがアウトボックスの行を書き込みます。
INSERT INTO email_outbox (idempotency_key, to_email, template_id, dynamic_template_data, categories)
VALUES (
  'email:password_reset:u_12345:req_01HPQ7ZK8Z7WQ2J9R8D2E6M3QK',
  '[email protected]',
  'd-2f3c4b5a6d7e8f90123456789abcdeff',
  '{"app_name":"Example","reset_url":"https://app.example.com/reset?token=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d","expires_minutes":30}',
  ARRAY['password_reset','transactional']
);
  1. ワーカーが保留中の行を読み取り、sendTemplateEmail を呼び出して送信済みにします。

  2. イベント Webhook が custom_args.request_id をキーとして配信状況を更新します。

2) PDF 添付ファイル付き領収書メール

SendGrid は添付ファイル(base64)に対応しています。サイズは小さく保ってください。大きな添付ファイルはメールの到達率を低下させます。

PDF_B64="$(base64 -w 0 receipt_2026-02-21.pdf)"
curl -sS -X POST "https://api.sendgrid.com/v3/mail/send" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" \
  -H "Content-Type: application/json" \
  -d @- <<JSON
{
  "from": { "email": "[email protected]", "name": "Example Billing" },
  "personalizations": [
    {
      "to": [{ "email": "[email protected]" }],
      "dynamic_template_data": {
        "amount": "49.00",
        "currency": "USD",
        "receipt_id": "rcpt_9f3a2c1d"
      },
      "custom_args": { "receipt_id": "rcpt_9f3a2c1d" }
    }
  ],
  "template_id": "d-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "attachments": [
    {
      "content": "${PDF_B64}",
      "type": "application/pdf",
      "filename": "receipt_rcpt_9f3a2c1d.pdf",
      "disposition": "attachment"
    }
  ],
  "categories": ["receipt","transactional"]
}
JSON
3) 重複排除機能付き Webhook 受信(Postgres の一意制約)

スキーマ:

CREATE TABLE sendgrid_events (
  sg_event_id text PRIMARY KEY,
  sg_message_id text,
  event text NOT NULL,
  email text NOT NULL,
  ts bigint NOT NULL,
  payload jsonb NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now()
);

競合を無視して挿入:

INSERT INTO sendgrid_events (sg_event_id, sg_message_id, event, email, ts, payload)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (sg_event_id) DO NOTHING;
4) 抑制リストを考慮した送信(重要なメールは事前に確認)

重要なメール(例:法的通知)については、抑制リストを事前に確認できます。

curl -sS "https://api.sendgrid.com/v3/asm/suppressions/global/alice%40example.net" \
  -H "Authorization: Bearer ${SENDGRID_API_KEY}" -i
  • 200 OK の場合、ユーザーは全体配信停止になっています → 送信しないでください。
  • 404 Not Found の場合、全体配信の抑制対象ではありません → 続行してください(ただし、バウンスまたはブロックの対象である可能性はあります)。

この方法は慎重に使用してください。レイテンシと API の負荷が増加します。

5) ドメイン認証の確認チェックリスト
  1. DKIM CNAME が名前解決できることを確認します。
dig +short CNAME s1._domainkey.mg.example.com
dig +short CNAME s2._domainkey.mg.example.com
  1. DMARC が存在することを確認します。
dig +short TXT _dmarc.example.com
  1. Gmail にテストメールを送信し、「Authentication-Results」を確認します。
  • spf=pass
  • dkim=pass
  • dmarc=pass
6) レート制限に耐える送信処理(再試行ポリシー)

擬似ポリシー:

  • 次の場合に再試行します:429、500、502、503、504
  • バックオフ:min(max_delay, base * 2^attempt) + random(0, 100ms)
  • 最大試行回数:5
  • 400/401/403 の場合は再試行しないでください。

クイックリファレンス

タスクコマンド/エンドポイント主なフラグ/フィールド
メールを送信POST /v3/mail/sendtemplate_id、personalizations[]、dynamic_template_data、custom_args、categories、mail_settings.sandboxMode
テンプレートを一覧表示GET /v3/templatesgenerations=dynamic、page_size
テンプレートを作成POST /v3/templatesname、generation:"dynamic"
テンプレートのバージョンを追加POST /v3/templates/{id}/versionsname、subject、html_content、plain_content、active
バージョンを有効化PATCH /v3/templates/{id}/versions/{vid}active:1
Webhook 設定を取得GET /v3/user/webhooks/event/settingsn/a
Webhook 設定を更新PATCH /v3/user/webhooks/event/settingsenabled、url、イベントの切り替え
全体配信停止リストを一覧表示GET /v3/asm/suppressions/globaloffset、limit
全体配信停止リストに追加POST /v3/asm/suppressions/globalrecipient_emails[]
全体配信停止リストから削除DELETE /v3/asm/suppressions/global/{email}メールアドレスを URL エンコード
バウンスを一覧表示GET /v3/suppression/bouncesemail、start_time、end_time
バウンスを削除DELETE /v3/suppression/bounces/{email}メールアドレスを URL エンコード
DNS を確認dig_dmarc、_domainkey、SPF TXT

グラフの関係

DEPENDS_ON
  • twilio-email DEPENDS_ON:
    • シークレットの安全な保存(Vault/AWS Secrets Manager/GCP Secret Manager)
    • Webhook 用の HTTPS イングレス(NGINX/ALB/API Gateway)
    • 冪等性/重複排除のための永続ストア(PostgreSQL/DynamoDB)
    • オブザーバビリティスタック(構造化ログ+メトリクス+トレーシング)
COMPOSES
  • twilio-email と組み合わせるもの:
    • twilio-messaging(メールがバウンスまたは抑制された場合の SMS フォールバック)
    • twilio-verify(OTP 用のメールチャネル。認証とトランザクションメールの連携)
    • twilio-studio(メール+SMS を送信するフローをトリガーする、Webhook 駆動のオーケストレーション)
    • Webhook の取り込みとアウトボックスワーカー用のキューシステム(SQS/Kafka/PubSub)
SIMILAR_TO
  • SIMILAR_TO:
    • AWS SES のトランザクションメールのパターン(Webhook/イベント駆動のバウンス/苦情)
    • Mailgun のトランザクションメールのパターン(Webhook+抑制リスト)
    • Postmark のトランザクションメールのパターン(テンプレート+メッセージストリーム)

コメント