用Claude Code安全实现SendGrid邮件发送
用Claude Code安全实现SendGrid邮件:发送方认证、Mail Send API、重试、日志与送达率。
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 Sender | SendGrid确认这个from地址可以发信 | 小测试可用Single Sender,生产建议做Domain Authentication |
| Domain Authentication | 用DNS证明你的域名可以通过SendGrid发信 | SPF/DKIM记录验证完成后再上线 |
| API Key | 服务端调用SendGrid的秘密凭证 | 只放在服务端环境变量里,泄漏后立即轮换 |
personalizations | 每个收件人的地址、主题、变量或自定义参数 | 一个personalization只放一个收件人,避免暴露名单 |
| suppression | 因退信、投诉或退订而不能再发送的地址 | 调SendGrid前先查自己的抑制列表 |
| provider log | SendGrid返回的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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
先运行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事件回写到发送前检查里。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。