用 Cloudflare 全家桶打造零成本多语言邮件订阅系统:Workers、D1、Resend 和 HMAC 认证
每个独立开发者和博主都面临同一个问题:如何让访客订阅你的更新,而不用每月花 $20 订阅第三方服务,也不用把用户数据交给他人?
当我决定为博客加上邮件订阅功能时,我有三个不可妥协的需求:
- 零成本 —— 全用免费层或自建
- 完全掌控 —— 不依赖第三方黑盒;订阅者数据存在自己的数据库
- 生产级安全 —— HMAC 签名的退订链接,防止访客相互取消对方订阅
- 多语言支持 —— 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)。
多语言欢迎邮件
欢迎邮件至关重要,既要建立信任,又要包含退订链接。必须:
- 建立信任 —— 专业、品牌化、诚实
- 设置预期 —— 他们会收到什么、多久收一次
- 提供逃脱口 —— 醒目的退订链接,让他们不会感到被困
模板结构:
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。所以让输入框可编辑既:
- 容易误导 —— 用户以为能改邮箱
- 多余 —— 后端会拒绝不匹配的签名
锁定它,减少认知负担。
后台仪表板
前端后台的"订阅者"标签可以:
- 查看全部:邮箱、位置(城市/省/国家)、时区、语言、来源、订阅日期
- 搜索筛选:按邮箱、位置、语言
- 编辑模式:点"编辑"显示删除按钮(双按钮防误操作)
- 删除:单条订阅者
<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"
部署清单
-
创建 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
- [ ] 欢迎邮件仅在首次订阅时发送,重复订阅不发
完整流程
当访客订阅时:
- 前端表单捕获邮箱+语言+蜜罐/时间
- POST /api/subscribe 验证、反垃圾检查、地理位置捕获
- D1 insert(UNIQUE 去重;重复订阅静默拒绝)
- HMAC 令牌生成 用于这个邮箱
- 发送欢迎邮件 给订阅者(本地化),附带签名退订 URL
- 发送管理员通知 给你(附带地理位置+临时退订链接)
当订阅者点击退订时:
- 退订页 (
/[locale]/unsubscribe?email=...&token=...) 显示邮箱(只读) - 点确认 → POST
/api/unsubscribe(邮箱+令牌) - 后端校验 HMAC —— 如果令牌 ≠ HMAC(邮箱, 密钥),返回 403
- D1 更新 设
status='unsubscribed' - 确认页 显示"你已取消订阅"
整个系统无状态、可扩展、零成本,因为一切都运行在 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 订阅,查收欢迎邮件。

