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

用Claude Code安全实现SendGrid邮件发送

用Claude Code安全实现SendGrid邮件:发送方认证、Mail Send API、重试、日志与送达率。

用Claude Code安全实现SendGrid邮件发送

SendGrid是一个通过API发送应用邮件的云服务。它适合处理联系表单确认信、注册后的引导邮件、每日统计报告、交易通知,以及有明确退订机制的销售跟进邮件。

危险点在于,邮件代码看起来很短,但邮件一旦发出就无法撤回。如果只让Claude Code“用SendGrid发邮件”,通常会得到能调用API的代码,却容易漏掉发送方验证、API密钥保管、重试时重复发送、退信、垃圾邮件投诉、日志保存和退订处理。真正的质量不在fetch这一行,而在发送前后的边界。

本文以Twilio SendGrid官方v3 Mail Send API、SendGrid的Validation Error说明SendGrid官网为基础,给出一份可以复制运行的Node.js脚本。脚本默认dry-run,不会真的发信;只有加上--send才会调用SendGrid;还包含payload验证、sandbox模式、临时错误重试、本地发送日志和简单的幂等保护。

如果你还在整理基础架构,可以同时阅读Claude Code邮件自动化API开发环境变量管理安全最佳实践

写代码前先理解SendGrid

SendGrid的Mail Send接口本身很直接:向POST https://api.sendgrid.com/v3/mail/send发送JSON,并在请求头里放入Authorization: Bearer SENDGRID_API_KEY。但是生产环境需要先确认以下内容。

项目简单解释实装前要确认
Verified SenderSendGrid确认这个from地址可以发信小测试可用Single Sender,生产建议做Domain Authentication
Domain Authentication用DNS证明你的域名可以通过SendGrid发信SPF/DKIM记录验证完成后再上线
API Key服务端调用SendGrid的秘密凭证只放在服务端环境变量里,泄漏后立即轮换
personalizations每个收件人的地址、主题、变量或自定义参数一个personalization只放一个收件人,避免暴露名单
suppression因退信、投诉或退订而不能再发送的地址调SendGrid前先查自己的抑制列表
provider logSendGrid返回的HTTP状态、响应体和x-message-id用于排障、客服查询和防止重复发送

SPF是DNS里的发送许可列表,DKIM是证明邮件未被篡改的签名,DMARC是收件方在SPF或DKIM不匹配时参考的策略。初学者不必一次记住所有术语,但要明白:发件人认证是邮件到达率的身份证明。

不要直接把随机Gmail地址放进from。本地验证可以先用SendGrid的Single Sender;正式环境应该认证自己的域名,再从产品或客服地址发送。很多Validation Error来自无效from、错误的personalizations、缺少content或template配置不正确。

四个常见使用场景

不要把所有邮件都塞进一个sendMail函数。不同场景的同意、频率、失败影响和日志要求都不一样。

使用场景例子必要保护
联系表单邮件给访客的确认信,给团队的通知转义用户输入,管理者和用户邮件分开发送,保存provider response
交易型引导邮件注册完成、首次登录说明、购买后指南内容应符合用户预期,不要把强营销藏进交易邮件
每日报告邮件销售额、错误摘要、预约汇总、学习进度使用幂等key,避免重试后出现重复报告
销售或外联邮件会后资料、提案跟进、休眠客户唤醒提供退订,遵守抑制列表,并确认当地合规要求

外联邮件尤其要谨慎。技术上能发送,不代表业务上或法律上可以发送。不同国家、不同关系、B2B和B2C的要求都可能不同。本文不是法律意见。至少要说明发送理由、提供可用的退订方式,并确保退订或投诉过的人不会再次收到邮件。

flowchart LR
  App["应用 / Claude Code修改"]
  Validate["payload验证"]
  Log["发送日志和幂等key"]
  SendGrid["SendGrid Mail Send API"]
  Inbox["收件箱"]
  Events["退信 / 投诉 / 退订"]
  Suppression["抑制列表"]

  App --> Validate --> Log --> SendGrid --> Inbox
  SendGrid --> Events --> Suppression
  Suppression --> Validate

可复制运行的Node.js脚本

下面的脚本支持Node.js 20以上,不需要额外依赖。默认执行只是dry-run,会打印并记录payload,不会调用SendGrid。要真实调用API时使用--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。在Windows PowerShell里:

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

本地JSON日志只是教学用。生产环境应换成PostgreSQL、Redis、SQS、Cloud Tasks或其他可靠队列,并对idempotency_key加唯一约束。SendGrid收到请求不等于业务上可以重复发,重复控制应该放在你的系统里。

给Claude Code的提示词

不要只要求“写发送代码”。把业务目标和失败边界一起交给Claude Code。

请在这个仓库中加入SendGrid邮件发送。
场景包括联系表单确认、注册后引导、每日报告和销售跟进。

约束:
- 使用SendGrid Mail Send API v3
- API密钥只从服务端环境变量SENDGRID_API_KEY读取
- 默认脚本必须是dry-run,只有传入--send才真实发送
- 每个personalization只允许一个收件人,避免暴露收件人列表
- 只对429和5xx使用指数退避重试
- 发送前检查unsubscribe、bounce和spam complaint抑制列表
- 保存provider response、HTTP status、x-message-id和idempotency key
- 外联邮件必须包含退订或opt-out路径
- README中保留官方SendGrid文档链接

先输出设计表和计划修改文件。等我批准后再编辑。

这类提示词会让Claude Code关注同意、抑制列表、日志和重试,而不是只写一个API调用。它也适合多人并行改仓库,因为Claude Code必须先列出文件范围。

常见失败和预防

失败后果预防
API Key泄漏他人可通过你的账号发信,影响信誉甚至账号状态.env加入ignore,CI做secret扫描,泄漏后立即轮换
未验证sender出现400系错误、拦截或到达率下降先完成Single Sender或Domain Authentication
重试导致重复发送同一报告、收据或销售邮件重复到达在调用provider前使用send log和幂等key
外联没有退订方式投诉、垃圾邮件报告和合规风险增加提供退订、公司信息和发送理由
发送太快触发rate limit,损害域名信誉从小批量开始,观察退信率和投诉率
不保存provider response出问题时无法说明发生了什么保存status、响应体、x-message-id和收件人哈希
收件人列表外露用户看到其他人的邮箱坚持一个personalization一个收件人

SendGrid返回202 Accepted并不代表邮件已进收件箱,只表示SendGrid接受了请求。后续还要看退信、阻挡、投诉和退订事件。日志里若包含个人信息,要提前决定保存期限和脱敏规则。

送达率、日志和CTA

送达率不是只靠DNS决定的。发送方认证、内容是否符合预期、发送频率、退信历史、投诉率和退订体验都会影响结果。上线后至少要看发送数、accepted数、bounce数、blocked数、spam report数和unsubscribe数。

如果你要把邮件流接入ClaudeCodeLab风格的业务漏斗,CTA也要匹配场景。联系表单确认可以指向相关文章;注册引导可以给清单或模板;每日报告应保持操作性;销售跟进只有在关系明确时才适合邀请咨询。团队需要按真实仓库设计时,可以通过Claude Code培训与咨询一起整理SendGrid设置、环境变量、secret扫描、抑制列表和日志设计。

实际验证结果

Masa在本地验证这份脚本时,最有用的设计是默认dry-run。无参数运行只打印payload并写入本地日志;如果MAIL_FROM仍是@example.com,加上--send也会在调用API前停止;--send --sandbox可以先让SendGrid验证请求结构而不投递。真实项目中,本地日志应替换为带唯一约束的数据库队列,并把bounce、spam complaint和unsubscribe事件回写到发送前检查里。

#Claude Code #SendGrid #email #API #automation
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。