twilio-email について
twilio-email は、SendGrid を使ったトランザクションメールシステムを構築・運用するエンジニア向けの OpenClaw スキルです。送信、テンプレート、イベント Webhook、抑制リスト、ドメイン認証など、本番運用に関わる項目を扱います。
主な機能
- 01SendGrid v3 API によるメール送信
- 02ダイナミックテンプレートの安全な利用と管理
- 03イベント Webhook の署名検証と重複排除
- 04バウンスや配信停止の抑制リスト管理
- 05SPF、DKIM、DMARC、IP ウォーミングの設定
活用シーン
- 01パスワードリセットや領収書などのメール送信
- 02配信・バウンスイベントの処理と検証
- 03配信停止や苦情の状態を送信に反映
- 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/ライブラリ(推奨)
- Node:
@sendgrid/[email protected]、@sendgrid/[email protected] - Python:
sendgrid==6.11.0 - Webhook 署名検証:
- Node:
@sendgrid/[email protected](または ECDSA 検証を手動で実装) - Go:
github.com/sendgrid/[email protected](メール送信)、Webhook 用のカスタム検証
- Node:
ネットワーク/インフラ
https://api.sendgrid.comへの送信 HTTPS 接続(TCP 443)。- Event Webhook 用の受信 HTTPS エンドポイント(パブリックアクセス可能)に必要な条件:
- TLS 1.2 以降(TLS 1.3 を推奨)
- 安定したホスト名
- バーストに対応できること(SendGrid はイベントをまとめて送信します)
認証の設定(正確な手順)
- API キーを作成します:
- SendGrid Dashboard → Settings → API Keys → Create API Key
- 名前:
prod-mail-send-2026-02 - 権限:Restricted Access →
Mail Send(必要に応じてその他も)を有効にします
- シークレットをシークレットマネージャーに保存します:
- AWS Secrets Manager:
prod/sendgrid/api_key - GCP Secret Manager:
prod-sendgrid-api-key - Vault:
secret/data/prod/sendgrid
- AWS Secrets Manager:
- テスト用にローカルでエクスポートします(コミットしないでください):
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-SignatureX-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
フィルター:
?start_time=1708473600&end_time=1708560000(UNIX 秒)[email protected]
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 トランザクションによってトリガーされる場合に使用します。
パターン:
- ビジネスイベントと同じ DB トランザクション内で、
email_outboxにレコードを挿入します。 - ワーカーが保留中の行を読み取り、SendGrid 経由で送信し、
sent_atを設定して送信済みにし、provider_responseを保存します。 - 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_idnotification_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 のコンパイル+サンプルデータ)
- 承認後にバージョンを有効化
エラー処理とトラブルシューティング
正確なエラーテキストと対処方法を記載します。
- 401 Unauthorized(API キーが無効)
症状(curl):
HTTP/2 401
{"errors":[{"message":"Permission denied, wrong credentials","field":null,"help":null}]}
根本原因:
SENDGRID_API_KEYが無効または失効しているか、必要なスコープがありません。
対処方法:
mail.sendを指定して API キーを再生成します。- ランタイムで正しいシークレットが読み込まれていることを確認します(環境変数の読み込み元、シークレットのマウントを確認)。
- 403 Forbidden(スコープが不足)
HTTP/2 403
{"errors":[{"message":"access forbidden","field":null,"help":null}]}
根本原因:
- API キーにエンドポイントを利用する権限がありません(例:テンプレートの書き込み)。
対処方法:
- API キーの権限を更新するか、テンプレート管理用の別のキーを作成します。
- 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アドレスの形式が正しくありません。
対処方法:
- エンキューする前にメールアドレスを検証します。
- ユーザー入力に基づく場合は、検証と正規化を必須にします。
- 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 を使用していることを確認します。
- 429 Too Many Requests
HTTP/2 429
{"errors":[{"message":"Too many requests","field":null,"help":null}]}
根本原因:
- アカウントまたは IP のレート制限を超過しています。トラフィックが急増しています。
対処方法:
- ジッターを加えた指数バックオフを実装します。
- 可能な場合は送信をバッチ処理します。
- 制限超過が続く場合は、上限の引き上げを申請するか、ウォームアップを行った複数の専用 IP を使用します。
- 413 Payload Too Large(Webhook 受信側)
NGINX:
413 Request Entity Too Large
根本原因:
- SendGrid がイベントをバッチ送信しており、ペイロードが
client_max_body_sizeを超えています。
対処方法:
client_max_body_sizeを増やします(例:2m または 5m)。- アプリケーションが大きな JSON 配列を効率よく解析できることを確認します。
- 401 invalid signature(Webhook 検証側)
サービスのログ:
HTTP 401 {"detail":"invalid signature"}
根本原因:
- 署名検証に未加工の本文ではなく、解析済みの JSON を使用しています。
- 公開鍵が間違っています(ローテーション済み、またはコピー時に誤りが発生)。
- リプレイチェックを含めている場合、タイムスタンプが一致していません。
対処方法:
- 受信したままのリクエストバイト列に対して署名を検証します。
- SendGrid の Event Webhook 設定から正しい公開鍵を取得します。
- 小さなクロックずれを許容し、NTP が動作していることを確認します。
- イベントのドロップ:抑制対象のアドレス
Event Webhook に含まれる内容:
{
"event":"dropped",
"reason":"Bounced Address",
"email":"[email protected]"
}
根本原因:
- 受信者がバウンス/ブロック/スパムの抑制リストに登録されています。
対処方法:
- そのアドレスへの送信を停止します。
- ユーザーが対処できるフローを用意します(メールアドレスの更新、アドレスの確認)。
- 正当な理由とユーザーの確認がある場合に限り、抑制を解除します。
- 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 でアドレスを無効としてマークします。
- ユーザーにメールアドレスの更新を求め、再試行はしません。
- 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)。
- root 以外のユーザーとして実行します(
- TLS:
- TLS 1.0/1.1 を無効にします(CIS は TLS 1.2 以降を推奨しています)。
- 依存関係の衛生管理:
- Node:CI で
npm auditを実行し、ロックファイルでバージョンを固定します。 - Python:本番環境では
pip-auditを実行し、ハッシュで固定した依存関係を使用します。
- Node:CI で
メールコンテンツのセキュリティ
- サニタイズされていない 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) パスワードリセットメール(ダイナミックテンプレート+アウトボックス)
- 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']
);
-
ワーカーが保留中の行を読み取り、
sendTemplateEmailを呼び出して送信済みにします。 -
イベント 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) ドメイン認証の確認チェックリスト
- DKIM CNAME が名前解決できることを確認します。
dig +short CNAME s1._domainkey.mg.example.com
dig +short CNAME s2._domainkey.mg.example.com
- DMARC が存在することを確認します。
dig +short TXT _dmarc.example.com
- Gmail にテストメールを送信し、「Authentication-Results」を確認します。
spf=passdkim=passdmarc=pass
6) レート制限に耐える送信処理(再試行ポリシー)
擬似ポリシー:
- 次の場合に再試行します:429、500、502、503、504
- バックオフ:
min(max_delay, base * 2^attempt) + random(0, 100ms) - 最大試行回数:5
- 400/401/403 の場合は再試行しないでください。
クイックリファレンス
| タスク | コマンド/エンドポイント | 主なフラグ/フィールド |
|---|---|---|
| メールを送信 | POST /v3/mail/send | template_id、personalizations[]、dynamic_template_data、custom_args、categories、mail_settings.sandboxMode |
| テンプレートを一覧表示 | GET /v3/templates | generations=dynamic、page_size |
| テンプレートを作成 | POST /v3/templates | name、generation:"dynamic" |
| テンプレートのバージョンを追加 | POST /v3/templates/{id}/versions | name、subject、html_content、plain_content、active |
| バージョンを有効化 | PATCH /v3/templates/{id}/versions/{vid} | active:1 |
| Webhook 設定を取得 | GET /v3/user/webhooks/event/settings | n/a |
| Webhook 設定を更新 | PATCH /v3/user/webhooks/event/settings | enabled、url、イベントの切り替え |
| 全体配信停止リストを一覧表示 | GET /v3/asm/suppressions/global | offset、limit |
| 全体配信停止リストに追加 | POST /v3/asm/suppressions/global | recipient_emails[] |
| 全体配信停止リストから削除 | DELETE /v3/asm/suppressions/global/{email} | メールアドレスを URL エンコード |
| バウンスを一覧表示 | GET /v3/suppression/bounces | email、start_time、end_time |
| バウンスを削除 | DELETE /v3/suppression/bounces/{email} | メールアドレスを URL エンコード |
| DNS を確認 | dig | _dmarc、_domainkey、SPF TXT |
グラフの関係
DEPENDS_ON
twilio-emailDEPENDS_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 のトランザクションメールのパターン(テンプレート+メッセージストリーム)