Cloudflare で無料のマルチ言語ニュースレターシステムを構築:Workers、D1、Resend と HMAC 認証
独立系クリエイターやブロガー全員が直面する同じ問題:訪問者に更新情報の購読を許可しながら、月額 $20 のサードパーティサービスを支払ったり、ユーザーデータを他人に預けたりしない方法はあるか?
ブログにニュースレターを追加することにしたとき、3 つの譲れない要件がありました:
- ゼロコスト — すべて無料層または自己ホスト
- 完全な制御 — サードパーティのブラックボックスに依存しない;購読者データは自分のデータベースに保存
- プロダクショングレードのセキュリティ — HMAC 署名の解除リンクで、訪問者が互いに購読を解除できないように
- 多言語サポート — 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 });
}
マルチ言語ウェルカムメール
ウェルカムメールは、オンボーディングとして重要で、次が必要:
- 信頼確立 — プロフェッショナル、ブランド化、誠実
- 期待設定 — 何を受け取り、どのくらいの頻度か
- 出口提供 — 目立つ購読解除リンク
const WELCOME_I18N = {
ja: {
subject: "登録が完了しました — elvisli.ca",
heading: "登録完了 👋",
body: "ご登録ありがとうございます。AI やものづくり、時々の実験についての新しい記事をお届けします。スパムはありません。",
cta: "最新の記事を読む →",
footer: "このメールは、このアドレスが elvisli.ca で購読登録されたため届いています。",
notyou: "ご自身で登録していませんか?",
unsub: "今すぐ配信を停止",
},
// ... 他の言語
};
ライブデモ: elvisli.ca/ja で登録し、ウェルカムメールを確認してください。

