Back to all posts
Tutorial

Cloudflare で無料のマルチ言語ニュースレターシステムを構築:Workers、D1、Resend と HMAC 認証

独立系クリエイターやブロガー全員が直面する同じ問題:訪問者に更新情報の購読を許可しながら、月額 $20 のサードパーティサービスを支払ったり、ユーザーデータを他人に預けたりしない方法はあるか?

ブログにニュースレターを追加することにしたとき、3 つの譲れない要件がありました:

  1. ゼロコスト — すべて無料層または自己ホスト
  2. 完全な制御 — サードパーティのブラックボックスに依存しない;購読者データは自分のデータベースに保存
  3. プロダクショングレードのセキュリティ — HMAC 署名の解除リンクで、訪問者が互いに購読を解除できないように
  4. 多言語サポート — 7 言語にローカライズされたウェルカムメールと解除ページ

このシステム全体を Cloudflare スタックで構築し、正確にどのように行うかを示します。


アーキテクチャ:無料層、妥協なし

完全なテクノロジースタック:

  • Cloudflare Workers (support-api.elvisli.ca):ステートレスバックエンド、購読、解除、管理 API を処理
  • Cloudflare D1:購読者とジオロケーションメタデータを保存する SQLite データベース
  • Cloudflare Pages:管理ダッシュボードと 7 言語の解除ページのフロントエンド
  • Resend:トランザクショナルメール送信用の無料 API(1 日 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

バックエンドは 3 つのエンドポイントを処理する単一 Worker ファイル:

1. POST /api/subscribe — 新規購読

リクエスト本文:

{
  "email": "user@example.com",
  "lang": "ja",
  "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 });
}

マルチ言語ウェルカムメール

ウェルカムメールは、オンボーディングとして重要で、次が必要:

  1. 信頼確立 — プロフェッショナル、ブランド化、誠実
  2. 期待設定 — 何を受け取り、どのくらいの頻度か
  3. 出口提供 — 目立つ購読解除リンク
const WELCOME_I18N = {
  ja: {
    subject: "登録が完了しました — elvisli.ca",
    heading: "登録完了 👋",
    body: "ご登録ありがとうございます。AI やものづくり、時々の実験についての新しい記事をお届けします。スパムはありません。",
    cta: "最新の記事を読む →",
    footer: "このメールは、このアドレスが elvisli.ca で購読登録されたため届いています。",
    notyou: "ご自身で登録していませんか?",
    unsub: "今すぐ配信を停止",
  },
  // ... 他の言語
};

ライブデモ: elvisli.ca/ja で登録し、ウェルカムメールを確認してください。