第 09 章 — Email 收集與後台目錄  

AI 互動行銷頁實戰 — 目標九宮格 · 第 09 章

Email 收集與後台:
留下名單,當場兌現承諾

honeypot 擋機器人、UNIQUE 去重不報錯、Bearer token 後台、一鍵匯出 CSV。社群觸及是租來的,email 名單才是你自己的資產。

核心觀念

免費價值先給,
留資換加值,
承諾當場兌現。

桌布 PNG 直接給,不設牆;email 表單放在下載旁邊,用「30 天行動追蹤模板」交換。訪客已經拿到主要價值,留資是加值交易,不是門票 — 轉換才不會帶著怨氣。

「立即拿」是設計,不是文案

表單與 honeypot

<form id="signup-form" novalidate>
  <input type="email" name="email" placeholder="you@example.com">
  <input type="text" name="company" class="hp-field"
         tabindex="-1" autocomplete="off" aria-hidden="true">
  <button type="submit">送出拿模板</button>
</form>

/* honeypot:移出視野但仍在 DOM(真人看不到,機器人照填) */
.hp-field { position: absolute; left: -9999px;
            width: 1px; height: 1px; opacity: 0; }

欄位越少轉換越高,所以只收 email。笨的灌名單機器人會把每個欄位都填滿 — company 有值就當機器人,但不回錯誤:假裝成功、不寫入,讓它以為得逞,不會回頭換手法。

/signup 的完整防線

// worker-deploy/src/lib/signup.js(節錄)
if (limited(ip)) return r(429, { error: "rate_limited" });  // 分鐘限流
if (!(await verifyTurnstile(env, body.turnstileToken, ip)))
  return r(403, { error: "turnstile_failed" });             // Turnstile
if (body.company)                                           // honeypot
  return r(200, { ok: true, gift: env.GIFT_URL });          // 假成功
const email = String(body.email || "").trim().toLowerCase();
if (!EMAIL_RE.test(email) || email.length > 120)
  return r(400, { error: "bad_email" });
const quota = await takeQuota(db, { kind: "signup", ip, limit: 10, day });
if (!quota.ok) return r(429, { error: "rate_limited" });    // D1 日配額
await db.prepare(
  "INSERT INTO signups (email, name, created_at, ip) VALUES (?, ?, ?, ?)")
  .bind(email, name || null, now, ip).run();

分鐘限流是記憶體 Map(快、零成本,但 Worker 多實例會歸零,best-effort);D1 原子配額才是繞不過的那層 — 第 08 章的教訓在便宜端點上的應用。email 先 trim 再轉小寫,Abc@Mail.com 和 abc@mail.com 才算同一人。

去重:重複送出不是錯誤

} catch (e) {
  if (String(e).includes("UNIQUE")) {   // schema:email TEXT NOT NULL UNIQUE
    return r(200, { ok: true, already: true, gift: env.GIFT_URL });
  }
}

// app/js/signup.js — 前端文案
already ? '你已經在名單上了 —— 模板連結照樣再給你一次:'
        : '完成,Email 已登記。這是你的 30 天行動追蹤模板:'

使用者換台電腦回來再填,看到「此 email 已存在」只會困惑 — 他要的是模板,再給一次就好;外人也無法用狀態碼探測誰在名單上。誠實註記:already 欄位本身仍可被探測,若收的是敏感名單(客戶、病患、會員),連 already 都拿掉,兩種回應完全一致。

誘因即時兌現:tracker.html

後台 admin.html:token gate

// app/admin.html — token 走標頭,不放網址
const r = await fetch(apiBase() + '/list',
  { headers: { Authorization: 'Bearer ' + t } });

// worker-deploy/src/lib/signup.js — 與 Secret 比對
export async function handleList({ db, env, bearer }) {
  if (!env.ADMIN_TOKEN || bearer !== env.ADMIN_TOKEN) {
    return { status: 401, body: { error: "unauthorized" } };
  }
  const { results } = await db.prepare(
    "SELECT id, email, name, created_at, ip FROM signups ORDER BY id DESC").all();
  return { status: 200, body: { signups: results, count: results.length } };
}

兩條前課就講過的鐵則:密碼只存 Worker Secret(ADMIN_TOKEN),永不出現在前端程式碼;傳輸走 Authorization 標頭 — ?token= 會被瀏覽器歷史和 CDN log 記下。

token 存哪:sessionStorage,不是 localStorage

localStorage 的爆炸半徑

  • yazelin.github.io 是共用 origin:同帳號所有專案頁共用同一個儲存空間
  • 任何一個舊專案有 XSS,注入的腳本就讀得到整個 origin 的 localStorage
  • 長效憑證放這裡,等於讓爆炸半徑涵蓋所有歷史包袱

sessionStorage 的交易

  • 只活在這個分頁,關了就消失
  • 代價:每次開後台重輸一次密碼
  • 換到:被偷窗口從「永久」縮成「分頁開著的期間」— 後台一天開不了幾次,划算

安全設計講出來就是信任感:輸入框上方直接註明「只記到這個分頁關閉為止」。

匯出 CSV,接進 EDM 工具

// app/admin.html — RFC 4180:欄位全包雙引號、引號翻倍、CRLF 列尾
function buildCsv(items) {
  const q = (v) => '"' + String(v ?? '').replace(/"/g, '""') + '"';
  const rows = [['email', 'name', 'created_at', 'ip']]
    .concat(items.map((s) => [s.email, s.name || '', s.created_at, s.ip || '']));
  return rows.map((r) => r.map(q).join(',')).join('\r\n') + '\r\n';
}
// 開頭加 UTF-8 BOM:Excel 直接開啟也認得中文
const blob = new Blob(['\uFEFF' + buildCsv(list)],
  { type: 'text/csv;charset=utf-8' });

稱呼裡一個逗號就能撐破整列;沒有 BOM,Excel 開起來中文全亂碼。匯出後丟進 MailerLite / Mailchimp / Brevo 的「匯入聯絡人」,你的第一波 email 行銷就有對象了。

驗收

常見坑

對照:demos/05-email + app/admin.html + app/assets/tracker.html

重點回顧

下一章:第 10 章 — 進階 9×9 與發佈:展開 81 格,讓全世界(和 AI)看見