問い合わせ・予約フォームの送信をCloudflare Pages Functionsで受け、Supabaseに保存して担当者へメールで知らせる最小構成。サーバー側の検証、保存と通知の失敗を分ける作り、個人情報を減らす設計
フォームの
この
このwrangler pages devで
フォームへの
処理の順番は、検証、保存、応答、通知
フォームの
| 順番 | 処理 | 失敗した |
|---|---|---|
| 1 | 送り元の |
403・413を |
| 2 | ボット対策の |
400を |
| 3 | 決めた |
422と、 |
| 4 | データベースに |
503を |
| 5 | 受付番号を |
(ここで |
| 6 | 担当者へ |
受付の |
メールを
表と関数:同じ送信IDなら、2件目を作らない
表には、submission_id)に
-- 問い合わせ・来場予約を保存する表(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;作りの
- 表には、
画面から RLSを一切触らせない。 有効に して ポリシーを 1つも 作らず、 anonとauthenticatedからは表の 権限も 関数の 実行権限も 外しています。 読み 書きは、 サーバーの 関数から シークレットキーで 呼んだ ときだけです。 RLSの 考え方は、Supabaseの RLSで に店舗ごとに データを 分ける 記事 詳しく 書いています。 - 表への
直接の シークレットキーは操作ではなく、 決まった 処理だけを する 関数を 呼ぶ。 RLSを 素通りします。 受け口から 呼べる 操作を 「受付を 保存する」 「通知を 取り出す」 「結果を 書く」に 限って おけば、 受け口の コードに 不具合が あっても、 ほかの 行を 書き換えに くくなります。 - 通知の
取り出しで、 状態と 回数を 一度に 変える。 「送信中」に して回数を 1つ増やす更新を 1つの SQLで 行うので、 同じ 受付を 2か 所から 同時に 送る ことを 防げます。 再送用の 取り出しには for update skip lockedを付け、 ほかの 処理が 掴んでいる 行は 飛ばします。 - データベースの
側にも、 名前の入力の 制約を 書く。 長さ、 電話番号の 形、 種別の 値は、 サーバーの 検証と 同じ 内容を check制約にも書いています。 サーバーの 検証に 抜けが あっても、 おかしな 値は 保存されません。
受付番号は、
入力の検証は、決めた項目だけを取り出してから行う
サーバー側の
// サーバー側の入力の検証。画面側の検証は使いやすさのため、こちらは守りのため(両方に置く)
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 };
}項目ごとに
| 項目 | 整え方 | 確かめる |
|---|---|---|
| すべて | 全角英数字を |
文字列でなければ |
| メールアドレス | 小文字に |
254文字以内、@と.を |
| 電話番号 | ハイフン・ |
数字10〜11桁。 |
| 希望日 | なし | 実在する |
| 相談内容 | なし | 2000文字以内。 |
| 同意 | なし | 同意していなければ |
メールアドレスの
受け口:保存できたら受付番号を返し、通知は応答のあとで送る
受け口のwaitUntilで
// 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';
}// 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にwaitUntilの
ログには、
verifyHumanのTURNSTILE_BYPASS=localと、localhostである
送れなかった通知は、再送の受け口で拾い直す
通知に
// 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;
}このnotify_status = 'failed' and notify_attempts >= 5の
メールをidempotency-keyをfrom・to・subject・textと、Idempotency-Keyの
個人情報は、持つ項目・載せる場所・持つ期間の3つで減らす
個人情報は、
| 減らし方 | この |
|---|---|
| 持つ項目を |
決めた |
| 電話番号は |
空ならnull。 |
| 同意の |
同意したconsent_version)を |
| 通知メールに |
受付番号・種別・受付日時・希望日と、 |
| ログに |
受付番号と |
| 見られる |
表は |
| 持つ期間を |
purge_inquiriesで、 |
通知メールに
手元で動かした結果
手元では、wrangler pages devでschema.sqlを
SupabaseのPOST /rest/v1/rpc/関数名のapikeyヘッダーのservice_roleかanonのschema.sqlの
// 手元の確認用: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'));// 手元の確認用:メール送信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に
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"流した
#!/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でutm_sourceとipは |
| 2. 同じ |
同じduplicate: trueで |
| 3. 入力の |
422で、 |
| 4. メールAPIの |
利用者にはfailed・1回目・mail api 500になった |
| 5. データベースの |
503が |
| 6. 再送 | 合言葉なしはsent・2回目になった |
| 7. 画面の |
permission denied for function submit_inquiryで |
| 8. メールAPIが |
1回目とfailed・5回の |
| 9. 保存期間を |
期間0で |
ログには、
{"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と2026-10-20の
作った
動作確認した環境
確認日は
| 項目 | バージョン・内容 |
|---|---|
| OS | macOS 26 |
| Node.js | 22.23.2 |
| Wrangler | 4.147.0wrangler pages dev、 |
| データベース | PGlite 0.5.8anon・authenticated・service_roleのschema.sqlを流した |
| メール送信 | 受け取った |
本物のwaitUntilの
参照した公式ドキュメント(確認日:2026年10月4日)
- Cloudflare Pages「Functions API reference」:
onRequestPost、env・waitUntilなどのEventContextの 項目 - Cloudflare Workers「Context (ctx)」:
waitUntilは応答の あとも 処理を 続ける こと、 応答の あとに 延ばせるのは 1つの リクエストで 合計30秒までで、 超えると 打ち切られる こと、 時間内に 終わらない 処理は Queuesに 送るよう 案内している こと - Cloudflare Pages「Local development」:
wrangler pages devと既定の ポート8788 - Cloudflare Pages「Bindings」:手元の
環境変数と シークレットを .dev.varsに置く こと、 gitに 入れない こと - Supabase「Understanding API keys」:publishable keyと
secret keyの 違い、 secret keyは apikeyヘッダーで送る こと、 service_roleがRLSを 素通りする こと、 ブラウザで 使わない こと - PostgREST「Functions as RPC」:
/rpc/関数名へのPOSTで、 JSONの 各項目が 関数の 引数に なる こと - PostgREST「Errors」:権限が
足りない エラー (42501)は、 ログインしていれば 403、 していなければ 401で 返る こと - PGlite「Getting started」:Nodeで
動く PostgreSQL、 execとqueryの使い方 - Resend「Send Email」:送信APIの
項目と、 Idempotency-Keyヘッダー(24時間有効) - 個人情報保護委員会
「個人情報の :第22条の保護に 関する 法律に ついての ガイドライン (通則編)」 データ内容の 正確性の 確保等 (不要に なった 個人データの 消去)、 第23条の 安全管理措置
よくある質問
フォームの送信を受けたら、先にメールを送るべきですか、先に保存するべきですか?
先に
通知メールの送信に失敗したら、利用者にはエラーを見せるべきですか?
見せません。
担当者への通知メールに、問い合わせの内容を全部載せてもよいですか?
載せる
SupabaseのシークレットキーをPages Functionsで使っても大丈夫ですか?
Pages Functionsは