Back to all posts
Tutorial

用 Cloudflare 全家桶打造零成本多语言邮件订阅系统:Workers、D1、Resend 和 HMAC 认证

每个独立开发者和博主都面临同一个问题:如何让访客订阅你的更新,而不用每月花 $20 订阅第三方服务,也不用把用户数据交给他人?

当我决定为博客加上邮件订阅功能时,我有三个不可妥协的需求:

  1. 零成本 —— 全用免费层或自建
  2. 完全掌控 —— 不依赖第三方黑盒;订阅者数据存在自己的数据库
  3. 生产级安全 —— HMAC 签名的退订链接,防止访客相互取消对方订阅
  4. 多语言支持 —— 7 种语言的本地化欢迎邮件和退订页面

我用 Cloudflare 全家桶完整实现了这个系统,现在把细节完整分享给你。


架构:免费层,无妥协

完整技术栈:

  • Cloudflare Workers (support-api.elvisli.ca):无状态后端,处理订阅、退订、后台 API
  • Cloudflare D1:SQLite 数据库,存储订阅者及地理位置元数据
  • Cloudflare Pages:前端,包括后台仪表板和 7 种语言的退订页面
  • Resend:免费层邮件 API(每天 280 封),用于发送事务邮件
  • Web Crypto API:浏览器和 Workers 原生 HMAC-SHA256,用于令牌签名

总成本:$0/月。(如果超过 Resend 免费额度,升级付费版;无运维成本。)


数据库设计:订阅者 + 地理位置

核心是 D1 中的一个 subscribers 表:

CREATE TABLE IF NOT EXISTS subscribers (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE,
  source TEXT DEFAULT 'home',           -- 订阅来源
  lang TEXT,                             -- 用户语言偏好
  country TEXT, region TEXT, city TEXT, -- Cloudflare 边缘自动注入的地理位置
  timezone TEXT,                         -- CF 自动填充
  ip TEXT, user_agent TEXT,              -- 审计用
  status TEXT DEFAULT 'active',          -- 'active' 或 'unsubscribed'
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_email ON subscribers(email);
CREATE INDEX IF NOT EXISTS idx_status ON subscribers(status);

为什么这样设计?

  • Email UNIQUE:防止重复订阅;重新订阅时静默失败且返回假成功(反垃圾)
  • 地理位置列:Cloudflare Workers 在边缘可以访问 request.cf,所以免费获得城市/国家/时区 —— 无需第三方 IP 查询 API
  • Status 字段:软删除模式;退订时只设 status='unsubscribed',不删除行(保留审计记录)
  • source 字段:追踪订阅来源(首页、邮件转发等)

后端:Workers API

后端是一个 Worker 文件,处理三个端点:

1. POST /api/subscribe —— 新订阅

请求体:

{
  "email": "user@example.com",
  "lang": "zh",
  "source": "home",
  "website": "",              // 蜜罐(隐藏输入框)
  "elapsedMs": 5000           // 表单加载到提交的毫秒数
}

流程:

async function handleSubscribe(request, env) {
  const body = await request.json();
  const { email, lang, source, website, elapsedMs } = body;

  // 1. 反垃圾:静默拒绝机器人(无可见验证码)
  if (website || elapsedMs < 1200) {
    return fakeSuccess(); // 机器人得到假 200,不知道自己被拦了
  }

  // 2. 邮箱验证
  if (!isValidEmail(email)) return error400("邮箱格式不对");

  // 3. 从 Cloudflare 边缘捕获地理位置
  const cf = request.cf || {};
  const country = cf.country || request.headers.get("CF-IPCountry");
  const timezone = cf.timezone;
  const ip = request.headers.get("CF-Connecting-IP");

  // 4. 写入 D1(UNIQUE 约束防止重复)
  const result = await env.DB.prepare(
    `INSERT OR IGNORE INTO subscribers (email, lang, source, country, timezone, ip)
     VALUES (?, ?, ?, ?, ?, ?)`
  ).bind(email, lang, source, country, timezone, ip).run();
  
  const isNew = result.meta.changes > 0;

  // 5. 发送欢迎邮件(本地化,仅限新订阅者)
  if (isNew) {
    const unsubToken = await hmacToken(email, env.UNSUBSCRIBE_SECRET);
    const unsubUrl = `https://elvisli.ca/${localePrefix(lang)}/unsubscribe?email=${email}&token=${unsubToken}`;
    
    // 给订阅者的欢迎邮件
    await sendResendEmail({
      to: email,
      subject: welcomeCopy(lang).subject,
      html: welcomeEmailHtml(lang, unsubUrl),
    });

    // 也要通知网站管理员(你)
    await sendResendEmail({
      to: env.NOTIFY_EMAIL,
      subject: `📨 新订阅者:${email}`,
      html: adminNotificationHtml(email, country, timezone, lang),
    });
  }

  return ok({ success: true, isNew, subscribed: true });
}

关键点:

  • 蜜罐 + 时间守卫:机器人要么填隐藏的 website 字段,要么在 1.2 秒内提交。两种情况都返回假成功(200 但不入库),机器人拿不到信号,就不会再探。
  • 地理位置从 request.cf 读:Cloudflare 在边缘自动填充;无需第三方 API 调用。
  • HMAC 令牌生成:在订阅时生成,嵌入退订 URL,随邮件发送。这是安全的核心 —— 见下文。

2. POST /api/unsubscribe —— 自助退订

请求体:

{
  "email": "user@example.com",
  "token": "a3f8d2c6..."  // HMAC-SHA256(邮箱, 密钥)
}

流程:

async function handleUnsubscribe(request, env) {
  const { email, token } = await request.json();

  // 校验 HMAC 签名(防止第三方退订)
  const expected = await hmacToken(email, env.UNSUBSCRIBE_SECRET);
  if (token !== expected) {
    return error403("无效或过期的退订链接");
  }

  // 标记为已退订(不删除 —— 保留审计记录)
  await env.DB.prepare(
    `UPDATE subscribers SET status = 'unsubscribed' WHERE email = ?`
  ).bind(email).run();

  return ok({ success: true });
}

为什么用 HMAC,而不是一次性令牌?

  • 无状态:不需要另建表存"一次性退订令牌",还要管理过期。签名本身就是证明。
  • 绑定邮箱:HMAC(邮箱, 密钥) 对同一邮箱总产生同样的签名。用 attacker@evil.com 的签名无法退订 victim@example.com。
  • 常数时间比较:用 XOR 比较签名,防止时序攻击。

验证逻辑:

// 常数时间 HMAC 验证
async function verifyUnsubToken(email, token, secret) {
  const expected = await hmacToken(email, secret);
  if (expected.length !== token.length) return false;
  
  let diff = 0;
  for (let i = 0; i < expected.length; i++) {
    diff |= expected.charCodeAt(i) ^ token.charCodeAt(i);
  }
  return diff === 0; // 时序安全的比较
}

3. GET /api/admin/subscribers —— 后台报表

用 Authorization: Bearer ${ADMIN_TOKEN} 保护:

const { results } = await env.DB.prepare(
  `SELECT id, email, source, lang, country, city, timezone, status, created_at
   FROM subscribers WHERE status = 'active' ORDER BY created_at DESC`
).all();

return json({ total: results.length, subscribers: results });

后台还可以通过 DELETE /api/admin/subscribers/:id 删除单条记录(同样需要 token)。


多语言欢迎邮件

欢迎邮件至关重要,既要建立信任,又要包含退订链接。必须:

  1. 建立信任 —— 专业、品牌化、诚实
  2. 设置预期 —— 他们会收到什么、多久收一次
  3. 提供逃脱口 —— 醒目的退订链接,让他们不会感到被困

模板结构:

const WELCOME_I18N = {
  en: {
    subject: "You're subscribed — elvisli.ca",
    heading: "You're in.",
    body: "Thanks for subscribing. You'll get new writing on AI, building things, and the occasional experiment — straight to your inbox. No spam, no noise.",
    cta: "Read the latest →",
    footer: "You're receiving this because someone subscribed this email at elvisli.ca.",
    notyou: "Didn't sign up?",
    unsub: "Unsubscribe instantly",
  },
  zh: {
    subject: "订阅成功 — elvisli.ca",
    heading: "订阅成功 👋",
    body: "感谢订阅。你将收到关于 AI、动手构建以及一些实验的新文章,直接送达收件箱。不发垃圾邮件,不打扰。",
    cta: "看看最新文章 →",
    footer: "你收到这封邮件,是因为有人用此邮箱在 elvisli.ca 订阅了更新。",
    notyou: "不是你本人订阅的?",
    unsub: "立即取消订阅",
  },
  // ... fr, es, ja, ko, zh-TW
};

function welcomeEmailHtml(lang, unsubUrl) {
  const t = welcomeCopy(lang);
  const blog = "https://elvisli.ca/blog";
  
  return `
  <div style="padding:32px 16px;background:#f5f5f5;font-family:system-ui;">
    <div style="max-width:480px;margin:0 auto;background:#fff;border:1px solid #e5e5e5;border-radius:12px;">
      <div style="padding:36px;">
        <p style="font-size:11px;letter-spacing:0.12em;color:#888;text-transform:uppercase;">
          ${t.eyebrow}
        </p>
        <h1 style="font-size:26px;font-weight:700;margin:14px 0;">${t.heading}</h1>
        <p style="font-size:15px;line-height:1.65;color:#555;margin:0 0 28px;">
          ${t.body}
        </p>
        <a href="${blog}" style="display:inline-block;background:#A3E635;color:#000;font-weight:600;padding:11px 22px;border-radius:8px;text-decoration:none;">
          ${t.cta}
        </a>
      </div>
      <div style="border-top:1px solid #eee;padding:18px 36px;background:#fafafa;font-size:12px;">
        <p style="color:#999;margin:0 0 8px;">${t.footer}</p>
        <p style="color:#999;">
          ${t.notyou}
          <a href="${unsubUrl}" style="color:#c0392b;font-weight:600;text-decoration:underline;">
            ${t.unsub}
          </a>
        </p>
      </div>
    </div>
  </div>`;
}

设计亮点:

  • 浅色背景:深色邮件容易被标记为垃圾。浅底+黑字+好对比度。
  • Lime 强调色 (#A3E635):与网站设计语言一致。
  • 醒目的"不是你订阅的?"行:红色、加粗、放在底部 —— 如果有人恶意拿别人邮箱订阅,访客一眼就看到可以点击退订。这是防守的第一道线。
  • 等宽 eyebrow:技术感,取信于黑客/构建者。

前端:退订页(7 种语言)

每个退订页 (/[locale]/unsubscribe) 都是只读的:

export default function UnsubscribePage() {
  const [email, setEmail] = useState('');
  const [token, setToken] = useState('');
  const [status, setStatus] = useState('init');

  useEffect(() => {
    // 邮箱和令牌仅从签名链接读取,不可编辑
    const params = new URLSearchParams(window.location.search);
    setEmail(params.get('email'));
    setToken(params.get('token'));
  }, []);

  const handleUnsubscribe = async () => {
    const res = await fetch('https://support-api.elvisli.ca/api/unsubscribe', {
      method: 'POST',
      body: JSON.stringify({ email, token }),
    });
    
    if (res.ok) setStatus('done');
    else if (res.status === 403) setStatus('invalid');
  };

  return (
    <div>
      <p>你即将取消订阅以下邮箱:</p>
      {/* 邮箱被锁定且只读 */}
      <div style={{ background: '#f5f5f5', padding: '11px', borderRadius: '8px', fontFamily: 'monospace' }}>
        {email}
      </div>
      <button onClick={handleUnsubscribe}>确认退订</button>
    </div>
  );
}

为什么只读?

HMAC 令牌与此邮箱绑定。如果用户改成 attacker@evil.com,签名就对不上了,后端返回 403。所以让输入框可编辑既:

  1. 容易误导 —— 用户以为能改邮箱
  2. 多余 —— 后端会拒绝不匹配的签名

锁定它,减少认知负担。


后台仪表板

前端后台的"订阅者"标签可以:

  • 查看全部:邮箱、位置(城市/省/国家)、时区、语言、来源、订阅日期
  • 搜索筛选:按邮箱、位置、语言
  • 编辑模式:点"编辑"显示删除按钮(双按钮防误操作)
  • 删除:单条订阅者
<table>
  <thead>
    <tr>
      <th>邮箱</th>
      <th>位置</th>
      <th>语言</th>
      <th>来源</th>
      <th>订阅时间</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <tr id="sub-row-5">
      <td>elvis@example.com</td>
      <td>多伦多, 安大略, 加拿大</td>
      <td><span class="badge">zh</span></td>
      <td>home</td>
      <td>2026-06-18 12:34:56</td>
      <td>
        <button onclick="toggleSubEdit(5)">编辑</button>
        <span id="sub-actions-5" style="display:none;">
          <button onclick="deleteSubscriber(5, 'elvis@example.com')">删除</button>
          <button onclick="toggleSubEdit(5)">取消</button>
        </span>
      </td>
    </tr>
  </tbody>
</table>

删除按钮默认隐藏(只有点了"编辑"才显示),防止误操作。


密钥和环境变量

所有敏感数据都存在 Cloudflare 密钥中,永不写入 wrangler.toml:

npx wrangler secret put ADMIN_TOKEN
npx wrangler secret put UNSUBSCRIBE_SECRET
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put NOTIFY_EMAIL
npx wrangler secret put NOTIFY_FROM

在 wrangler.jsonc 中(非密钥,半公开):

[vars]
ALLOWED_ORIGIN = "*"

[[d1_databases]]
binding = "DB"
database_name = "newsletter-db"
database_id = "your-d1-uuid"

部署清单

  1. 创建 D1 数据库:

npx wrangler d1 create newsletter-db
npx wrangler d1 execute newsletter-db --local --file=schema.sql
npx wrangler d1 execute newsletter-db --remote --file=schema.sql
  • 设置密钥:

  • npx wrangler secret put ADMIN_TOKEN        # 随机字符串,e.g. openssl rand -hex 32
    npx wrangler secret put UNSUBSCRIBE_SECRET # 同上
    npx wrangler secret put RESEND_API_KEY     # 从 https://resend.com
    npx wrangler secret put NOTIFY_EMAIL       # 你的邮箱
    
  • 部署 Worker:

  • npx wrangler deploy
    
  • 部署退订页(Next.js):

  • npm run deploy
    
  • 测试:

  • curl -X POST https://support-api.elvisli.ca/api/subscribe \
      -H "Content-Type: application/json" \
      -d '{"email":"test@example.com","lang":"zh","source":"test"}'
    

    安全清单

    • [ ] 退订 URL 中的邮箱被 URL 编码(encodeURIComponent)
    • [ ] HMAC 令牌用常数时间比较(防时序攻击)
    • [ ] HTML 邮件中的所有用户输入被转义(防邮件客户端 XSS)
    • [ ] 蜜罐+时间守卫静默拒绝机器人(无错误消息泄露信息)
    • [ ] UNSUBSCRIBE_SECRET 至少 32 随机字节(强 HMAC 密钥)
    • [ ] D1 schema 在 email 上用 UNIQUE(DB 层防重复)
    • [ ] 退订链接包含语言前缀,不会 404
    • [ ] 欢迎邮件仅在首次订阅时发送,重复订阅不发

    完整流程

    当访客订阅时:

    1. 前端表单捕获邮箱+语言+蜜罐/时间
    2. POST /api/subscribe 验证、反垃圾检查、地理位置捕获
    3. D1 insert(UNIQUE 去重;重复订阅静默拒绝)
    4. HMAC 令牌生成 用于这个邮箱
    5. 发送欢迎邮件 给订阅者(本地化),附带签名退订 URL
    6. 发送管理员通知 给你(附带地理位置+临时退订链接)

    当订阅者点击退订时:

    1. 退订页 (/[locale]/unsubscribe?email=...&token=...) 显示邮箱(只读)
    2. 点确认 → POST /api/unsubscribe(邮箱+令牌)
    3. 后端校验 HMAC —— 如果令牌 ≠ HMAC(邮箱, 密钥),返回 403
    4. D1 更新 设 status='unsubscribed'
    5. 确认页 显示"你已取消订阅"

    整个系统无状态、可扩展、零成本,因为一切都运行在 Cloudflare 免费层。


    后续

    • 集成你的邮件发送者(e.g.,每周循环 SELECT * FROM subscribers WHERE status='active' 构建邮件列表)
    • 加 campaigns 表追踪打开/点击(用重定向 URL 如 https://support-api.elvisli.ca/click?campaign_id=...&subscriber_id=...)
    • 与 CRM 同步(e.g.,拉进 Mailchimp 做分群)
    • A/B 测试欢迎邮件文案,测量退订率

    你已经打好了坚实基础。从这里开始,你完全掌控自己的受众。


    线上演示: 在 elvisli.ca/zh 订阅,查收欢迎邮件。