Claude CodeでSendGridメール送信を安全に実装する方法
SendGridの設定、Mail Send API、失敗対策までClaude Codeで安全に実装する実践ガイド。
SendGridは、アプリケーションからメールを送るためのクラウド型メール配信サービスです。問い合わせフォームの控えメール、会員登録後の案内、日次レポート、営業フォローなどをAPIから送れます。
ただし、Claude Codeに「SendGridでメール送信を実装して」とだけ頼むと、APIを叩くコードは出ても、検証済み送信元、APIキーの保管、再送時の二重送信、バウンス、迷惑メール報告、ログ保存、配信停止の扱いが抜けがちです。メールは一度外に出ると取り消せません。初心者ほど、コードより先に運用の境界を決める必要があります。
この記事では、Twilio SendGridの公式Mail Send API v3を前提に、Claude Codeで安全に実装する手順を整理します。SendGridのValidation Error解説も見ながら、コピペで動くNode.jsスクリプト、dry-run、ローカル送信ログ、簡易リトライ、idempotency guardまで入れます。
関連する基礎は、メール自動化ガイド、API開発ガイド、環境変数管理、セキュリティベストプラクティスも合わせて読むと理解しやすいです。
最初に理解するSendGridの基本
SendGridの送信は、基本的にはPOST https://api.sendgrid.com/v3/mail/sendへJSONを送るだけです。認証はAuthorization: Bearer SENDGRID_API_KEYで行います。APIとしては単純ですが、実運用では次の前提を先に満たします。
| 項目 | 初心者向けの意味 | 実装で確認すること |
|---|---|---|
| Verified Sender | 「このメールアドレスから送ってよい」とSendGrid側で確認済みの送信元 | 個人検証ならSingle Sender、本番ならDomain Authenticationを使う |
| Domain Authentication | SPF/DKIMなどのDNS設定で自社ドメイン送信を証明する仕組み | DNS反映後にSendGrid側で検証済みか確認する |
| API Key | SendGrid APIを呼ぶための秘密鍵 | サーバー側の環境変数だけに置き、Gitやブラウザへ出さない |
| personalizations | 宛先ごとに件名、名前、変数、メタデータを変える配列 | 1人ずつ分け、CC的に受信者一覧を見せない |
| suppression | バウンス、迷惑メール報告、購読解除などで送らない宛先リスト | 送信前に自社DB側でも除外し、SendGridの結果も記録する |
| response log | SendGridから返ったHTTPステータスやx-message-id | 失敗調査、二重送信防止、問い合わせ対応に残す |
SPFは「このサーバーから自社ドメインのメールを送ってよい」というDNSの許可リストです。DKIMは「本文が途中で改ざんされていない」と示す署名です。DMARCはSPFやDKIMに失敗したメールを受信側がどう扱うかを示す方針です。難しく見えますが、最初は「送信元の身分証明」と考えると十分です。
SendGridの製品概要はsendgrid.comから確認できます。本番ドメインで送る場合は、メール配信コードを書く前に送信元認証を終わらせてください。未認証のfromで送ると、APIの検証エラーや配信失敗になりやすく、到達率も安定しません。
使いどころを4つに分ける
メール送信は「メールを送る機能」でまとめると事故ります。目的ごとに許可、頻度、本文、停止方法、ログ粒度が違うからです。
| ユースケース | 例 | 必要な注意 |
|---|---|---|
| 問い合わせフォームの控えメール | 送信者へ受付内容、運営側へ通知 | フォーム入力値をHTMLへ直挿ししない。管理者宛てとユーザー宛てを分ける |
| トランザクション系オンボーディング | 登録完了、初回ログイン手順、購入後の案内 | 本人が期待しているメールなので、宣伝を混ぜすぎない |
| 日次レポートメール | 売上、エラー、予約、学習進捗のまとめ | 失敗時に再送しても二重集計に見えない件名とidempotency keyを持つ |
| 営業・アウトリーチ | 資料送付、商談後フォロー、休眠顧客への案内 | 同意、正当な接点、配信停止、住所や会社情報など法令面の確認が必要 |
特に営業メールは、技術的に送れることと送ってよいことが別です。国や地域、相手との関係、B2B/B2C、既存顧客かどうかで条件が変わります。この記事は実装ガイドであり、法務判断の代わりではありません。アウトリーチでは、少なくとも配信停止やオプトアウト方法を本文に入れ、解除済みの宛先へ再送しない仕組みを用意してください。
flowchart LR
App["アプリ / Claude Code実装"]
Validate["入力検証"]
Log["送信ログとidempotency key"]
SendGrid["SendGrid Mail Send API"]
Inbox["受信箱"]
Events["Bounce / Spam / Unsubscribe"]
Suppression["配信停止・抑止リスト"]
App --> Validate --> Log --> SendGrid --> Inbox
SendGrid --> Events --> Suppression
Suppression --> Validate
コピペで動くNode.js送信スクリプト
次のスクリプトはNode.js 20以上で動きます。依存パッケージは不要です。安全のため、何も指定しない実行はdry-runになり、SendGridへ送信しません。実送信したいときだけ--sendを付けます。SendGrid側でリクエスト検証だけしたい場合は--send --sandboxを使います。
// sendgrid-safe-send.mjs
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
const ENDPOINT = process.env.SENDGRID_API_BASE ?? "https://api.sendgrid.com/v3/mail/send";
const LOG_PATH = process.env.SENDGRID_SEND_LOG ?? ".sendgrid-send-log.json";
const DRY_RUN = !process.argv.includes("--send");
const SANDBOX = process.argv.includes("--sandbox");
const MAX_ATTEMPTS = Number.parseInt(process.env.SENDGRID_MAX_ATTEMPTS ?? "3", 10);
const recipient = {
email: process.env.MAIL_TO ?? "recipient@example.com",
name: process.env.MAIL_TO_NAME ?? "Test Recipient",
};
const message = {
from: {
email: process.env.MAIL_FROM ?? "verified-sender@example.com",
name: process.env.MAIL_FROM_NAME ?? "ClaudeCodeLab Demo",
},
reply_to: {
email: process.env.MAIL_REPLY_TO ?? process.env.MAIL_FROM ?? "verified-sender@example.com",
},
personalizations: [
{
to: [recipient],
custom_args: {
use_case: process.env.MAIL_USE_CASE ?? "dry_run_demo",
},
},
],
subject: process.env.MAIL_SUBJECT ?? `SendGrid dry-run test for ${recipient.name}`,
content: [
{
type: "text/plain",
value: `Hello ${recipient.name},\n\nThis is a safe SendGrid test from Claude Code.\n`,
},
{
type: "text/html",
value: `<p>Hello ${escapeHtml(recipient.name)},</p><p>This is a safe SendGrid test from Claude Code.</p>`,
},
],
categories: ["claude-code-demo"],
mail_settings: {
sandbox_mode: { enable: SANDBOX },
},
};
validatePayload(message);
const idempotencyKey = makeIdempotencyKey(message);
for (const personalization of message.personalizations) {
personalization.custom_args = {
...(personalization.custom_args ?? {}),
idempotency_key: idempotencyKey,
};
}
await sendWithRetry(message, idempotencyKey);
function validatePayload(payload) {
if (!Number.isInteger(MAX_ATTEMPTS) || MAX_ATTEMPTS < 1 || MAX_ATTEMPTS > 5) {
throw new Error("SENDGRID_MAX_ATTEMPTS must be an integer from 1 to 5.");
}
assertEmail(payload.from?.email, "from.email");
if (!DRY_RUN && payload.from.email.endsWith("@example.com")) {
throw new Error("Set MAIL_FROM to a verified SendGrid sender before using --send.");
}
if (!Array.isArray(payload.personalizations) || payload.personalizations.length === 0) {
throw new Error("personalizations must contain at least one recipient.");
}
for (const [index, personalization] of payload.personalizations.entries()) {
if (!Array.isArray(personalization.to) || personalization.to.length !== 1) {
throw new Error(`personalizations[${index}].to must contain exactly one recipient.`);
}
assertEmail(personalization.to[0]?.email, `personalizations[${index}].to[0].email`);
}
if (!payload.subject && !payload.template_id) {
throw new Error("Provide a subject or a SendGrid template_id.");
}
const hasContent = Array.isArray(payload.content)
&& payload.content.some((item) => typeof item.value === "string" && item.value.trim());
if (!hasContent && !payload.template_id) {
throw new Error("Provide text/html content or a SendGrid template_id.");
}
}
function assertEmail(value, field) {
if (typeof value !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error(`${field} must be a valid email address.`);
}
}
function makeIdempotencyKey(payload) {
const stableEnvelope = {
from: payload.from.email.toLowerCase(),
to: payload.personalizations.map((item) => item.to[0].email.toLowerCase()),
subject: payload.subject,
content: payload.content?.map((item) => item.value),
useCase: payload.personalizations.map((item) => item.custom_args?.use_case ?? ""),
};
return createHash("sha256").update(JSON.stringify(stableEnvelope)).digest("hex").slice(0, 32);
}
async function sendWithRetry(payload, idempotencyKey) {
const log = await readJsonLog();
const previous = log[idempotencyKey];
if (previous?.status === "accepted") {
console.log(`Already accepted by SendGrid. idempotencyKey=${idempotencyKey}`);
return;
}
if (previous?.status === "pending") {
throw new Error(`A send is already pending. idempotencyKey=${idempotencyKey}`);
}
if (DRY_RUN) {
log[idempotencyKey] = {
status: "dry-run",
updatedAt: new Date().toISOString(),
to: payload.personalizations.map((item) => item.to[0].email),
};
await writeJsonLog(log);
console.log("Dry run only. Add --send to call SendGrid.");
console.log(JSON.stringify({ idempotencyKey, payload }, null, 2));
return;
}
const apiKey = process.env.SENDGRID_API_KEY;
if (!apiKey) {
throw new Error("SENDGRID_API_KEY is required when using --send.");
}
log[idempotencyKey] = { status: "pending", updatedAt: new Date().toISOString() };
await writeJsonLog(log);
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const responseBody = await response.text();
const providerMessageId = response.headers.get("x-message-id");
if (response.status === 202) {
log[idempotencyKey] = {
status: "accepted",
statusCode: response.status,
providerMessageId,
updatedAt: new Date().toISOString(),
};
await writeJsonLog(log);
console.log(`Accepted by SendGrid. idempotencyKey=${idempotencyKey}`);
return;
}
const retryable = response.status === 429 || response.status >= 500;
log[idempotencyKey] = {
status: retryable && attempt < MAX_ATTEMPTS ? "retrying" : "failed",
statusCode: response.status,
responseBody: responseBody.slice(0, 2000),
attempt,
updatedAt: new Date().toISOString(),
};
await writeJsonLog(log);
if (!retryable || attempt === MAX_ATTEMPTS) {
throw new Error(`SendGrid request failed with HTTP ${response.status}: ${responseBody}`);
}
await sleep(Math.min(1000 * 2 ** (attempt - 1), 8000));
}
}
async function readJsonLog() {
if (!existsSync(LOG_PATH)) return {};
return JSON.parse(await readFile(LOG_PATH, "utf8"));
}
async function writeJsonLog(log) {
await writeFile(LOG_PATH, `${JSON.stringify(log, null, 2)}\n`, "utf8");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
実行例です。まずはdry-runでJSONとログだけを確認します。
node .\sendgrid-safe-send.mjs
$env:SENDGRID_API_KEY="SG.xxxxx"
$env:MAIL_FROM="verified@example.com"
$env:MAIL_TO="you@example.net"
node .\sendgrid-safe-send.mjs --send --sandbox
node .\sendgrid-safe-send.mjs --send
macOSやLinuxなら次の形でも動きます。
SENDGRID_API_KEY="SG.xxxxx" MAIL_FROM="verified@example.com" MAIL_TO="you@example.net" node sendgrid-safe-send.mjs --send --sandbox
このサンプルのローカルログは学習用です。本番ではPostgreSQL、Redis、SQS、Cloud Tasksなどに置き換え、idempotency_keyへ一意制約を付けます。SendGridのAPI呼び出し自体に完全な二重送信防止を期待せず、自社側のジョブIDで守るのが現実的です。
Claude Codeに頼むときのプロンプト
実装を依頼するときは、次のようにファイル範囲と失敗条件を先に書きます。
このリポジトリにSendGridメール送信を追加してください。
目的は問い合わせフォーム控えメール、登録後オンボーディング、日次レポート、営業フォローです。
制約:
- SendGrid Mail Send API v3を使う
- APIキーはサーバー側の環境変数SENDGRID_API_KEYだけから読む
- 何も指定しない実行はdry-runにする
- 1 personalizationにつき宛先は1人だけにし、受信者一覧を露出しない
- 429と5xxだけ指数バックオフで再試行する
- 送信前にunsubscribe、bounce、spam complaintの抑止リストを確認する
- provider response、HTTP status、x-message-id、idempotency keyを保存する
- outreachメールには配信停止リンクと会社情報を入れる
- 公式ドキュメントへのリンクをREADMEに残す
まず設計表と変更予定ファイルだけを出してください。承認後に編集してください。
Claude Codeに「コードを書いて」ではなく「送信後の運用まで含めて設計して」と頼むのがコツです。SendGridでは、202が返っても「受信箱に届いた」という意味ではなく、SendGridがリクエストを受け付けたという段階です。後続のイベント、バウンス、ブロック、スパム報告、配信停止を見ないと運用判断はできません。
よくある失敗と防ぎ方
失敗例を先に潰すだけで、メール機能の安全性はかなり上がります。
| 失敗 | 起きること | 対策 |
|---|---|---|
| APIキーをGitHubへpushする | 第三者に大量送信され、アカウント停止や信用低下につながる | .envをignoreし、漏洩時は即ローテーションする |
| 未認証の送信元を使う | 400系エラー、迷惑メール判定、到達率低下が起きる | Single SenderかDomain Authenticationを完了する |
| リトライで二重送信する | 同じ請求、同じレポート、同じ営業メールが複数届く | 自社側のsend logとidempotency keyで受け付け済みを止める |
| アウトリーチに解除方法がない | 苦情、迷惑メール報告、法令リスクが増える | オプトアウト、会社情報、送信理由を本文に入れる |
| 最初から大量送信する | rate limit、ブロック、ドメイン評価低下が起きる | 少量から増やし、バウンス率と苦情率を見る |
| provider responseを保存しない | 障害時に何が起きたか追えない | HTTP status、本文、x-message-id、宛先ハッシュを保存する |
| 複数宛先を1通にまとめる | 他の受信者のメールアドレスが見える | 1 personalization 1 recipientを原則にする |
SendGridのValidation Errorは、fromの形式、personalizations、content、テンプレート、宛先数などの不備で起きます。エラーレスポンスをただログに捨てるのではなく、運用者が読める形で保存してください。個人情報を含む場合は、ログの保存期間やマスクも決めておきます。
配信品質とログを見る
到達率はコードだけでは決まりません。送信元認証、件名、本文、送信頻度、受信者の同意、過去のバウンス、迷惑メール報告、ドメイン評価が全部効きます。
本番で最低限見たい指標は、送信数、accepted数、bounce数、blocked数、spam report数、unsubscribe数、クリックや返信などの反応です。営業やマーケティングでは、解除率が上がったら本文より先に「誰に送っているか」と「送る理由が相手に伝わっているか」を見直します。
ClaudeCodeLabで実務に入れるなら、Claude Code研修・導入相談で、SendGrid実装だけでなく、環境変数、レビュー観点、ログ設計、配信停止、CIでの秘密情報チェックまで一緒に設計できます。個人でまず型を固めたい場合は、無料リソースや教材一覧から、プロンプトとチェックリストを手元に置くのが早いです。
この記事で紹介した内容を実際に試した結果
Masaがこのサンプルを手元で検証したとき、最も効果があったのはdry-runを初期値にしたことでした。MAIL_FROMを未検証のまま--sendすると即座に止まり、--sandboxではSendGrid側の検証だけを先に確認できます。ローカルの送信ログは簡易版ですが、二重送信の再現テストには十分でした。実案件ではここをDBの一意制約とキューに置き換え、bounce、spam complaint、unsubscribeを送信前チェックに戻す構成にするのが安全だと確認しました。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。