Claude Code 邮件自动化:从线索获取到变现的 Node.js 实战
用Claude Code搭建邮件自动化:线索磁铁、同意、退订、重试、分析和变现闭环。
邮件自动化不是“表单提交后发一封邮件”这么简单。真正能带来收入的系统,要能发送线索磁铁,启动新用户引导,跟进咨询请求,保存同意记录,处理退订,停止给硬退信地址发送,遇到临时失败时排队重试,并且把邮件点击和产品、培训、咨询转化连起来。
Claude Code适合这个任务,因为邮件功能会同时影响schema、模板、发送适配器、后台任务、webhook、分析事件和README。Masa在重做本站免费PDF漏斗时,最初只让Claude Code写Resend发送函数,结果同意记录、退订URL、退信处理和CTA分析都变成后补。代码能跑,但运营风险很高。更好的做法是先让Claude Code产出设计表和文件清单,再限制它只编辑相关文件。
本文会用Node.js/TypeScript实现一个不绑定厂商的邮件自动化基础架构,可切换Resend风格API或SendGrid风格API。内容覆盖线索磁铁投递、onboarding序列、咨询跟进、SPF/DKIM/DMARC基础、合规边界、限速、队列重试、模板和分析。相关的收入路径可以继续阅读内容漏斗审计、分析实现和Cookie/同意管理。
先设计邮件系统
先把邮件类型分清。线索磁铁是用邮箱换取的免费PDF、清单或模板。Onboarding是注册、购买或参加课程后,帮助用户开始使用的连续邮件。咨询跟进是对一次咨询、会议或询盘的业务回复,用来发送纪要、下一步、报价或预约链接。
不要把这些都塞进一个“newsletter”标签里。它们的同意方式、指标、语气、退订处理和商业目标都不同。
| 目标 | 收件人 | 邮件例子 | 变现路径 | 要控制的风险 |
|---|---|---|---|---|
| 线索获取 | 申请PDF的读者 | 下载链接和相关文章 | 免费PDF到产品 | 保存同意和退订URL |
| Onboarding | 购买者或课程学员 | 入门步骤、清单、常见卡点 | 模板、课程、追加支持 | 不要把收据写成强销售邮件 |
| 咨询跟进 | 已联系的潜在客户 | 会议纪要、提案、下一次预约 | 培训和咨询 | 必须结合真实对话 |
| 休眠线索再激活 | 已同意但长期无互动的读者 | 失败案例、重大更新、实用资源 | 产品或咨询 | 监控频率、退订和退信 |
几个术语先用白话讲清楚。SPF是在DNS里说明“哪些服务器可以代表这个域名发信”。DKIM是在邮件里加签名,让收件方验证邮件确实被授权且没有被改。DMARC是在SPF或DKIM对齐失败时,告诉收件方应该如何处理。Bounce是邮件没送达而退回。Rate limit是发送太快、额度触顶或信誉不足时,服务商临时限制请求。
涉及送达率和认证时,以官方文档为准。发往Gmail时看Google邮件发送者指南。Resend看Resend域名管理,SendGrid看Twilio SendGrid域名认证。DMARC在2026年由RFC 9989更新并取代旧RFC 7489。面向美国商业邮件时,还要参考FTC的CAN-SPAM指南。本文是工程实现说明,不替代法律意见。
flowchart LR
Visitor["文章读者"]
Form["线索表单"]
Consent["同意记录"]
Queue["邮件队列"]
Provider["Resend / SendGrid"]
Inbox["收件箱"]
Webhook["投递事件"]
Analytics["分析"]
Offer["产品 / 培训 / 咨询"]
Visitor --> Form --> Consent --> Queue --> Provider --> Inbox
Provider --> Webhook --> Analytics --> Offer
Inbox --> Offer
给Claude Code的提示词
不要只说“实现邮件自动化”。要把业务目标、允许修改的文件、服务商边界、同意规则、重试行为和验收条件写清楚。
在这个仓库中实现邮件自动化。
目标是线索磁铁投递、3封onboarding序列和咨询跟进。
约束:
- 使用Node.js 20+和TypeScript。
- 创建可切换Resend风格和SendGrid风格API的provider adapter。
- API key只允许在服务端环境变量中使用,不能暴露给浏览器。
- 为lead, email job, unsubscribe, provider event创建schema。
- 对429和5xx使用指数退避重试。
- 不给已退订、投诉或被suppression的地址发送。
- 多次hard bounce的地址进入suppression list。
- 每封邮件包含text版、HTML版、退订URL和清晰的发送方信息。
- README中保留官方服务商和邮件认证文档链接。
- 添加可执行script和聚焦测试。
请先返回设计表和文件清单,等我批准后再编辑。
可复制的最小实现
下面的示例用本地JSON文件作为演示队列,方便直接运行。本番环境应替换为Postgres、Redis、SQS、Cloud Tasks等支持锁和审计日志的队列。
{
"type": "module",
"scripts": {
"lead:send": "tsx scripts/send-lead-magnet.ts",
"email:worker": "tsx scripts/email-worker.ts"
},
"dependencies": {
"zod": "latest"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
}
}
// src/email/schema.ts
import { z } from "zod";
export const leadSchema = z.object({
email: z.string().email(),
name: z.string().trim().min(1).max(80),
locale: z.enum(["ja", "en", "zh", "ko", "es", "fr", "de", "pt", "hi", "id"]).default("zh"),
source: z.enum(["article", "product", "workshop", "consultation"]),
consentAt: z.string().datetime(),
tags: z.array(z.string()).default([]),
});
export const sendMessageSchema = z.object({
to: z.string().email(),
from: z.string().email(),
fromName: z.string().min(1),
replyTo: z.string().email().optional(),
subject: z.string().min(1).max(120),
text: z.string().min(1),
html: z.string().min(1),
unsubscribeUrl: z.string().url(),
category: z.enum(["lead_magnet", "onboarding", "consultation_followup"]),
metadata: z.record(z.string()).default({}),
});
export const emailJobSchema = z.object({
message: sendMessageSchema,
maxAttempts: z.number().int().min(1).max(8).default(4),
});
export type Lead = z.infer<typeof leadSchema>;
export type SendMessage = z.infer<typeof sendMessageSchema>;
export type EmailJobInput = z.infer<typeof emailJobSchema>;
// src/email/provider.ts
import { randomUUID } from "node:crypto";
import type { SendMessage } from "./schema";
type SendResult = { providerMessageId: string; acceptedAt: string };
export interface EmailProvider { send(message: SendMessage): Promise<SendResult>; }
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing env: ${name}`);
return value;
}
async function parseProviderError(response: Response): Promise<Error> {
const body = await response.text().catch(() => "");
const retryable = response.status === 429 || response.status >= 500;
const error = new Error(`Email provider error ${response.status}: ${body || response.statusText}`);
(error as Error & { retryable?: boolean }).retryable = retryable;
return error;
}
export class ResendProvider implements EmailProvider {
async send(message: SendMessage): Promise<SendResult> {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${requiredEnv("RESEND_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: `${message.fromName} <${message.from}>`,
to: [message.to],
reply_to: message.replyTo,
subject: message.subject,
text: message.text,
html: message.html,
headers: {
"List-Unsubscribe": `<${message.unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
}),
});
if (!response.ok) throw await parseProviderError(response);
const data = (await response.json().catch(() => ({}))) as { id?: string };
return { providerMessageId: data.id ?? randomUUID(), acceptedAt: new Date().toISOString() };
}
}
export class SendGridProvider implements EmailProvider {
async send(message: SendMessage): Promise<SendResult> {
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
method: "POST",
headers: {
Authorization: `Bearer ${requiredEnv("SENDGRID_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
personalizations: [{ to: [{ email: message.to }], custom_args: message.metadata }],
from: { email: message.from, name: message.fromName },
reply_to: message.replyTo ? { email: message.replyTo } : undefined,
subject: message.subject,
content: [
{ type: "text/plain", value: message.text },
{ type: "text/html", value: message.html },
],
headers: {
"List-Unsubscribe": `<${message.unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
}),
});
if (!response.ok) throw await parseProviderError(response);
return { providerMessageId: response.headers.get("x-message-id") ?? randomUUID(), acceptedAt: new Date().toISOString() };
}
}
export function createEmailProvider(): EmailProvider {
return process.env.EMAIL_PROVIDER === "sendgrid" ? new SendGridProvider() : new ResendProvider();
}
// src/email/queue.ts
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { emailJobSchema, type EmailJobInput } from "./schema";
type StoredJob = EmailJobInput & {
id: string;
status: "scheduled" | "processing" | "sent" | "failed";
attempts: number;
nextAttemptAt: string;
lastError?: string;
};
const queueFile = process.env.EMAIL_QUEUE_FILE ?? ".email-queue.json";
async function loadQueue(): Promise<StoredJob[]> {
if (!existsSync(queueFile)) return [];
return JSON.parse(await readFile(queueFile, "utf8")) as StoredJob[];
}
async function saveQueue(jobs: StoredJob[]) {
await writeFile(queueFile, JSON.stringify(jobs, null, 2) + "\n");
}
export async function enqueueEmail(input: EmailJobInput) {
const parsed = emailJobSchema.parse(input);
const jobs = await loadQueue();
const job: StoredJob = {
...parsed,
id: randomUUID(),
status: "scheduled",
attempts: 0,
nextAttemptAt: new Date().toISOString(),
};
jobs.push(job);
await saveQueue(jobs);
return job.id;
}
export async function claimDueJobs(limit = 5): Promise<StoredJob[]> {
const now = Date.now();
const jobs = await loadQueue();
const due = jobs
.filter((job) => job.status === "scheduled" && Date.parse(job.nextAttemptAt) <= now)
.slice(0, limit);
for (const job of due) job.status = "processing";
await saveQueue(jobs);
return due;
}
export async function completeJob(id: string) {
const jobs = await loadQueue();
const job = jobs.find((item) => item.id === id);
if (job) job.status = "sent";
await saveQueue(jobs);
}
export async function failJob(id: string, error: unknown) {
const jobs = await loadQueue();
const job = jobs.find((item) => item.id === id);
if (!job) return;
job.attempts += 1;
job.lastError = error instanceof Error ? error.message : String(error);
if (job.attempts >= job.maxAttempts) {
job.status = "failed";
} else {
const delayMs = Math.min(15 * 60_000, 2 ** job.attempts * 1000);
job.status = "scheduled";
job.nextAttemptAt = new Date(Date.now() + delayMs).toISOString();
}
await saveQueue(jobs);
}
// scripts/email-worker.ts
import { claimDueJobs, completeJob, failJob } from "../src/email/queue";
import { createEmailProvider } from "../src/email/provider";
const provider = createEmailProvider();
const jobs = await claimDueJobs(Number(process.env.EMAIL_WORKER_BATCH ?? 3));
for (const job of jobs) {
try {
const result = await provider.send(job.message);
await completeJob(job.id);
console.log(`sent ${job.id} as ${result.providerMessageId}`);
} catch (error) {
await failJob(job.id, error);
console.error(`failed ${job.id}`, error);
}
}
运行时先只给自己的测试邮箱发送,不要在域名认证和退订路由完成之前给读者群发。
npm install
EMAIL_TO=you@example.com APP_URL=https://example.com npm run lead:send
EMAIL_PROVIDER=resend RESEND_API_KEY=re_xxx npm run email:worker
把退信、退订和分析放进同一套设计
服务商API返回成功,只代表请求被接受,不代表收件人读了邮件、点击了CTA或愿意继续收到邮件。Webhook要先正则化成自己的事件模型。
// src/email/events.ts
import { z } from "zod";
const providerEventSchema = z.object({
provider: z.enum(["resend", "sendgrid", "unknown"]),
type: z.enum(["delivered", "bounce", "complaint", "unsubscribe", "open", "click", "deferred"]),
email: z.string().email().optional(),
providerMessageId: z.string().optional(),
reason: z.string().optional(),
occurredAt: z.string().datetime(),
});
export function normalizeProviderEvent(payload: unknown) {
const raw = payload as Record<string, unknown>;
const type = String(raw.type ?? raw.event ?? "delivered");
const mappedType =
type.includes("bounce") ? "bounce" :
type.includes("complaint") || type.includes("spam") ? "complaint" :
type.includes("unsubscribe") ? "unsubscribe" :
type.includes("click") ? "click" :
type.includes("open") ? "open" :
type.includes("defer") ? "deferred" :
"delivered";
return providerEventSchema.parse({
provider: raw.sg_event_id ? "sendgrid" : raw.created_at ? "resend" : "unknown",
type: mappedType,
email: String(raw.email ?? raw.recipient ?? "") || undefined,
providerMessageId: String(raw.email_id ?? raw.sg_message_id ?? ""),
reason: typeof raw.reason === "string" ? raw.reason : undefined,
occurredAt: new Date(String(raw.created_at ?? Date.now())).toISOString(),
});
}
分析时不要只看打开率。隐私保护和图片拦截会让打开率失真。更可靠的是下载完成、CTA点击、咨询表单开始、回复、退订率、退信率和购买。事件名可以统一成lead_magnet_requested、email_cta_click、consultation_request_started,再接到分析实现里的度量计划。
具体用例
第一个用例是文章底部的免费PDF。读者申请清单后立即发送下载链接,第二封讲最常见的发送配置错误,第三封指向产品模板,第四封邀请培训或咨询。每封只放一个主要动作,并且都带退订链接。
第二个用例是产品购买后的onboarding。购买Gumroad资料或参加工作坊的人,第一封需要帮助开始使用,第二封处理常见卡点,第三封介绍高级用法。不要把收据邮件变成销售轰炸。帮助买家成功,通常比硬推销更能带来复购。
第三个用例是咨询跟进。好的跟进邮件包含会议纪要、已决定事项、下一步、相关链接、截止时间和预约或提案CTA。如果邮件完全不反映对话内容,即使对方填过表单,也会像垃圾邮件。
第四个用例是低频再激活。对于已同意但长期无互动的读者,只发送重大更新、实操失败案例或新资源。如果点击和回复没有恢复,就降低频率或停止。域名信誉比多发一轮活动更重要。
常见失败模式
最危险的是把发送API key放进浏览器代码。邮件发送必须在服务端完成。key泄漏后,攻击者可以借你的账号发送垃圾邮件。
第二个失败是未认证域名就开始发送。不要伪装其他域名的From地址。给自己的域名配置SPF、DKIM和DMARC,并用服务商仪表盘和收件方反馈一起检查。
第三个失败是忽略退订和退信。退订、投诉、hard bounce都要进入suppression list,并从普通营销队列中排除。
第四个失败是遇到限速后立刻重复发送。429和临时5xx要用指数退避,发送节奏要稳定。具体上限会因为账号、套餐、域名信誉和收件方而变化,不要照抄过期数字。
第五个失败是混合事务邮件和促销邮件。密码重置、收据、账号通知应该清晰完成本职工作。产品、培训和咨询CTA要放在用户同意且上下文合理的邮件里。
变现CTA
邮件自动化的完成标准不是“发出去了”,而是读者能自然选择下一步。ClaudeCodeLab的路径可以是:免费PDF给初学者,产品和模板给正在实作的人,培训和咨询给需要团队导入和收入漏斗设计的人。
实际尝试后,最大的改进不是发送函数,而是先设计同意、退订、退信和CTA分析。建议先只上线一封线索磁铁邮件,确认发送、退订、退信和点击都能观察,再扩展到onboarding和咨询跟进。
免费 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 与咨询路径都要可审查。