IT技術ブログ

問い合わせ・予約フォームの送信をCloudflare Pages Functionsで受け、Supabaseに保存して担当者へメールで知らせる最小構成。サーバー側の検証、保存と通知の失敗を分ける作り、個人情報を減らす設計

フォームの送信をCloudflare Pages Functionsで受け、Supabaseに保存してから担当者へメールで知らせる最小構成です。サーバー側の検証、通知を再送できる作り、個人情報の減らし方を手元で動かして確かめました。

この記事の結論:問い合わせや予約のフォームは、①サーバー側で決めた項目だけを取り出して検証し、②先にSupabase(PostgreSQL)へ保存し、③保存できたら受付番号を返して、④担当者へのメールは応答のあとで送る、の順で作ります。通知の状態(未送信・送信中・送信済み・失敗)を受付の行に持たせておけば、メールの送信に失敗しても受付は失われず、再送の受け口から送り直せます。個人情報は、保存する項目・通知メールに載せる項目・持っておく期間の3つで減らします。

この記事では、その最小構成のコードの全文と、手元で動かした結果を載せます。手元では、Cloudflare Pages Functionsをwrangler pages devで動かし、Supabaseの代わりにPGlite(WASMで動くPostgreSQL)を使った代役、メール送信APIの代わりに受け取った中身を記録するだけの代役を置きました。本物のSupabase、本物のメール送信、Turnstileの検証は、この記事のためには動かしていません。

フォームへのボット対策(Turnstileのサーバー側の検証、ハニーポット、送信の回数制限)と、通知メールの送信元の設定は、問い合わせフォームのスパム対策の記事で扱っています。この記事は、その対策を通ったあとの「受け取ってから」を扱います。

処理の順番は、検証、保存、応答、通知

フォームの受け口は、検証、保存、応答、通知の順に処理します。通知を最後に回し、応答のあとで送るのが要点です。

順番 処理 失敗したとき
1 送り元のページと本文の大きさを確かめる 403・413を返す
2 ボット対策の検証(Turnstile) 400を返す
3 決めた項目だけを取り出して検証する 422と、項目ごとの理由を返す
4 データベースに保存する 503を返す。画面には入力内容を残す
5 受付番号を返す (ここで利用者への応答は終わり)
6 担当者へメールで知らせる 受付の行に「失敗」と書き、再送の受け口が拾う

メールを先に送る作りにすると、メールの送信に失敗したときに、問い合わせの中身がどこにも残りません。逆に、保存とメールの両方が終わるまで応答を待たせると、メールの送信先が遅いだけで、利用者の画面が止まります。保存を先にして、通知は応答のあとに回せば、2つの失敗を別々に扱えます。

表と関数:同じ送信IDなら、2件目を作らない

表には、受付の中身と通知の状態を一緒に持たせます。画面を開いたときに作る送信ID(submission_id)に一意の制約を付け、二度押しや再読み込みで同じ送信が2回届いても、2件目を作らずに最初の受付を返します。

schema.sqlsql
-- 問い合わせ・来場予約を保存する表(Supabase の SQL Editor かマイグレーションで流す)
create table public.inquiries (
  id               uuid primary key default gen_random_uuid(),
  receipt_no       text generated always as (upper(left(replace(id::text, '-', ''), 10))) stored unique,
  submission_id    uuid not null unique,              -- 画面を開いたときに作る送信ID(二重送信の判定用)
  kind             text not null check (kind in ('contact', 'visit')),
  name             text not null check (char_length(name) between 1 and 50),
  email            text not null check (char_length(email) between 3 and 254),
  phone            text check (phone ~ '^[0-9]{10,11}$'),
  preferred_date   date,
  message          text not null default '' check (char_length(message) <= 2000),
  consent_version  text not null,                     -- 同意したプライバシーポリシーの版
  created_at       timestamptz not null default now(),
  notify_status    text not null default 'pending'
                   check (notify_status in ('pending', 'sending', 'sent', 'failed')),
  notify_attempts  int not null default 0,
  notify_last_error text,
  notify_updated_at timestamptz not null default now()
);
create index inquiries_notify_idx on public.inquiries (notify_status, created_at);

-- 画面(ブラウザ)からは一切触らせない。読み書きはサーバーの関数から secret key でだけ行う
alter table public.inquiries enable row level security;
revoke all on public.inquiries from public, anon, authenticated;
grant select, insert, update, delete on public.inquiries to service_role;

-- 保存:同じ submission_id が来たら、新しく作らずに最初の受付を返す
create function public.submit_inquiry(
  p_submission_id uuid, p_kind text, p_name text, p_email text, p_phone text,
  p_preferred_date date, p_message text, p_consent_version text
) returns table (id uuid, receipt_no text, created boolean)
language plpgsql as $$
begin
  return query
    insert into public.inquiries as i
      (submission_id, kind, name, email, phone, preferred_date, message, consent_version)
    values
      (p_submission_id, p_kind, p_name, p_email, p_phone, p_preferred_date, p_message, p_consent_version)
    on conflict (submission_id) do nothing
    returning i.id, i.receipt_no, true;
  if not found then
    return query select i.id, i.receipt_no, false from public.inquiries i where i.submission_id = p_submission_id;
  end if;
end $$;

-- 通知の取り出し(1件):まだ送っていない受付を「送信中」にして、回数を1つ増やす
create function public.claim_notification(p_id uuid)
returns table (id uuid, receipt_no text, kind text, preferred_date date, created_at timestamptz, notify_attempts int)
language sql as $$
  update public.inquiries i
     set notify_status = 'sending', notify_attempts = i.notify_attempts + 1, notify_updated_at = now()
   where i.id = p_id and i.notify_status = 'pending'
  returning i.id, i.receipt_no, i.kind, i.preferred_date, i.created_at, i.notify_attempts;
$$;

-- 通知の取り出し(再送用):失敗したもの、送信中のまま止まったもの、取り残された pending をまとめて取る
create function public.claim_notifications(p_limit int default 20, p_max_attempts int default 5)
returns table (id uuid, receipt_no text, kind text, preferred_date date, created_at timestamptz, notify_attempts int)
language sql as $$
  update public.inquiries i
     set notify_status = 'sending', notify_attempts = i.notify_attempts + 1, notify_updated_at = now()
   where i.id in (
     select t.id from public.inquiries t
      where t.notify_attempts < p_max_attempts
        and (t.notify_status = 'failed'
             or (t.notify_status in ('pending', 'sending') and t.notify_updated_at < now() - interval '10 minutes'))
      order by t.created_at
      limit p_limit
      for update skip locked)
  returning i.id, i.receipt_no, i.kind, i.preferred_date, i.created_at, i.notify_attempts;
$$;

-- 通知の結果を書く(エラーの文には個人情報を入れない)
create function public.mark_notification(p_id uuid, p_ok boolean, p_error text default null)
returns void language sql as $$
  update public.inquiries
     set notify_status = case when p_ok then 'sent' else 'failed' end,
         notify_last_error = case when p_ok then null else left(p_error, 200) end,
         notify_updated_at = now()
   where id = p_id;
$$;

-- 保存期間を過ぎた受付を消す(期間は自社のプライバシーポリシーに合わせる)
create function public.purge_inquiries(p_keep interval)
returns int language sql as $$
  with d as (delete from public.inquiries where created_at < now() - p_keep returning 1)
  select count(*)::int from d;
$$;

-- 関数も、サーバー(service_role)からだけ呼べるようにする
revoke execute on function public.submit_inquiry, public.claim_notification, public.claim_notifications,
  public.mark_notification, public.purge_inquiries from public, anon, authenticated;
grant execute on function public.submit_inquiry, public.claim_notification, public.claim_notifications,
  public.mark_notification, public.purge_inquiries to service_role;

作りの要点は4つです。

  • 表には、画面から一切触らせない。 RLSを有効にしてポリシーを1つも作らず、anonとauthenticatedからは表の権限も関数の実行権限も外しています。読み書きは、サーバーの関数からシークレットキーで呼んだときだけです。RLSの考え方は、SupabaseのRLSで店舗ごとにデータを分ける記事に詳しく書いています。
  • 表への直接の操作ではなく、決まった処理だけをする関数を呼ぶ。 シークレットキーはRLSを素通りします。受け口から呼べる操作を「受付を保存する」「通知を取り出す」「結果を書く」に限っておけば、受け口のコードに不具合があっても、ほかの行を書き換えにくくなります。
  • 通知の取り出しで、状態と回数を一度に変える。 「送信中」にして回数を1つ増やす更新を1つのSQLで行うので、同じ受付を2か所から同時に送ることを防げます。再送用の取り出しにはfor update skip lockedを付け、ほかの処理が掴んでいる行は飛ばします。
  • データベースの側にも、入力の制約を書く。 名前の長さ、電話番号の形、種別の値は、サーバーの検証と同じ内容をcheck制約にも書いています。サーバーの検証に抜けがあっても、おかしな値は保存されません。

受付番号は、IDの先頭10文字を大文字にしたものです。連番にすると、番号から受付の件数が分かってしまうため、連番にはしていません。

入力の検証は、決めた項目だけを取り出してから行う

サーバー側の検証では、送られてきた中身をそのまま使わず、決めた項目だけを取り出してから確かめます。画面の側の検証は入力しやすくするためのもので、サーバーの側の検証は、画面を通らずに直接送られたものから守るためのものです。

lib/validate.jsjs
// サーバー側の入力の検証。画面側の検証は使いやすさのため、こちらは守りのため(両方に置く)
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const DATE = /^\d{4}-\d{2}-\d{2}$/;

// 文字列として取り出し、全角英数字を半角にそろえ、前後の空白を落とす
const str = (v) => (typeof v === 'string' ? v : '').normalize('NFKC').trim();

// 日本時間の「今日」を YYYY-MM-DD で(サーバーは UTC で動くため)
const todayJst = (now = new Date()) => new Date(now.getTime() + 9 * 3600e3).toISOString().slice(0, 10);
const addDays = (ymd, n) => new Date(Date.parse(`${ymd}T00:00:00Z`) + n * 864e5).toISOString().slice(0, 10);

export function validateInquiry(input, { now = new Date(), maxDaysAhead = 180 } = {}) {
  const errors = {};
  // 決めた項目だけを取り出す(知らない項目は保存しない)
  const v = {
    submission_id: str(input.submission_id),
    kind: str(input.kind),
    name: str(input.name),
    email: str(input.email).toLowerCase(),
    phone: str(input.phone).replace(/[-‐-ー\s()]/g, '') || null,
    preferred_date: str(input.preferred_date) || null,
    message: str(input.message),
    consent: str(input.consent),
  };

  if (!UUID.test(v.submission_id)) errors.submission_id = '画面を読み込み直してから送ってください';
  if (!['contact', 'visit'].includes(v.kind)) errors.kind = '種別を選んでください';
  if ([...v.name].length < 1 || [...v.name].length > 50) errors.name = 'お名前は1〜50文字で入力してください';
  if (v.email.length > 254 || !EMAIL.test(v.email)) errors.email = 'メールアドレスの形式を確かめてください';
  if (v.phone !== null && !/^[0-9]{10,11}$/.test(v.phone)) errors.phone = '電話番号は10〜11桁の数字で入力してください';
  if ([...v.message].length > 2000) errors.message = 'ご相談内容は2000文字以内で入力してください';
  if (v.kind === 'contact' && !v.message) errors.message = 'ご相談内容を入力してください';

  if (v.kind === 'visit') {
    const min = addDays(todayJst(now), 1);
    const max = addDays(todayJst(now), maxDaysAhead);
    const valid = v.preferred_date && DATE.test(v.preferred_date) &&
      new Date(`${v.preferred_date}T00:00:00Z`).toISOString().slice(0, 10) === v.preferred_date; // 2月30日などを弾く
    if (!valid) errors.preferred_date = '希望日を選んでください';
    else if (v.preferred_date < min || v.preferred_date > max) errors.preferred_date = `希望日は${min}〜${max}の間で選んでください`;
  } else {
    v.preferred_date = null; // 問い合わせでは希望日を持たない
  }

  if (v.consent !== 'yes') errors.consent = 'プライバシーポリシーへの同意が必要です';
  delete v.consent;
  return { value: v, errors };
}

項目ごとに決めたことは次のとおりです。

項目 整え方 確かめること
すべて 全角英数字を半角に(NFKC)、前後の空白を落とす 文字列でなければ空として扱う
メールアドレス 小文字にそろえる 254文字以内、@と.を含む簡単な形
電話番号 ハイフン・空白・括弧を取る 数字10〜11桁。空なら持たない
希望日 なし 実在する日付で、日本時間の翌日から180日以内
相談内容 なし 2000文字以内。問い合わせでは必須
同意 なし 同意していなければ受け付けない

メールアドレスの形は、厳密に確かめようとすると、正しいアドレスまで落とすことがあります。ここでは明らかな入力の誤りだけを止め、届くかどうかは、自動返信などで実際に送って確かめる考え方にしています。希望日の範囲は、サーバーがUTCで動くため、日本時間の今日を計算してから決めています。

受け口:保存できたら受付番号を返し、通知は応答のあとで送る

受け口の関数は、保存に失敗したら503を返してメールも送らず、保存できたら受付番号を返して、通知はwaitUntilで応答のあとに送ります。

functions/api/inquiry.jsjs
// POST /api/inquiry  問い合わせ・来場予約の受け口(Cloudflare Pages Functions)
import { validateInquiry } from '../../lib/validate.js';
import { rpc, deliver } from '../../lib/backend.js';

const json = (status, body) =>
  new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8' } });

export async function onRequestPost(context) {
  const { request, env } = context;

  // 1. 受け取る前の確認(送り元のページと、本文の大きさ)
  const origin = request.headers.get('origin');
  if (origin && !env.ALLOWED_ORIGINS.split(',').includes(origin)) return json(403, { message: '送信元が許可されていません' });
  if (Number(request.headers.get('content-length') ?? 0) > 16 * 1024) return json(413, { message: '送信内容が大きすぎます' });

  let input;
  try {
    const type = request.headers.get('content-type') ?? '';
    input = type.includes('application/json') ? await request.json() : Object.fromEntries(await request.formData());
  } catch {
    return json(400, { message: '送信内容を読み取れませんでした' });
  }

  // 2. ボット対策(Turnstile のサーバー側の検証)。中身は別の記事の実装をそのまま使う
  if (!(await verifyHuman(input['cf-turnstile-response'], request, env))) {
    return json(400, { message: '送信を確認できませんでした。画面を読み込み直してください' });
  }

  // 3. 入力の検証(決めた項目だけを取り出す)
  const { value, errors } = validateInquiry(input);
  if (Object.keys(errors).length) return json(422, { errors });

  // 4. 先に保存する。保存できなければ、メールも送らずに失敗を返す
  let saved;
  try {
    [saved] = await rpc(env, 'submit_inquiry', {
      p_submission_id: value.submission_id, p_kind: value.kind, p_name: value.name, p_email: value.email,
      p_phone: value.phone, p_preferred_date: value.preferred_date, p_message: value.message,
      p_consent_version: env.PRIVACY_POLICY_VERSION,
    });
  } catch (e) {
    console.error(JSON.stringify({ event: 'save_failed', status: e.status, code: e.code }));
    return json(503, { message: '送信できませんでした。入力内容はそのままです。時間をおいてもう一度送ってください' });
  }

  // 5. 通知は応答のあとで送る。失敗しても受付は済んでいる(再送の仕組みが拾う)
  if (saved.created) {
    context.waitUntil(
      rpc(env, 'claim_notification', { p_id: saved.id })
        .then(([row]) => row && deliver(env, row))
        .catch((e) => console.error(JSON.stringify({ event: 'claim_failed', receipt_no: saved.receipt_no, error: e.message }))),
    );
  }
  return json(200, { receipt_no: saved.receipt_no, duplicate: !saved.created });
}

async function verifyHuman(token, request, env) {
  // 手元の確認だけで使う抜け道。本番の環境変数には TURNSTILE_BYPASS を入れない
  if (env.TURNSTILE_BYPASS === 'local' && new URL(request.url).hostname === 'localhost') return true;
  const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
    method: 'POST',
    body: new URLSearchParams({ secret: env.TURNSTILE_SECRET, response: token ?? '' }),
  });
  const v = await res.json();
  return v.success === true && v.action === 'inquiry';
}
lib/backend.jsjs
// Supabase(PostgREST の RPC)とメール送信の呼び出し
export async function rpc(env, fn, args) {
  const res = await fetch(`${env.SUPABASE_URL}/rest/v1/rpc/${fn}`, {
    method: 'POST',
    headers: { 'content-type': 'application/json', apikey: env.SUPABASE_SECRET_KEY },
    body: JSON.stringify(args),
    signal: AbortSignal.timeout(8000),
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw Object.assign(new Error(`rpc ${fn} failed: ${res.status} ${body.code ?? ''}`), { status: res.status, code: body.code });
  }
  return res.status === 204 ? null : res.json();
}

// 担当者への通知。名前・メール・相談内容は載せず、受付番号と管理画面へのリンクだけにする
export async function sendStaffMail(env, row) {
  const createdJst = new Date(Date.parse(row.created_at) + 9 * 3600e3).toISOString().replace('T', ' ').slice(0, 16);
  const kindLabel = row.kind === 'visit' ? '来場予約' : '問い合わせ';
  const text = [
    `${kindLabel}を受け付けました。`,
    `受付番号: ${row.receipt_no}`,
    `受付日時: ${createdJst}(日本時間)`,
    row.preferred_date ? `希望日: ${row.preferred_date}` : null,
    '',
    `内容は管理画面で確認してください: ${env.ADMIN_URL}/inquiries/${row.id}`,
  ].filter((l) => l !== null).join('\n');

  const res = await fetch(env.MAIL_API_URL, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      authorization: `Bearer ${env.MAIL_API_KEY}`,
      'idempotency-key': `inquiry-notify-${row.id}`, // 同じ受付の通知を二重に送らないための鍵
    },
    body: JSON.stringify({
      from: env.NOTIFY_FROM,
      to: env.NOTIFY_TO.split(','),
      subject: `[${kindLabel}] 受付番号 ${row.receipt_no}`, // 入力された値は件名に入れない
      text,
    }),
    signal: AbortSignal.timeout(8000),
  });
  if (!res.ok) throw new Error(`mail api ${res.status}`);
}

// 取り出した1件を送り、結果を書く。失敗しても例外を外へ出さない
export async function deliver(env, row) {
  try {
    await sendStaffMail(env, row);
    await rpc(env, 'mark_notification', { p_id: row.id, p_ok: true });
    return 'sent';
  } catch (e) {
    console.error(JSON.stringify({ event: 'notify_failed', receipt_no: row.receipt_no, attempt: row.notify_attempts, error: e.message }));
    await rpc(env, 'mark_notification', { p_id: row.id, p_ok: false, p_error: e.message }).catch(() => {});
    return 'failed';
  }
}

waitUntilに渡した処理は、応答を返したあとも動き続けます。ただし、Cloudflareの説明では、応答のあとに動ける時間は1つのリクエストにつき合計30秒までで、それを超えると打ち切られます。そのため、waitUntilの中で送れなかったものを拾い直す仕組みが要ります。時間内に終わらない処理については、Cloudflareの説明はQueues(確実な配信と自動の再試行を備える仕組み)に積んで別のWorkerで処理する形を案内しているので、その形も選べます。この記事では、表の状態を見て送り直す受け口を作りました。

ログには、名前やメールアドレスを出さず、受付番号とエラーの種類だけを出しています。エラーの調べ物のためにログを見る人と、問い合わせの中身を見てよい人は、同じとは限らないからです。

verifyHumanの中のTurnstileの検証は、手元では動かしていません。手元で確かめるときだけ、環境変数TURNSTILE_BYPASS=localと、ホスト名がlocalhostであることの両方がそろったときに検証を飛ばしています。本番の環境変数には入れません。

送れなかった通知は、再送の受け口で拾い直す

通知に失敗した受付と、送信中のまま10分以上止まっている受付は、再送の受け口がまとめて取り出し、1件ずつ送り直します。5回試して送れなかったものは取り出さず、「失敗」のまま残します。

functions/api/notify-retry.jsjs
// POST /api/notify-retry  送れなかった通知を送り直す(定期実行の仕組みから呼ぶ)
import { rpc, deliver } from '../../lib/backend.js';

export async function onRequestPost({ request, env }) {
  const auth = request.headers.get('authorization') ?? '';
  if (!env.RETRY_TOKEN || !(await sameText(auth, `Bearer ${env.RETRY_TOKEN}`))) {
    return new Response('unauthorized', { status: 401 });
  }
  const rows = await rpc(env, 'claim_notifications', { p_limit: 20, p_max_attempts: 5 });
  const result = { tried: rows.length, sent: 0, failed: 0 };
  for (const row of rows) result[await deliver(env, row)]++; // 1件ずつ順に送る
  return Response.json(result);
}

// 文字列の比較で、一致した長さが応答時間から漏れないようにする
async function sameText(a, b) {
  const enc = new TextEncoder();
  const [x, y] = await Promise.all([a, b].map((s) => crypto.subtle.digest('SHA-256', enc.encode(s))));
  const u = new Uint8Array(x), w = new Uint8Array(y);
  let diff = 0;
  for (let i = 0; i < u.length; i++) diff |= u[i] ^ w[i];
  return diff === 0;
}

この受け口は、定期的に実行する仕組み(Cron TriggersのWorkerなど)から、決めた合言葉を付けて呼ぶ想定です。定期実行の側は、この記事では作っていません。5回で打ち切った受付は、送り直しても届かない理由(宛先の設定の誤り、メール送信サービスの契約切れなど)がある可能性が高いので、notify_status = 'failed' and notify_attempts >= 5の件数を毎日数え、1件でもあれば別の経路で担当者に知らせます。

メールを二重に送らないために、メール送信APIには受付ごとに決まったidempotency-keyを付けています。この記事の代役は、Resendの送信APIの形(from・to・subject・textと、24時間有効なIdempotency-Keyのヘッダー)に合わせました。同じ鍵で送り直したときに、前回が失敗していた場合どう扱われるかは、使うメール送信サービスの説明で確かめてください。

個人情報は、持つ項目・載せる場所・持つ期間の3つで減らす

個人情報は、受け取った中身を全部持たないこと、通知メールやログに広げないこと、要らなくなったら消すことの3つで減らします。個人情報保護法では、利用する必要がなくなった個人データは遅滞なく消去するよう努めること(第22条)と、漏えいなどを防ぐ安全管理の措置(第23条)が求められています。

減らし方 この構成での作り
持つ項目を決める 決めた8項目だけを保存する。送られてきたほかの項目(広告の計測値やIPアドレスなど)は保存しない
電話番号は任意にする 空ならnull。来場予約でも必須にしていない
同意の記録は版だけを持つ 同意したプライバシーポリシーの版(consent_version)を持ち、本文は持たない
通知メールに載せない 受付番号・種別・受付日時・希望日と、管理画面へのリンクだけ。名前・連絡先・相談内容は載せない
ログに出さない 受付番号とエラーの種類だけ。エラーの文も200文字で切り、個人情報を含めない
見られる人を絞る 表は画面の鍵(publishable key)からは読めない。管理画面は、ログインした担当者だけが見る
持つ期間を決める purge_inquiriesで、決めた期間を過ぎた受付を消す。期間はプライバシーポリシーに合わせる

通知メールに中身を載せないと、担当者は管理画面を開かないと内容が分かりません。管理画面がまだ無い会社では、名前だけは載せる、などの折り合いを、社内で決めてから作ります。問い合わせた人に直接返信したいなら、メールアドレスを返信先(Reply-To)に入れる形もありますが、そのぶん受信箱に個人情報が残ることになります。

手元で動かした結果

手元では、wrangler pages devで受け口を動かし、Supabaseの代役(PGliteにschema.sqlを流し、RPCの呼び出しだけを真似るサーバー)と、メール送信APIの代役を立てて、9つの場面を流しました。

Supabaseの代役は、PostgRESTのPOST /rest/v1/rpc/関数名の形で受け、JSONの各項目を関数の引数に渡します。apikeyヘッダーの鍵によって、service_roleかanonの役割に切り替えてから関数を呼ぶので、schema.sqlの権限の設定がそのまま効きます。

mock/supabase-mock.mjs(手元の確認用)js
// 手元の確認用:Supabase の RPC(POST /rest/v1/rpc/関数名)だけを真似るサーバー。
// 中身のデータベースは PGlite(WASM で動く PostgreSQL)で、schema.sql をそのまま流す
import http from 'node:http';
import fs from 'node:fs';
import { PGlite } from '@electric-sql/pglite';

const SECRET = process.env.MOCK_SECRET_KEY;
const PUBLISHABLE = process.env.MOCK_PUBLISHABLE_KEY;
// PostgREST と同じく、date 型は "2026-10-20" の文字列で返す(1082 は date 型の番号)
const db = new PGlite({ parsers: { 1082: (v) => v } });
// Supabase にある役割を手元にも作る(service_role は RLS を通り抜ける)
await db.exec(`create role anon nologin; create role authenticated nologin; create role service_role nologin bypassrls;
               grant usage on schema public to anon, authenticated, service_role;`);
await db.exec(fs.readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'));

const VOID_FNS = new Set(['mark_notification']);
const SCALAR_FNS = new Set(['purge_inquiries']);
let failNext = 0;

const send = (res, status, body) => {
  res.writeHead(status, { 'content-type': 'application/json' });
  res.end(body === undefined ? '' : JSON.stringify(body));
};
const readJson = async (req) => { let s = ''; for await (const c of req) s += c; return s ? JSON.parse(s) : {}; };

http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://x');
  try {
    if (url.pathname === '/__fail') { failNext = (await readJson(req)).n ?? 1; return send(res, 200, { failNext }); }
    if (url.pathname === '/__rows') {
      const r = await db.query(`select receipt_no, kind, name, email, phone, preferred_date, message, consent_version,
        notify_status, notify_attempts, notify_last_error from public.inquiries order by created_at`);
      return send(res, 200, r.rows);
    }
    const m = url.pathname.match(/^\/rest\/v1\/rpc\/(\w+)$/);
    if (!m || req.method !== 'POST') return send(res, 404, { message: 'not found' });

    const key = req.headers.apikey;
    const role = key === SECRET ? 'service_role' : key === PUBLISHABLE ? 'anon' : null;
    if (!role) return send(res, 401, { message: 'Invalid API key' });
    if (failNext > 0) { failNext--; return send(res, 503, { message: 'mock: database unavailable' }); }

    const fn = m[1];
    const args = await readJson(req);
    const names = Object.keys(args);
    if (names.some((n) => !/^p_\w+$/.test(n))) return send(res, 400, { message: 'bad arg' });
    const sql = `select * from public.${fn}(${names.map((n, i) => `${n} => $${i + 1}`).join(', ')})`;
    const rows = await db.transaction(async (tx) => {
      await tx.exec(`set local role ${role}`);
      return (await tx.query(sql, names.map((n) => args[n]))).rows;
    });
    if (VOID_FNS.has(fn)) return send(res, 204);
    if (SCALAR_FNS.has(fn)) return send(res, 200, Object.values(rows[0])[0]);
    return send(res, 200, rows);
  } catch (e) {
    const status = e.code === '23505' ? 409 : e.code === '42501' ? 403 : 400;
    return send(res, status, { code: e.code, message: e.message });
  }
}).listen(54321, () => console.log('supabase mock on :54321'));
mock/mail-mock.mjs(手元の確認用)js
// 手元の確認用:メール送信APIの代わり。実際には送らず、受け取った中身を記録するだけ
import http from 'node:http';

const KEY = process.env.MOCK_MAIL_KEY;
const sent = [];
const done = new Map(); // idempotency-key → 返した id
let failNext = 0;

const send = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
const readJson = async (req) => { let s = ''; for await (const c of req) s += c; return s ? JSON.parse(s) : {}; };

http.createServer(async (req, res) => {
  if (req.url === '/__fail') { failNext = (await readJson(req)).n ?? 1; return send(res, 200, { failNext }); }
  if (req.url === '/__sent') return send(res, 200, sent);
  if (req.url !== '/emails' || req.method !== 'POST') return send(res, 404, {});
  if (req.headers.authorization !== `Bearer ${KEY}`) return send(res, 401, { message: 'bad key' });
  const body = await readJson(req);
  if (failNext > 0) { failNext--; return send(res, 500, { message: 'mock: mail provider error' }); }
  const idem = req.headers['idempotency-key'];
  if (idem && done.has(idem)) return send(res, 200, { id: done.get(idem) }); // 同じ鍵なら送り直さない
  const id = `mock-${sent.length + 1}`;
  sent.push({ id, idempotencyKey: idem, ...body });
  if (idem) done.set(idem, id);
  return send(res, 200, { id });
}).listen(8025, () => console.log('mail mock on :8025'));

手元で使った環境変数は、.dev.varsに置きました。すべて手元だけの仮の値です。

.dev.vars(手元の確認用。gitには入れない)
SUPABASE_URL="http://127.0.0.1:54321"
SUPABASE_SECRET_KEY="sb_secret_local_dummy"
MAIL_API_URL="http://127.0.0.1:8025/emails"
MAIL_API_KEY="re_local_dummy"
NOTIFY_FROM="[email protected]"
NOTIFY_TO="[email protected]"
ADMIN_URL="https://admin.example.co.jp"
ALLOWED_ORIGINS="http://localhost:8788"
PRIVACY_POLICY_VERSION="2026-10-01"
RETRY_TOKEN="local-retry-token"
TURNSTILE_BYPASS="local"

流した手順と結果です。日付の範囲は、2026年10月4日に流したときのものです。

test.shbash
#!/bin/bash
# 手元の確認の手順(wrangler pages dev と2つの代役を起動してから流す)
API=http://localhost:8788/api/inquiry
post() { curl -s -w '  -> HTTP %{http_code}\n' -X POST "$API" -H 'content-type: application/json' -H 'origin: http://localhost:8788' -d "$1"; }
rows() { curl -s http://127.0.0.1:54321/__rows; echo; }
S1=11111111-1111-4111-8111-111111111111
S2=22222222-2222-4222-8222-222222222222
S3=33333333-3333-4333-8333-333333333333

echo '## 1. 来場予約を送る'
post '{"submission_id":"'$S1'","kind":"visit","name":"山田 花子","email":"[email protected]","phone":"090-1234-5678","preferred_date":"2026-10-20","message":"","consent":"yes","utm_source":"x","ip":"203.0.113.1"}'
sleep 1
echo '## 2. 同じ送信IDでもう一度送る(二度押し・再読み込み)'
post '{"submission_id":"'$S1'","kind":"visit","name":"山田 花子","email":"[email protected]","preferred_date":"2026-10-20","consent":"yes"}'
echo '## 3. 入力の誤り(過去の日付・メールの形式・同意なし)'
post '{"submission_id":"'$S2'","kind":"visit","name":"","email":"hanako@example","preferred_date":"2026-10-01","consent":""}'
echo '## 4. メールAPIが失敗する'
curl -s -X POST http://127.0.0.1:8025/__fail -d '{"n":1}' >/dev/null
post '{"submission_id":"'$S2'","kind":"contact","name":"佐藤 一郎","email":"[email protected]","message":"見積もりの相談です","consent":"yes"}'
sleep 1
echo '## 5. データベースが失敗する'
curl -s -X POST http://127.0.0.1:54321/__fail -d '{"n":1}' >/dev/null
post '{"submission_id":"'$S3'","kind":"contact","name":"鈴木 次郎","email":"[email protected]","message":"資料がほしいです","consent":"yes"}'
echo '## 保存された行'
rows
echo '## 6. 再送(鍵なし・鍵あり)'
curl -s -w '  -> HTTP %{http_code}\n' -X POST http://localhost:8788/api/notify-retry
curl -s -w '  -> HTTP %{http_code}\n' -X POST http://localhost:8788/api/notify-retry -H 'authorization: Bearer local-retry-token'
echo '## 7. 画面用の鍵(publishable)で関数を直接呼ぶ'
curl -s -w '  -> HTTP %{http_code}\n' -X POST http://127.0.0.1:54321/rest/v1/rpc/submit_inquiry -H 'apikey: sb_publishable_local_dummy' -H 'content-type: application/json' \
  -d '{"p_submission_id":"'$S3'","p_kind":"contact","p_name":"x","p_email":"[email protected]","p_phone":null,"p_preferred_date":null,"p_message":"x","p_consent_version":"v"}'
echo '## 最後の状態'
rows
echo '## メールの代役が受け取った通知'
curl -s http://127.0.0.1:8025/__sent; echo
echo '## 8. メールAPIが止まり続ける(5回で打ち切る)'
S4=44444444-4444-4444-8444-444444444444
curl -s -X POST http://127.0.0.1:8025/__fail -d '{"n":10}' >/dev/null
post '{"submission_id":"'$S4'","kind":"contact","name":"田中 三郎","email":"[email protected]","message":"相談です","consent":"yes"}'
sleep 1
for i in 1 2 3 4 5; do curl -s -X POST http://localhost:8788/api/notify-retry -H 'authorization: Bearer local-retry-token'; echo; done
curl -s http://127.0.0.1:54321/__rows | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).map(r=>`${r.receipt_no} ${r.notify_status} attempts=${r.notify_attempts} ${r.notify_last_error??""}`).join("\n")))'
echo '## 9. 保存期間を過ぎた受付を消す(確認のため期間0で実行)'
curl -s -X POST http://127.0.0.1:54321/rest/v1/rpc/purge_inquiries -H 'apikey: sb_secret_local_dummy' -H 'content-type: application/json' -d '{"p_keep":"0 seconds"}'; echo
rows
## 1. 来場予約を送る
{"receipt_no":"143BEF7DBA","duplicate":false}  -> HTTP 200
## 2. 同じ送信IDでもう一度送る(二度押し・再読み込み)
{"receipt_no":"143BEF7DBA","duplicate":true}  -> HTTP 200
## 3. 入力の誤り(過去の日付・メールの形式・同意なし)
{"errors":{"name":"お名前は1〜50文字で入力してください","email":"メールアドレスの形式を確かめてください","preferred_date":"希望日は2026-10-05〜2027-04-02の間で選んでください","consent":"プライバシーポリシーへの同意が必要です"}}  -> HTTP 422
## 4. メールAPIが失敗する
{"receipt_no":"BEB903372C","duplicate":false}  -> HTTP 200
## 5. データベースが失敗する
{"message":"送信できませんでした。入力内容はそのままです。時間をおいてもう一度送ってください"}  -> HTTP 503
## 保存された行
[{"receipt_no":"143BEF7DBA","kind":"visit","name":"山田 花子","email":"[email protected]","phone":"09012345678","preferred_date":"2026-10-20","message":"","consent_version":"2026-10-01","notify_status":"sent","notify_attempts":1,"notify_last_error":null},{"receipt_no":"BEB903372C","kind":"contact","name":"佐藤 一郎","email":"[email protected]","phone":null,"preferred_date":null,"message":"見積もりの相談です","consent_version":"2026-10-01","notify_status":"failed","notify_attempts":1,"notify_last_error":"mail api 500"}]
## 6. 再送(鍵なし・鍵あり)
unauthorized  -> HTTP 401
{"tried":1,"sent":1,"failed":0}  -> HTTP 200
## 7. 画面用の鍵(publishable)で関数を直接呼ぶ
{"code":"42501","message":"permission denied for function submit_inquiry"}  -> HTTP 403
## 最後の状態
[{"receipt_no":"143BEF7DBA","kind":"visit","name":"山田 花子","email":"[email protected]","phone":"09012345678","preferred_date":"2026-10-20","message":"","consent_version":"2026-10-01","notify_status":"sent","notify_attempts":1,"notify_last_error":null},{"receipt_no":"BEB903372C","kind":"contact","name":"佐藤 一郎","email":"[email protected]","phone":null,"preferred_date":null,"message":"見積もりの相談です","consent_version":"2026-10-01","notify_status":"sent","notify_attempts":2,"notify_last_error":null}]
## メールの代役が受け取った通知
[{"id":"mock-1","idempotencyKey":"inquiry-notify-143bef7d-babb-480b-9762-f240a17904ba","from":"[email protected]","to":["[email protected]"],"subject":"[来場予約] 受付番号 143BEF7DBA","text":"来場予約を受け付けました。\n受付番号: 143BEF7DBA\n受付日時: 2026-10-04 22:02(日本時間)\n希望日: 2026-10-20\n\n内容は管理画面で確認してください: https://admin.example.co.jp/inquiries/143bef7d-babb-480b-9762-f240a17904ba"},{"id":"mock-2","idempotencyKey":"inquiry-notify-beb90337-2c0d-4234-bd69-4ea2f0126815","from":"[email protected]","to":["[email protected]"],"subject":"[問い合わせ] 受付番号 BEB903372C","text":"問い合わせを受け付けました。\n受付番号: BEB903372C\n受付日時: 2026-10-04 22:02(日本時間)\n\n内容は管理画面で確認してください: https://admin.example.co.jp/inquiries/beb90337-2c0d-4234-bd69-4ea2f0126815"}]
## 8. メールAPIが止まり続ける(5回で打ち切る)
{"receipt_no":"BB3C68E124","duplicate":false}  -> HTTP 200
{"tried":1,"sent":0,"failed":1}
{"tried":1,"sent":0,"failed":1}
{"tried":1,"sent":0,"failed":1}
{"tried":1,"sent":0,"failed":1}
{"tried":0,"sent":0,"failed":0}
143BEF7DBA sent attempts=1 
BEB903372C sent attempts=2 
BB3C68E124 failed attempts=5 mail api 500
## 9. 保存期間を過ぎた受付を消す(確認のため期間0で実行)
3
[]

結果から確かめられたことは次のとおりです。

場面 結果
1. 来場予約を送る 200で受付番号が返り、メールの代役に通知が1通届いた。電話番号は全角とハイフンが整えられ、送ったutm_sourceとipは保存されていない
2. 同じ送信IDで再送 同じ受付番号がduplicate: trueで返り、行も通知も増えなかった
3. 入力の誤り 422で、名前・メール・希望日・同意の4項目の理由が返った
4. メールAPIの失敗 利用者には200で受付番号が返り、行はfailed・1回目・mail api 500になった
5. データベースの失敗 503が返り、メールは送られなかった
6. 再送 合言葉なしは401。合言葉ありでは4の受付が送られ、sent・2回目になった
7. 画面の鍵で関数を呼ぶ permission denied for function submit_inquiryで拒まれた(代役は403で返す。本物のPostgRESTは、ログインしていない利用者のこの拒否を401で返す)
8. メールAPIが止まり続ける 1回目と再送4回で5回失敗し、6回目の取り出しでは0件になった。行はfailed・5回のまま残った
9. 保存期間を過ぎた受付を消す 期間0で実行し、3件消えて表が空になった

ログには、4・5・8の場面で次の7行が出ました。名前やメールアドレスは含まれていません。

{"event":"notify_failed","receipt_no":"BEB903372C","attempt":1,"error":"mail api 500"}
{"event":"save_failed","status":503}
{"event":"notify_failed","receipt_no":"BB3C68E124","attempt":1,"error":"mail api 500"}
{"event":"notify_failed","receipt_no":"BB3C68E124","attempt":2,"error":"mail api 500"}
{"event":"notify_failed","receipt_no":"BB3C68E124","attempt":3,"error":"mail api 500"}
{"event":"notify_failed","receipt_no":"BB3C68E124","attempt":4,"error":"mail api 500"}
{"event":"notify_failed","receipt_no":"BB3C68E124","attempt":5,"error":"mail api 500"}

最初に流したときは、通知メールの希望日が2026-10-20T00:00:00.000Zと出ました。PGliteがdate型をJavaScriptの日付に変えて返していたためで、PostgRESTは2026-10-20の文字列で返すため、代役の側を直しました。代役と本物の違いで、本番には無い不具合が出ることも、無い不具合が隠れることもあるので、本物のSupabaseにつないだ確認は別に要ります。

作ったフォームが止まっていないか、通知が届いているかを公開後に見張る方法は、会社のコラム予約・問い合わせフォームが止まっていないか、見張る方法にまとめています。株式会社bundlyzeでは、予約管理システムを企画から運用まで開発しており、フォームの受け口と管理画面を合わせた相談は、システム開発のページで受け付けています。

動作確認した環境

確認日は2026年10月4日、使ったのは手元のMacです。

項目 バージョン・内容
OS macOS 26(Darwin 25.6.0)
Node.js 22.23.2
Wrangler 4.147.0(wrangler pages dev、ポート8788)
データベース PGlite 0.5.8(PostgreSQL 18.3)。Supabaseのanon・authenticated・service_roleの役割を作ってschema.sqlを流した
メール送信 受け取った中身を記録するだけの代役。実際には送っていない

本物のSupabase(PostgREST)への接続、本物のメール送信サービスでの送信、Turnstileの検証、Cloudflareの本番の環境でのwaitUntilの動き、定期実行からの再送の呼び出しは、この記事のためには動かしていません。同時に多数の送信が来たときの動きや、送信中のまま10分止まった受付を拾い直す場面も試していません。

参照した公式ドキュメント(確認日:2026年10月4日)

よくある質問

フォームの送信を受けたら、先にメールを送るべきですか、先に保存するべきですか?

先に保存します。保存できたら利用者には受付番号を返し、担当者への通知はそのあとで送ります。メールを先に送る作りだと、メールの送信に失敗したときに問い合わせの中身がどこにも残りません。保存を先にしておけば、通知に失敗しても、保存した受付から送り直せます。

通知メールの送信に失敗したら、利用者にはエラーを見せるべきですか?

見せません。保存までできていれば、受付は済んでいます。そこでエラーを見せると、利用者がもう一度送り、同じ問い合わせが2件になります。通知の失敗は受付の行に記録し、再送の仕組みで送り直します。保存そのものに失敗したときだけ、入力内容を画面に残したまま「もう一度送ってください」と伝えます。

担当者への通知メールに、問い合わせの内容を全部載せてもよいですか?

載せることはできますが、メールの受信箱にも個人情報が残り、転送や誤送信で広がりやすくなります。この記事の構成では、通知には受付番号・種別・受付日時・希望日と管理画面へのリンクだけを載せ、名前や連絡先、相談内容はデータベースにだけ置いています。管理画面が無い場合は、どこまで載せるかを社内で決めてから作ります。

SupabaseのシークレットキーをPages Functionsで使っても大丈夫ですか?

Pages Functionsはサーバーの側で動くので、シークレットキーを環境変数(シークレット)として置けば、利用者のブラウザには届きません。ただし、シークレットキーはRLSを素通りするので、関数のコードの中で扱う行をきちんと絞る必要があります。この記事では、表への直接の操作ではなく、決まった処理だけをするデータベースの関数を呼ぶ形にしています。