Use Cases (更新: 2026/6/2)

Claude CodeでSendGridメール送信を安全に実装する方法

SendGridの設定、Mail Send API、失敗対策までClaude Codeで安全に実装する実践ガイド。

Claude CodeでSendGridメール送信を安全に実装する方法

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 AuthenticationSPF/DKIMなどのDNS設定で自社ドメイン送信を証明する仕組みDNS反映後にSendGrid側で検証済みか確認する
API KeySendGrid APIを呼ぶための秘密鍵サーバー側の環境変数だけに置き、Gitやブラウザへ出さない
personalizations宛先ごとに件名、名前、変数、メタデータを変える配列1人ずつ分け、CC的に受信者一覧を見せない
suppressionバウンス、迷惑メール報告、購読解除などで送らない宛先リスト送信前に自社DB側でも除外し、SendGridの結果も記録する
response logSendGridから返った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("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

実行例です。まずは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の形式、personalizationscontent、テンプレート、宛先数などの不備で起きます。エラーレスポンスをただログに捨てるのではなく、運用者が読める形で保存してください。個人情報を含む場合は、ログの保存期間やマスクも決めておきます。

配信品質とログを見る

到達率はコードだけでは決まりません。送信元認証、件名、本文、送信頻度、受信者の同意、過去のバウンス、迷惑メール報告、ドメイン評価が全部効きます。

本番で最低限見たい指標は、送信数、accepted数、bounce数、blocked数、spam report数、unsubscribe数、クリックや返信などの反応です。営業やマーケティングでは、解除率が上がったら本文より先に「誰に送っているか」と「送る理由が相手に伝わっているか」を見直します。

ClaudeCodeLabで実務に入れるなら、Claude Code研修・導入相談で、SendGrid実装だけでなく、環境変数、レビュー観点、ログ設計、配信停止、CIでの秘密情報チェックまで一緒に設計できます。個人でまず型を固めたい場合は、無料リソース教材一覧から、プロンプトとチェックリストを手元に置くのが早いです。

この記事で紹介した内容を実際に試した結果

Masaがこのサンプルを手元で検証したとき、最も効果があったのはdry-runを初期値にしたことでした。MAIL_FROMを未検証のまま--sendすると即座に止まり、--sandboxではSendGrid側の検証だけを先に確認できます。ローカルの送信ログは簡易版ですが、二重送信の再現テストには十分でした。実案件ではここをDBの一意制約とキューに置き換え、bounce、spam complaint、unsubscribeを送信前チェックに戻す構成にするのが安全だと確認しました。

#Claude Code #SendGrid #メール #API #自動化
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。