静的サイトにニュースレター登録フォームを付けたいとき、手っ取り早い選択肢は Mailchimp や Brevo の埋め込みウィジェットだ。ところが、これらのウィジェットは外部スクリプトを読み込む都合上、デザインの自由度が低く、サードパーティ Cookie の問題も気になる。

Cloudflare Pages のサイトであれば、Pages Functions + D1(SQLite)で購読者データを自前管理し、Brevo の API でメール配信する構成を組むことができる。無料枠内で収まる可能性はありますが、D1の処理量とBrevoのプラン・配信数を確認します。Cloudflare の無料枠だけで個人サービスを動かす全体像はCloudflare Pages + D1 で副業サービスを実質無料で動かす構成にもまとめている。登録・重複チェック・配信停止まで自分のコードで完結する。

この記事では、lifeevent-hub(家族向けライフイベント情報サイト)に実装したニュースレター基盤の構成と実装を紹介する。

📋 この記事でわかること

  • Cloudflare Pages Functions で subscribe / unsubscribe API を作る手順
  • D1(SQLite)で購読者テーブルを管理するマイグレーションの書き方
  • 重複登録を安全に処理するパターン(メールアドレスを外部に漏らさない設計)
  • Brevo API でコンタクトを追加・削除する実装
  • フロントエンドを fetch() ベースのインラインフィードバックフォームに書き換える方法
  • 配信停止リンクをトークンで実装する手順

構成の全体像

解除リンクは確認画面へ。GETでリンクを開く:トークンを検証して確認画面を表示、この時点では購読状態を変更しない。利用者が解除を確定:フォームからPOSTを送信、入力とトークンを改めて検証する。状態を変更する:購読解除を保存、同じ解除の繰り返しを安全に扱う
登録側には入力検証・濫用対策・同意確認も必要。

ブラウザ
  └─ POST /api/subscribe(Pages Functions)
        ├─ D1: subscribers テーブルに INSERT
        └─ Brevo API: コンタクト追加

配信停止リンク
  └─ GET /api/unsubscribe?token=xxx → 確認画面(DB更新なし)
        └─ POST /api/unsubscribe(利用者が確定)
              ├─ D1: 配信停止とoutboxを同一batchで保存
              └─ worker: Brevo連携を再試行

Brevo は API キーが設定されていれば呼び出し、なければスキップする。D1 だけでも動く設計にしてあるため、「まずローカルで試す → 後から Brevo を追加」という段階的な導入ができる。

Step 1: D1 テーブルを作る

まず wrangler.toml に D1 バインディングを追加する。


# wrangler.toml
[[d1_databases]]
name = "DB"
database_name = "lifeevent-newsletter"
database_id = "your-database-id"

次にマイグレーションファイルを作成する。


-- migrations/0001_create_subscribers.sql
CREATE TABLE IF NOT EXISTS subscribers (
  id                INTEGER PRIMARY KEY AUTOINCREMENT,
  email             TEXT    NOT NULL UNIQUE,
  unsubscribe_token TEXT    UNIQUE,
  status            TEXT    NOT NULL DEFAULT 'active', -- active | unsubscribed
  subscribed_at     TEXT    NOT NULL DEFAULT (datetime('now')),
  unsubscribed_at   TEXT
);

CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email);
CREATE INDEX IF NOT EXISTS idx_subscribers_token ON subscribers(unsubscribe_token);
-- 配信停止後の外部連携を永続化する。workerが成功したらprocessed_atを保存する。
CREATE TABLE IF NOT EXISTS newsletter_outbox (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  subscriber_id INTEGER NOT NULL,
  operation TEXT NOT NULL CHECK(operation IN ('unsubscribe')),
  processed_at TEXT,
  UNIQUE(subscriber_id, operation)
);

マイグレーションを実行する。


# ローカル D1 に適用
npx wrangler d1 migrations apply lifeevent-newsletter --local

# 本番 D1 に適用
npx wrangler d1 migrations apply lifeevent-newsletter

D1 を型安全に扱いたい場合は Drizzle ORM を載せる手もある。その構成はAstro×D1×Drizzle 副業アプリ0円構成ガイドで詳しく書いた。今回は購読者テーブル1枚なので生 SQL で進める。

Step 2: subscribe API を実装する

functions/api/subscribe.ts を作成する。Pages Functions はファイルのパスがそのまま API エンドポイントになる。


// functions/api/subscribe.ts
interface Env {
  DB: D1Database;
  BREVO_API_KEY?: string;
  BREVO_LIST_ID?: string; // Brevo のリスト ID(数値を文字列で)
}

export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
  const headers = { "Content-Type": "application/json" };

  // リクエストボディのパース
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return new Response(JSON.stringify({ error: "リクエストが不正です" }), { status: 400, headers });
  }

  // メールアドレスのバリデーション
  const email = (typeof body === "object" && body !== null && "email" in body)
    ? String((body as Record<string, unknown>).email).trim().toLowerCase()
    : "";

  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return new Response(
      JSON.stringify({ error: "メールアドレスが正しくありません" }),
      { status: 400, headers }
    );
  }

  // 配信停止リンク用のトークンを生成
  const token = crypto.randomUUID();

  // D1 に INSERT
  try {
    await env.DB.prepare(
      `INSERT INTO subscribers (email, unsubscribe_token) VALUES (?, ?)`
    ).bind(email, token).run();
  } catch (e) {
    const msg = e instanceof Error ? e.message : String(e);
    if (msg.includes("UNIQUE constraint failed")) {
      // 新規・重複とも同じstatusとmessageを返す(タイミング差は別に対策)
      return new Response(JSON.stringify({ message: "accepted" }), { status: 202, headers });
    }
    console.error("D1 insert failed"); // エラーに含まれるメール等をログへ出さない
    return new Response(JSON.stringify({ error: "登録に失敗しました" }), { status: 500, headers });
  }

  // Brevo にコンタクトを追加(API key が設定されている場合のみ)
  if (env.BREVO_API_KEY) {
    const listId = env.BREVO_LIST_ID ? parseInt(env.BREVO_LIST_ID, 10) : undefined;
    await fetch("https://api.brevo.com/v3/contacts", {
      method: "POST",
      headers: {
        "api-key": env.BREVO_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        email,
        ...(listId ? { listIds: [listId] } : {}),
        updateEnabled: true, // 既存コンタクトも更新する
      }),
    }).catch((err) => console.error("Brevo add contact error:", err));
  }

  return new Response(JSON.stringify({ message: "accepted" }), { status: 202, headers });
};

新規と重複は、どちらも202と同じacceptedを返します。元の例では200/201とmessageが異なり、登録有無を隠せていませんでした。この訂正でも処理時間による推測まで防ぐ保証はありません。上のsubscribeはAPIの骨格で、利用者の同意を確認するdouble opt-in、濫用対策、再登録時のトークン再発行、外部連携のoutbox化は未掲載です。配信へ使う前に実装します。Brevoのレスポンスはネットワーク例外だけでなくres.okも確認し、失敗を保存して再試行する必要があります。

Step 3: unsubscribe API を実装する

配信停止はトークン経由で行う。メールアドレスを URL に含めると予測・悪用されやすいため、登録時に発行したランダムトークンを使う。

// functions/api/unsubscribe.ts
interface Env { DB: D1Database }
const validToken = (token: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token);
const headers = {
  "Content-Type": "text/html; charset=utf-8",
  "Cache-Control": "no-store",
  "Referrer-Policy": "no-referrer",
  "Content-Security-Policy": "default-src 'none'; form-action 'self'; frame-ancestors 'none'",
  "X-Content-Type-Options": "nosniff",
};

// GETは読み取りだけ。リンクの自動スキャンで購読を解除しない。
export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
  const token = new URL(request.url).searchParams.get("token") ?? "";
  if (!validToken(token)) return new Response("無効なリンクです", { status: 400, headers });
  const row = await env.DB.prepare(
    "SELECT id FROM subscribers WHERE unsubscribe_token = ? AND status = 'active'"
  ).bind(token).first();
  if (!row) return new Response("リンクが無効、または配信停止済みです", { headers });
  // tokenは上のUUID形式に限定済み。任意のHTMLを埋め込まない。
  return new Response(`<!doctype html><html lang="ja"><meta charset="utf-8">
    <title>配信停止の確認</title><form method="post" action="/api/unsubscribe">
    <input type="hidden" name="token" value="${token}">
    <button type="submit">配信を停止する</button></form></html>`, { headers });
};

// 利用者が確認画面のフォームを送信して初めて状態を変更する。
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
  const origin = new URL(request.url).origin;
  if (request.headers.get("Origin") !== origin) {
    return new Response("Forbidden", { status: 403, headers });
  }
  if (!request.headers.get("Content-Type")?.startsWith("application/x-www-form-urlencoded")) {
    return new Response("Unsupported media type", { status: 415, headers });
  }
  let token: string;
  try { token = String((await request.formData()).get("token") ?? ""); }
  catch { return new Response("Invalid form", { status: 400, headers }); }
  if (!validToken(token)) return new Response("Invalid token", { status: 400, headers });
  // D1 batchの各SQLは同一トランザクションで処理される。
  // 先に同期要求を保存し、続けてトークンを無効化する。
  await env.DB.batch([
    env.DB.prepare(`INSERT INTO newsletter_outbox (subscriber_id, operation)
      SELECT id, 'unsubscribe' FROM subscribers
      WHERE unsubscribe_token = ? AND status = 'active'
      ON CONFLICT(subscriber_id, operation) DO UPDATE SET processed_at = NULL`).bind(token),
    env.DB.prepare(`UPDATE subscribers SET status = 'unsubscribed',
      unsubscribed_at = datetime('now'), unsubscribe_token = NULL
      WHERE unsubscribe_token = ? AND status = 'active'`).bind(token),
  ]);
  return new Response("配信停止の要求を受け付けました", { headers });
};

GETはトークンを読み取って確認画面を表示するだけです。配信停止とトークンの無効化はPOSTで行います。GETの安全なメソッドとしての意味に従い、メールの自動スキャンだけで状態を変えない形にします。D1のbatchで、配信停止と外部同期要求の保存を一緒に確定します。

上の例はD1までの処理です。outboxを読むworkerは未掲載であり、このコードだけではBrevo側の配信停止は完了しません。workerは購読者と最新状態を取得し、専用リストからの解除など連携方針に沿ってAPIを呼び、HTTP応答を検証してからprocessed_atを更新します。エラーはバックオフして再試行し、未同期を監視します。連携先が送信対象を更新するまでは遅延があるため、配信開始前にも停止済みを除外してください。共有のBrevoコンタクトを削除すると別リストにも影響する点に注意します。

Step 4: フォームを fetch() ベースに書き換える

Mailchimp や Brevo のウィジェット方式を使っていた場合、フォームを fetch() ベースのインラインフィードバック方式に書き換える。HTML + <script> で実装しているなら以下のようになる。


<form id="nlForm" novalidate>
  <div class="nl-row">
    <input id="nlEmail" type="email" placeholder="メールアドレス" required autocomplete="email" />
    <button id="nlBtn" type="submit">登録</button>
  </div>
  <p id="nlMsg" aria-live="polite"></p>
  <p class="nl-note">月2回・配信停止はいつでも可</p>
</form>

<script>
  const form = document.getElementById("nlForm");
  const emailInput = document.getElementById("nlEmail");
  const btn = document.getElementById("nlBtn");
  const msg = document.getElementById("nlMsg");

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const email = emailInput.value.trim();
    if (!email) return;

    btn.disabled = true;
    btn.textContent = "登録中…";
    msg.textContent = "";

    try {
      const res = await fetch("/api/subscribe", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email }),
      });
      const data = await res.json();

      if (res.ok) {
        msg.textContent = "ご登録ありがとうございます!";
        form.style.display = "none";
      } else {
        msg.textContent = data.error ?? "登録に失敗しました。";
        btn.disabled = false;
        btn.textContent = "登録";
      }
    } catch {
      msg.textContent = "通信エラーが発生しました。";
      btn.disabled = false;
      btn.textContent = "登録";
    }
  });
</script>

aria-live="polite" を <p id="nlMsg"> に付けておくと、スクリーンリーダーがメッセージ変化を読み上げてくれる。

Step 5: Cloudflare にシークレットを設定する

Brevo の API キーとリスト ID を Cloudflare Pages の環境変数に設定する。


# Cloudflare Pages に環境変数を追加
npx wrangler pages secret put BREVO_API_KEY
npx wrangler pages secret put BREVO_LIST_ID

wrangler.toml にシークレットを書かないよう注意する。wrangler secret put で登録した値はダッシュボードの「Settings → Environment variables」にも反映される。

ハマりやすいポイント

D1 バインディング名をコードと合わせる

wrangler.toml の name = "DB" に対して、Pages Functions 側は env.DB でアクセスする。この例ではDBという名前なので、コードのdbでは一致しません。大文字が必須という仕様ではなく、設定名と一致させる必要があります。


// ❌ 小文字にすると undefined
const result = await env.db.prepare(...);

// ✅ 大文字で合わせる
const result = await env.DB.prepare(...);

ローカル開発では –local フラグが必要

wrangler pages dev でローカル起動している場合、D1 はローカルの SQLite ファイルに接続される。ローカルとリモートのデータは別なので、マイグレーションも両方に適用しておく必要がある。

Brevo の updateEnabled: true を忘れると既存コンタクトでエラー

Brevo API にすでに存在するメールアドレスを POST /contacts で追加しようとすると、updateEnabled が false(デフォルト)の場合はエラーになる。updateEnabled: true を付けておくと冪等に動く。

よくある質問

Q. D1 の無料枠で何人くらい管理できますか?

A. D1 の無料枠は 5GB ストレージ・読み取り 500 万行/日・書き込み 10 万行/日。購読者テーブルは 1 行あたり数十バイト程度なので、行のサイズ、インデックス、日々のクエリで収容人数と処理量は変わるため、人数だけでは判断できません。無料枠超過時の失敗を想定し、公式の現行上限と利用量を確認します。

Q. Brevo なしで運用することはできますか?

A. できる。BREVO_API_KEY を設定しなければ Brevo の呼び出しはスキップされ、D1 だけでメールアドレスを管理する状態になる。配信はダッシュボードから D1 のデータを CSV エクスポートして Brevo にインポートする手動フローで対応できる。

Q. メール配信の送信はどうやるんですか?

A. Brevo の「メールキャンペーン」機能から手動で配信する、または Brevo の API(POST /emailCampaigns)を使って定期バッチを組む。Cloudflare Workers の Cron Triggers と組み合わせると、毎月決まった曜日に自動配信する仕組みも作れる。

Q. 配信停止リンクがあれば、法令対応は完了しますか?

この技術例だけで法令への適合を判断することはできません。対象地域、配信内容、同意取得、利用目的の表示、保存・削除方針などを別途確認し、必要なら専門家に相談してください。

✅ まとめ

Cloudflare Pages + D1 + Brevo の構成で、外部ウィジェットに依存しないニュースレター基盤を作った。

実装のポイントを整理すると:

  • Pages Functions はファイルパスがそのまま API エンドポイントになるので設定が少ない
  • 新規と重複の応答は202 + { message: "accepted" }へ統一する
  • 配信停止はGETで確認し、POSTで確定する。トークンは秘密としてログに残さない
  • Brevoの連携はHTTP応答を検証し、永続outboxから再試行する
  • D1 バインディング名をコードと合わせるで統一する

まず同意確認・保存方針・濫用対策を設計してからD1で試し、配信の準備が整ったら Brevo の API キーを追加する「段階的導入」が一番ハードルが低い。静的サイトでも自前でユーザーリストを持てるようになると、メディア運用の選択肢がぐっと広がる。D1 は購読者管理以外にも使い回せて、たとえばCloudflare D1 + Astro でコメント機能を実装した記事では同じ構成でコメント欄を作っている。