用 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-tw": {
subject: "訂閱成功 — elvisli.ca",
heading: "訂閱成功 👋",
body: "感謝訂閱。你將收到關於 AI、動手實作以及一些實驗的新文章,直接送達收件匣。不發垃圾郵件,不打擾。",
cta: "看看最新文章 →",
footer: "你收到這封郵件,是因為有人用此信箱在 elvisli.ca 訂閱了更新。",
notyou: "不是你本人訂閱的?",
unsub: "立即取消訂閱",
},
// ... fr, es, ja, ko, zh
};
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-TW 訂閱,查收歡迎郵件。

