Tech

Cloudflare Workers で、静的サイトのまま問い合わせフォームを作る

  • #cloudflare workers
  • #turnstile
  • #resend
  • #astro
  • #個人開発

このサイトには問い合わせフォームがあります。
送信するとボット対策を通り、私宛にメールが届く。ごく普通のフォームです。

このサイトは Astro で作った静的サイトで、配信は Cloudflare です。
Cloudflare にもメール送信の機能はあるのですが、DNS を Cloudflare で運用していることが条件です。このサイトは DNS が別のサービスにあるので使えません。
そこで、DNS がどこにあっても使えるメール送信サービス Resend を使いました。ボット対策の Turnstile も足して、サーバーを借りずに無料枠だけで動いています。

この記事はその実装の記録です。
静的サイトでフォームを作りたい人の参考になれば嬉しいです(実装は前回と同じく Claude Code に任せています)。

mailto でも Google フォームでもなく、自前にした理由

楽な選択肢は2つありました。

  • mailto リンク:ページにメールアドレスを書く
  • Google フォーム:埋め込むかリンクで飛ばす

mailto はメールアドレスを公開することになるので不採用にしました。
Google フォームは実用的ですが、どうせサイトを作るのにフォームだけ Google というのも中途半端です。ちゃんと作ったらいいよね、ということで自前にしました。

全体の流れ

送信を受け取る処理は Cloudflare Workers で書きました。Cloudflare のサーバー上で自作のコードを動かせる仕組みで、このサイトでは worker/index.ts がその本体です(以後「Worker」と呼びます)。

送信からメールが届くまでの流れです。

ブラウザ(フォーム + Turnstile のチェック)
  → POST /api/contact(Worker)
    → Turnstile 検証(人間からの送信かをサーバー側で照合)
    → Resend(メール送信)
      → 私の受信箱

静的サイトの配信と Worker が同居する仕組みはこうです。
デプロイ設定(wrangler.jsonc)に、静的ファイルの置き場所と Worker の入り口を並べて書きます。

// wrangler.jsonc(抜粋)
{
  "main": "worker/index.ts",        // Worker の入り口
  "assets": {
    "directory": "./dist",          // 静的ファイル置き場(Astro のビルド出力)
    "binding": "ASSETS",
    "not_found_handling": "404-page"
  }
}

この構成でのリクエストの振り分けは3段です。

  1. リクエストはまず dist/ の静的ファイルと照合され、一致すればそのまま配信されます。このとき Worker は動きません
  2. 一致しなかったリクエストだけが Worker に渡ってきます。/api/contact も、存在しない URL へのアクセスも、ここに来ます
  3. Worker は /api/contact なら自分で処理し、それ以外は ASSETS(静的配信側)に投げ返します。投げ返された先にも該当ファイルは無いので、404 ページが返ります

/api/contact に対応する静的ファイルは無いので、フォームの送信は 1. をすり抜けて必ず Worker に届きます。
「サイトは静的配信のまま、/api/contact だけ自分で処理」ができるわけです。

入り口のコードはこれだけです。2. で渡ってきたリクエストを、3. のとおり振り分けています。

// worker/index.ts(抜粋)
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    // 末尾スラッシュの有無どちらでも受ける(後述のハマりどころ)
    const path = url.pathname.replace(/\/+$/, "");
    if (path === "/api/contact") {
      return handleContact(request, env);
    }
    return env.ASSETS.fetch(request);
  },
};

受け取り処理:チェックを順番に通すだけ

/api/contact の中身は、チェックを順番に通していくだけの素直な作りです。

// worker/index.ts(抜粋・整理)
async function handleContact(request: Request, env: Env): Promise<Response> {
  // 1. POST 以外は 405
  if (request.method !== "POST") {
    return json({ ok: false, error: "method_not_allowed" }, 405);
  }
  // 2. 鍵が未設定なら 503(設定前でも公開はできるが、実行時に明示的に止める)
  if (!(env.TURNSTILE_SECRET_KEY && env.RESEND_API_KEY)) {
    return json({ ok: false, error: "service_unavailable" }, 503);
  }
  // 3. 同じ IP からの連投を制限(60 秒に 5 件まで)
  const ip = request.headers.get("CF-Connecting-IP");
  if (env.CONTACT_RATE_LIMITER && ip) {
    const { success } = await env.CONTACT_RATE_LIMITER.limit({ key: ip });
    if (!success) return json({ ok: false, error: "rate_limited" }, 429);
  }
  // 4. 入力チェック(必須・文字数上限・メール形式)
  // 5. Turnstile 検証 → NG なら 403
  // 6. Resend でメール送信 → NG なら 502
  return json({ ok: true }, 200);
}

設計の補足を4つ。

連投対策は Cloudflare の機能で済む

連投制限は自分で作り込まなくても、Cloudflare の Rate Limiting が使えます。設定に数行書くだけです。

// wrangler.jsonc(抜粋)
"ratelimits": [
  {
    "name": "CONTACT_RATE_LIMITER",
    "namespace_id": "1001",
    "simple": { "limit": 5, "period": 60 }   // 60 秒に 5 件まで
  }
]

チェックは Turnstile やメール送信より前に置いています。
攻撃されたとき、外部サービスを呼ぶ前の入り口で止めるためです。

ボット対策は、サーバー側の照合が本体

Turnstile は reCAPTCHA の同類です(画像パズルを人間に解かせないのが売り)。
仕組みは、トークンの受け渡しで成り立っています。

  1. フォームのページに、Turnstile が提供するウィジェットを置く。ウィジェットは表示時に訪問者をブラウザ上でチェックし、通過するとトークンを発行するので、フォームが保持しておく
  2. フォームは送信時に、入力値と一緒にトークンも POST の body に含める
  3. Worker は受け取ったトークンを Cloudflare の siteverify エンドポイントに投げ、本物のウィジェットが発行した有効なトークンかを照合してもらう

ウィジェットを置いたら終わり、ではなく、3. の照合までやって初めてボット対策になります。
照合のコードはこれだけです。

// worker/contact.ts(抜粋)
const res = await fetch(
  "https://challenges.cloudflare.com/turnstile/v0/siteverify",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ secret, response: token, remoteip }),
  },
);

これを省くと、フォームを経由せず /api/contact に直接送りつけるボットには無力です。ここは省略しないのが大事です。

HTML インジェクション対策

入力値は私宛の HTML メールに埋め込むので、<> を解釈されないようエスケープしてから使います(worker/contact.tsescapeHtml を用意して、メール本文の組み立て時に必ず通す)。
問い合わせフォームは「見知らぬ誰かの入力が、自分の開くメールになる」仕組みです。ここも省略しないほうがいいところです。

メール送信は Resend の API に POST 1本

チェックをすべて通ったら、組み立てたメールを Resend の REST API に POST して終わりです。

// worker/contact.ts(抜粋)
const res = await fetch("https://api.resend.com/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(message), // from / to / reply_to / subject / text / html
});

実用的な工夫を1つ。メールの reply_to にフォーム送信者のアドレスを入れておくと、届いた問い合わせに受信箱からそのまま返信できます。

フォーム側:トークンを同梱して送る

フォーム画面は React で作り、Astro のページに埋め込んでいます。
ウィジェットを描画すると、チェックを通ったときに callback でトークンが渡ってくるので、送信時に入力値と一緒に body へ同梱して POST します。

// src/components/ContactForm.tsx(抜粋・整理)
turnstile.render(widgetRef.current, {
  sitekey: SITE_KEY,
  callback: (t) => setToken(t), // チェック通過の証としてトークンを受け取る
});

// 送信時
await fetch("/api/contact", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name, email, message, token }),
});

工夫は1つだけ。ウィジェットに必要な「site key」(公開してよい方の鍵)を設定から読み、未設定なら公式のテストキーに切り替わるようにしました。

// src/components/ContactForm.tsx(抜粋)
const SITE_KEY = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY ?? TEST_SITE_KEY;

公式のテストキーでは、必ず「成功」になるウィジェットが表示されます。
おかげで開発中は設定なしでフォーム一式が動き、本番は管理画面で本物の鍵を1つ設定するだけです。コードの書き換えはありません。

管理画面側でやること

コードの外の作業は3つです。

  1. Turnstile のウィジェットを作る(site key と、照合用の secret key をもらう)
  2. Resend に送信元ドメインを登録する(指示された DNS レコードを追加して認証 → API キーをもらう)
  3. もらった鍵のうち秘密の2つ(Turnstile の secret key と Resend の API キー)を Workers の secret として登録する(wrangler secret put かダッシュボードから。コードには書かない)。公開してよい site key はビルド用の環境変数に設定する

手間なのは Resend のドメイン登録だけですが、画面の指示どおりにレコードを足せば通ります。
送信専用のサブドメイン(例: send.example.com)を切っておくと、本来のドメインのメール設定に触らずに済みます。

ハマりどころ

実装そのものより、この2つで時間を使いました。

鍵を設定したのに、まだ「未設定」のレスポンスが返る

secret を登録したのに、送信すると 503(鍵が未設定のときのレスポンス)が返ってくる。
管理画面を見ると鍵は入っている。なぜ?

Workers はデプロイのたびに「バージョン」を作ります。バージョンには、そのデプロイ時点で登録されていたコードと設定(secret や環境変数)のセットが記録され、できあがったバージョンの設定をあとから差し替えることはできません。

今回は、デプロイが先で鍵の登録があとでした。
動いているのは「鍵なし」の時点で作られたバージョンなので、あとから管理画面に鍵を入れても、そこには届きません。もう一度デプロイして「鍵あり」の今の設定で新しいバージョンを作ると、反映されます。

「設定を保存したのに反映されない」は、たいていこれです。鍵や環境変数を入れたら再デプロイ、と覚えておくと無駄にハマらずに済みます。

/api/contact/ で 404 ページが出る

/api/contact/(末尾スラッシュ付き)を開くと、API のレスポンスが戻るのではなくサイトの 404 ページが表示されてしまいました。

原因は前述の振り分けの3段目です。
/api/contact/ に対応する静的ファイルは無いので、リクエスト自体は Worker まで届いています。ところが当時は /api/contact の完全一致で API 判定していたため、スラッシュ付きは API 扱いにならず、ASSETS に投げ返されて「該当ファイルなし → 404 ページ」になっていました。
対処は、入り口のコードのとおり末尾スラッシュを削ってから比較するだけです。

同居構成では「どちらにも一致しなかったリクエストがどこへ流れるか」を意識しておくと、この手の不思議な 404 に慌てずに済みます。

まとめ

静的サイトのまま、フォームのためのサーバーを借りずに問い合わせフォームを作りました。

  • 部品は Cloudflare Workers + Turnstile + Resend の3つ。個人サイトの規模なら無料枠に収まりそうです
  • /api/contact だけ Worker で処理し、残りは静的配信のまま
  • ボット対策・連投制限・HTML インジェクション対策は、それぞれ数行〜数十行で入る
  • ハマりどころは「鍵を設定したら再デプロイ」と「末尾スラッシュの流れ先」

コード全体はこのサイトのリポジトリで公開しています(worker/src/components/ContactForm.tsx)。
参考になれば嬉しいです。