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-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。所以讓輸入框可編輯既:

  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-TW 訂閱,查收歡迎郵件。