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

Claude Code 邮件自动化:从线索获取到变现的 Node.js 实战

用Claude Code搭建邮件自动化:线索磁铁、同意、退订、重试、分析和变现闭环。

Claude Code 邮件自动化:从线索获取到变现的 Node.js 实战

邮件自动化不是“表单提交后发一封邮件”这么简单。真正能带来收入的系统,要能发送线索磁铁,启动新用户引导,跟进咨询请求,保存同意记录,处理退订,停止给硬退信地址发送,遇到临时失败时排队重试,并且把邮件点击和产品、培训、咨询转化连起来。

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_requestedemail_cta_clickconsultation_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和咨询跟进。

#Claude Code #邮件自动化 #线索获取 #Resend #SendGrid #Node.js
免费

免费 PDF: Claude Code 速查表

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

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

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

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

Masa

关于作者

Masa

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