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

SendGridのメール送信をNodeで実装:到達率を上げるSPF/DKIMと二重送信対策

SendGrid Mail Send APIでメールを送るNodeコードを公開。APIキー設定、動的テンプレート、SPF/DKIMで到達率を上げる設定、バウンス処理まで僕の失敗談つきで。

SendGridのメール送信をNodeで実装:到達率を上げるSPF/DKIMと二重送信対策

「登録完了メール、送れてますよね?」

サービスを出して三日目、ユーザーからそう聞かれて血の気が引きました。コードはちゃんと書いた。SendGridのAPIも 202 を返している。なのに、相手の受信箱には一通も届いていない。全部まとめて迷惑メールフォルダに沈んでいたんです。

原因は、送信ドメインの認証(SPF/DKIM)をすっ飛ばしていたこと。メールは「送った」と「届いた」がまったくの別物で、僕はそこを完全になめていました。

この記事は、その失敗から学んだことの全部です。SendGridでメールを送るNodeコードはもちろん、迷惑メール送りを避ける到達率の設定、バウンス(不達)の扱い、同じメールを二回送ってしまう事故の防ぎ方まで、手元で動かしたものだけを書きます。

この記事の要点

  • SendGridのメール送信は POST https://api.sendgrid.com/v3/mail/send にJSONを投げるだけ。難しいのはコードじゃなく「届かせる設定」のほう。
  • 到達率は 送信ドメイン認証(SPF/DKIM) でほぼ決まる。Single Senderは検証用、本番はDomain Authentication一択。
  • APIが返す 202 は「受け付けた」であって「届いた」ではない。バウンスとスパム報告は別途イベントで追う。
  • リトライは二重送信の温床。idempotency key(同じ内容なら一度きり、を保証する鍵)を自分で持って守る。
  • 動的テンプレートを使うと、件名や本文をコードから剥がしてSendGrid側で管理できる。差し込み変数は dynamic_template_data で渡す。

SendGridのメール送信は、実は一行で終わる

まず、難しく考えすぎないでほしいんです。SendGridでメールを送る、その核心はこれだけです。

https://api.sendgrid.com/v3/mail/send に、宛先・送信元・件名・本文を入れたJSONを POST する。認証はヘッダーに Authorization: Bearer SENDGRID_API_KEY を付ける。以上。

APIとしては拍子抜けするくらい単純です。じゃあ何が大変なのか。送ったメールを、ちゃんと相手の受信箱に届かせること、これに尽きます。僕が事故ったのもここでした。

本番で送り始める前に、最低限おさえる前提を表にしておきます。コードを書く前にこっちを片付けるのが、遠回りに見えていちばん速いです。

項目かみくだくと確認すること
Verified Sender「このアドレスから送っていい」とSendGridが認めた送信元検証用はSingle Sender、本番はDomain Authentication
Domain AuthenticationSPF/DKIMのDNS設定で「自社ドメインから送ってる」と証明する仕組みDNS反映後、SendGrid側で検証済み表示になるか
API KeyAPIを叩くための秘密鍵サーバーの環境変数だけに置く。Gitにもブラウザにも出さない
personalizations宛先ごとに件名・名前・変数を変える配列1人ずつ分け、他人のアドレスを見せない
suppressionバウンス・スパム報告・配信停止で「送らない」宛先リスト送信前に自社DBでも除外し、結果も記録
response log返ってきたHTTPステータスや x-message-id不達調査・二重送信防止・問い合わせ対応に残す

SendGridの製品全体は公式サイトで、送信APIの仕様はMail Send API v3で確認できます。本番ドメインで送るなら、メール送信コードを書く前に送信元認証を終わらせてください。未認証の from で送ると、APIの検証エラーになるか、通っても到達率がガタガタになります。

到達率はSPF/DKIMで決まる:迷惑メール送りを避ける

僕の事故の話に戻ります。送信ドメイン認証は、おまじないじゃなく到達率の土台です。受信側のサーバー(Gmailなど)は、知らないところから来たメールを強く疑います。その疑いを晴らす身分証明が、SPFとDKIMです。

  • SPF:「このサーバーから自社ドメインのメールを送っていい」というDNS上の許可リスト。差出人のなりすましを防ぐ。
  • DKIM:「本文が途中で改ざんされていない」と示す電子署名。SendGridが鍵で署名し、受信側がDNSの公開鍵で検証する。
  • DMARC:SPFやDKIMに失敗したメールを、受信側がどう扱うか(拒否/隔離/素通し)を差出人が宣言する方針。

最初は「送信元の身分証明を3点セットで固めるんだな」くらいの理解で十分です。SendGridでは Settings → Sender Authentication → Domain Authentication から進めると、登録すべきCNAMEレコードが提示されます。それを自分のDNS(お名前.com、Cloudflare、Route 53など)に登録し、反映後にSendGrid側で Verified になれば完了です。詳しい手順は公式のDomain Authentication解説にあります。

検証用の @gmail.com などからのSingle Senderでも送信自体はできますが、独自ドメインの到達率は出ません。本番でユーザーに届けるなら、必ずDomain Authenticationまでやってください。ここをやるかやらないかで、受信箱に入るか迷惑メールに沈むかが変わります。僕は身をもって学びました。

到達率を底上げする小ワザも添えておきます。

  1. 件名と本文に煽り表現を詰めない。「!!!」「無料」「今すぐ」の多用はスコアを下げます。
  2. text/plainとtext/htmlを両方入れる。HTMLだけだと評価が下がりやすい。
  3. いきなり大量送信しない。少量から増やし、バウンス率と苦情率を見ながらドメインの評価(ウォームアップ)を育てる。
  4. 配信停止リンクを用意する。スパム報告が減り、結果的にドメイン評価が守られます。

使いどころを4つに分ける

「メールを送る機能」とひとくくりにすると事故ります。目的によって、許可の要不要・送る頻度・本文・止め方・ログの細かさが全部違うからです。僕は最初これを一本のコードに詰め込んで、レポートメールに配信停止リンクが付くという珍妙な状態を作りました。

ユースケース注意点
問い合わせフォームの控え送信者へ受付内容、運営へ通知フォーム入力値をHTMLへ直挿ししない。管理者宛てとユーザー宛てを分ける
トランザクション系登録完了、初回ログイン手順、購入後の案内本人が待っているメール。宣伝を混ぜすぎない
日次レポート売上・エラー・予約・進捗のまとめ再送しても二重集計に見えない件名と冪等キーを持つ
営業・アウトリーチ資料送付、商談後フォロー、休眠掘り起こし同意・接点・配信停止・会社情報など法令面の確認が必要

特に営業メールは、技術的に送れること送っていいことがまったくの別問題です。国や地域、相手との関係、B2BかB2Cか、既存顧客かどうかで条件が変わります。この記事は実装の話であって、法務判断の代わりにはなりません。アウトリーチでは、最低でも配信停止の方法を本文に入れ、解除済みの宛先へ二度と送らない仕組みを必ず持ってください。

メール送信の基礎全体はメール自動化ガイドに、SMS通知をやりたい場合はTwilio SMS連携に、APIキーの安全な扱いは秘密情報を漏らさない手順にまとめてあります。あわせて読むと全体像がつかめます。

コピペで動くNode送信スクリプト

ここからが本題のコードです。Node.js 20以上で、追加パッケージなしで動きます。事故防止のため、何も付けずに実行するとdry-run(送信せずJSONとログだけ確認)になります。実際に送るときだけ --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");    // SendGrid側の検証のみ
const MAX_ATTEMPTS = Number.parseInt(process.env.SENDGRID_MAX_ATTEMPTS ?? "3", 10);

const recipient = {
  email: process.env.MAIL_TO ?? "[email protected]",
  name: process.env.MAIL_TO_NAME ?? "Test Recipient",
};

const message = {
  from: {
    email: process.env.MAIL_FROM ?? "[email protected]",
    name: process.env.MAIL_FROM_NAME ?? "ClaudeCodeLab Demo",
  },
  reply_to: {
    email: process.env.MAIL_REPLY_TO ?? process.env.MAIL_FROM ?? "[email protected]",
  },
  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: [
    // text/plain と text/html の両方を入れると到達率が安定する
    {
      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 p of message.personalizations) {
  p.custom_args = { ...(p.custom_args ?? {}), idempotency_key: idempotencyKey };
}

await sendWithRetry(message, idempotencyKey);

// --- 送信前のチェック:壊れたメールをSendGridに届く前に止める ---
function validatePayload(payload) {
  if (!Number.isInteger(MAX_ATTEMPTS) || MAX_ATTEMPTS < 1 || MAX_ATTEMPTS > 5) {
    throw new Error("SENDGRID_MAX_ATTEMPTS は 1〜5 の整数にしてください。");
  }
  assertEmail(payload.from?.email, "from.email");
  if (!DRY_RUN && payload.from.email.endsWith("@example.com")) {
    throw new Error("--send の前に MAIL_FROM を検証済みの送信元へ変えてください。");
  }
  if (!Array.isArray(payload.personalizations) || payload.personalizations.length === 0) {
    throw new Error("personalizations に宛先が1件もありません。");
  }
  for (const [i, p] of payload.personalizations.entries()) {
    if (!Array.isArray(p.to) || p.to.length !== 1) {
      throw new Error(`personalizations[${i}].to は宛先1人だけにしてください。`);
    }
    assertEmail(p.to[0]?.email, `personalizations[${i}].to[0].email`);
  }
  if (!payload.subject && !payload.template_id) {
    throw new Error("subject か 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("text/html などの本文か template_id を入れてください。");
  }
}

function assertEmail(value, field) {
  if (typeof value !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
    throw new Error(`${field} が正しいメールアドレスではありません。`);
  }
}

// 同じ内容なら同じ鍵になる → 二重送信を見分ける
function makeIdempotencyKey(payload) {
  const envelope = {
    from: payload.from.email.toLowerCase(),
    to: payload.personalizations.map((p) => p.to[0].email.toLowerCase()),
    subject: payload.subject,
    content: payload.content?.map((item) => item.value),
    useCase: payload.personalizations.map((p) => p.custom_args?.use_case ?? ""),
  };
  return createHash("sha256").update(JSON.stringify(envelope)).digest("hex").slice(0, 32);
}

async function sendWithRetry(payload, key) {
  const log = await readJsonLog();
  const previous = log[key];

  if (previous?.status === "accepted") {
    console.log(`既にSendGridが受け付け済み。idempotencyKey=${key}`);
    return;
  }
  if (previous?.status === "pending") {
    throw new Error(`送信処理が進行中です。idempotencyKey=${key}`);
  }

  if (DRY_RUN) {
    log[key] = {
      status: "dry-run",
      updatedAt: new Date().toISOString(),
      to: payload.personalizations.map((p) => p.to[0].email),
    };
    await writeJsonLog(log);
    console.log("dry-run のみ。実送信は --send を付けてください。");
    console.log(JSON.stringify({ idempotencyKey: key, payload }, null, 2));
    return;
  }

  const apiKey = process.env.SENDGRID_API_KEY;
  if (!apiKey) throw new Error("--send には SENDGRID_API_KEY が必要です。");

  log[key] = { 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 body = await response.text();
    const providerMessageId = response.headers.get("x-message-id");

    if (response.status === 202) {
      log[key] = {
        status: "accepted",
        statusCode: 202,
        providerMessageId, // 後でイベント追跡に使う
        updatedAt: new Date().toISOString(),
      };
      await writeJsonLog(log);
      console.log(`SendGridが受け付けました。idempotencyKey=${key}`);
      return;
    }

    // 429(流量制限)と5xx(サーバー側)だけ再試行する
    const retryable = response.status === 429 || response.status >= 500;
    log[key] = {
      status: retryable && attempt < MAX_ATTEMPTS ? "retrying" : "failed",
      statusCode: response.status,
      responseBody: body.slice(0, 2000),
      attempt,
      updatedAt: new Date().toISOString(),
    };
    await writeJsonLog(log);

    if (!retryable || attempt === MAX_ATTEMPTS) {
      throw new Error(`SendGrid がHTTP ${response.status} を返しました: ${body}`);
    }
    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));
}

// フォーム入力をHTMLに直挿しすると壊れる/危ない → 必ず無害化する
function escapeHtml(value) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

実行はこんな流れです。まずdry-runでJSONとログだけ眺め、次にsandboxで検証だけ通し、最後に本送信、という順番が安全です。

# 1) 送らずに中身を確認
node .\sendgrid-safe-send.mjs

# 2) SendGrid側の検証だけ通す(受信箱には届かない)
$env:SENDGRID_API_KEY="SG.xxxxx"
$env:MAIL_FROM="[email protected]"
$env:MAIL_TO="[email protected]"
node .\sendgrid-safe-send.mjs --send --sandbox

# 3) 本当に送る
node .\sendgrid-safe-send.mjs --send

macOSやLinuxなら一行でも書けます。

SENDGRID_API_KEY="SG.xxxxx" MAIL_FROM="[email protected]" MAIL_TO="[email protected]" node sendgrid-safe-send.mjs --send --sandbox

このサンプルのログはローカルのJSONファイルで、あくまで学習用です。本番ではPostgreSQLやRedis、SQS、Cloud Tasksに置き換え、idempotency_key にDBの一意制約を付けてください。SendGridのAPI呼び出し自体に完全な二重送信防止を期待せず、自社のジョブIDで守るのが現実的です。

動的テンプレートで件名と本文をコードから剥がす

メールの本文をコードに直書きすると、文言を直すたびにデプロイが必要になります。SendGridの**動的テンプレート(Dynamic Templates)**を使うと、件名やHTMLをSendGrid側のダッシュボードで管理でき、コードからは差し込みデータだけを渡せます。マーケ担当が文言を直せるようになるのが地味に効きます。

ダッシュボードの Email API → Dynamic Templates でテンプレートを作り、{{name}}{{order_id}} のように差し込み箇所を埋め込みます。生成された d- で始まるテンプレートIDを控えたら、コード側はこう書きます。

// sendgrid-template-send.mjs(動的テンプレート版の中核)
const message = {
  from: { email: process.env.MAIL_FROM, name: "ClaudeCodeLab" },
  personalizations: [
    {
      to: [{ email: process.env.MAIL_TO }],
      // テンプレート内の {{name}} {{order_id}} に差し込まれる
      dynamic_template_data: {
        name: "中道",
        order_id: "A-10293",
        total: "¥4,980",
      },
    },
  ],
  template_id: process.env.SENDGRID_TEMPLATE_ID, // "d-xxxxxxxx..."
};

const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(message),
});

// 202 なら受付OK。それ以外は本文にエラー理由が入る
console.log(response.status, await response.text());

ポイントは、template_id を使うときは subjectcontent を省ける点です(テンプレート側に持たせるため)。差し込み変数は必ず dynamic_template_data で渡します。テンプレートの細かい構文は公式のDynamic Templatesドキュメントを見てください。

202が返っても安心しない:バウンスとエラー処理

ここが、僕が二度目にハマったところです。SendGridのAPIが 202 を返すと、つい「送れた!」と思います。でも 202 の意味は 「SendGridがリクエストを受け付けた」 だけ。受信箱に届いたかは、まだ何もわかっていません。

その後に起きることを追わないと、運用判断ができません。

  • バウンス(bounce):宛先が存在しない、受信拒否などで届かなかった。
  • ブロック(block):受信側がドメイン評価などで弾いた。
  • スパム報告(spam report):受信者が「迷惑メール」を押した。これが増えるとドメイン評価が落ちる。
  • 配信停止(unsubscribe):購読解除。以後は送ってはいけない。

これらは送信レスポンスではなく、Event Webhook で後から飛んできます。SendGridの Settings → Mail Settings → Event Webhook に自分のエンドポイントURLを登録すると、配信結果がPOSTで届きます。最小の受け口はこれだけです。

// webhook.mjs(SendGridのイベント受け口・最小版)
import http from "node:http";

http.createServer((req, res) => {
  if (req.method !== "POST") { res.writeHead(405).end(); return; }
  let raw = "";
  req.on("data", (chunk) => { raw += chunk; });
  req.on("end", () => {
    const events = JSON.parse(raw); // 配列で複数イベントが届く
    for (const e of events) {
      // bounce / dropped / spamreport は送信抑止リストへ入れる
      if (["bounce", "dropped", "spamreport", "unsubscribe"].includes(e.event)) {
        console.log(`抑止対象: ${e.email} (${e.event}) reason=${e.reason ?? "-"}`);
        // ここで自社DBの suppression テーブルに upsert する
      }
    }
    res.writeHead(200).end("ok");
  });
}).listen(3000, () => console.log("listening on :3000"));

大事なのは、バウンスやスパム報告が来た宛先を 自社の抑止リスト(suppression)に積み、次の送信前に必ず除外する ことです。バウンスし続ける宛先に送り続けると、ドメイン評価が下がって、まともな宛先にも届かなくなります。Webhookの仕様とイベントの種類は公式のEvent Webhookリファレンスにあります。

エラーレスポンスの扱いもひとこと。SendGridのValidation Errorは、from の形式、personalizationscontent、テンプレート、宛先数の不備で起きます。返ってきたエラー本文をログに捨てるのではなく、運用者が読める形で残してください。個人情報を含むなら、保存期間やマスクも先に決めておきます。

よくある質問

Q. SendGridの無料枠でどこまでできますか? A. 無料プランでも日次の送信上限の範囲で本番送信できます(上限はプランで変わるので公式の料金ページで確認を)。Domain Authenticationや動的テンプレート、Event Webhookも無料枠で使えます。まず型を固めてから有料に上げるのが堅いです。

Q. SPF/DKIMを設定したのに迷惑メールに入ります。 A. 認証は前提条件であって、それだけでは満点になりません。件名の煽り、HTMLのみの本文、新しいドメインの低い評価、過去のバウンス放置などが重なります。少量から送って評価を育て、text/plainも入れ、配信停止リンクを付けてください。

Q. @gmail.com を送信元にして送れますか? A. Single Senderで検証すれば送れますが、本番では避けてください。GmailやYahooは自ドメインを騙る外部送信を厳しく扱います。独自ドメインでDomain Authenticationを通すのが正解です。

Q. 同じメールが二回届く事故はどう防ぎますか? A. リトライと再実行が原因の大半です。上のコードのように内容から idempotency key を作り、自社DBの一意制約で「受付済みなら送らない」を保証してください。SendGrid任せにしないのがコツです。

Q. APIキーが漏れたらどうすれば? A. すぐにSendGridダッシュボードで該当キーを失効させ、新しいキーを発行して環境変数を差し替えます。漏れの起点を断つ予防策は秘密情報を漏らさない手順にまとめました。Gitに上げない、これだけは死守してください。

実際に試した結果

このサンプルを手元で何度も回して、いちばん効いたのは dry-runを既定値にしたこと でした。MAIL_FROM を未検証のまま --send すると、SendGridに届く前にコードが止まる。--sandbox を挟めば、本送信せずにSendGrid側の検証だけ先に通せる。この二段構えがあるだけで、テスト中の誤送信がゼロになりました。

到達率のほうは、Domain Authenticationを通した瞬間に体感が変わりました。Single Senderのときは半分くらい迷惑メールに沈んでいたのが、SPF/DKIMを固めたら受信箱に素直に入るようになった。あの登録完了メール事件の原因は、結局これ一個だったわけです。

ローカルのJSONログは簡易版ですが、二重送信の再現テストには十分でした。実案件ではここをDBの一意制約とキューに置き換え、バウンス・スパム報告・配信停止をWebhookで拾って送信前チェックに戻す。この形にすれば、メールが「送れたつもり」で終わる事故はほぼ消えます。

仕事で本格的に組み込むなら、環境変数・レビュー観点・ログ設計・配信停止・CIでの秘密情報チェックまで含めてClaude Code研修・導入相談で一緒に設計できます。まず自分で型を固めたい人は、教材一覧のプロンプトとチェックリストを手元に置くのが早いです。

#Claude Code #SendGrid #メール送信 #到達率 #Node.js
無料

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

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

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

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

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

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

PR

関連書籍・参考図書

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

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