このサイトには問い合わせフォームがあります。
送信するとボット対策を通り、私宛にメールが届く。ごく普通のフォームです。
このサイトは 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段です。
- リクエストはまず
dist/の静的ファイルと照合され、一致すればそのまま配信されます。このとき Worker は動きません - 一致しなかったリクエストだけが Worker に渡ってきます。
/api/contactも、存在しない URL へのアクセスも、ここに来ます - 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 の同類です(画像パズルを人間に解かせないのが売り)。
仕組みは、トークンの受け渡しで成り立っています。
- フォームのページに、Turnstile が提供するウィジェットを置く。ウィジェットは表示時に訪問者をブラウザ上でチェックし、通過するとトークンを発行するので、フォームが保持しておく
- フォームは送信時に、入力値と一緒にトークンも POST の body に含める
- 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.ts に escapeHtml を用意して、メール本文の組み立て時に必ず通す)。
問い合わせフォームは「見知らぬ誰かの入力が、自分の開くメールになる」仕組みです。ここも省略しないほうがいいところです。
メール送信は 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つです。
- Turnstile のウィジェットを作る(site key と、照合用の secret key をもらう)
- Resend に送信元ドメインを登録する(指示された DNS レコードを追加して認証 → API キーをもらう)
- もらった鍵のうち秘密の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)。
参考になれば嬉しいです。
