お問い合わせフォームの項目を変える
管理画面でフォームの項目を編集し、問い合わせを一覧で読み、CSV で書き出します。
お問い合わせフォームの項目は、管理画面で追加、削除、並べ替えできます。保存すると、30 秒ほどのうちにWebサイトに反映されます。コードの編集もデプロイもいりません。
このページでは、問い合わせ一覧、詳細、CSV の書き出しも説明します。
フォームを編集する
- 管理画面にログインし、メニューの フォーム(英語表示では Form)を開きます。お問い合わせフォーム のページに、項目が順番に並んでいます。
- 項目を編集します。できることは、画面の画像の後にまとめています。
- バージョン N として保存 を押します。項目が新しいバージョンとして保存されます。


編集画面でできること:
- 項目を追加、削除、上下の並べ替え
- 種類、項目名、必須にする の設定
- 入力例と説明 の設定(任意。それぞれ日本語と英語)
- テキストの項目の 最大文字数 の設定
- プルダウン、ラジオボタン、チェックボックスの 選択肢 の追加、削除、並べ替え
- お問い合わせ一覧に表示する の設定(一覧に列として出すかどうか)
- 日本語と英語での プレビュー
問い合わせページは、訪問者が開いたときに現在のバージョンを読み込みます。フォームは最大 30 秒キャッシュされるので、変更は 30 秒ほどのうちに訪問者に届きます。site.config.ts の編集はいりません。
最初のバージョンは、starter に付いているフォームと同じです。お名前(必須)、会社名、電話番号、お問い合わせの種類(選択肢 5 つのプルダウン)、お問い合わせ内容(必須)です。
変えられない部分
次の部分はいつもフォームにあり、編集できません。
| 部分 | 固定している理由 |
|---|---|
| メールアドレス(必須) | 返信先のアドレスで、どの問い合わせも専用の列に保存します。ほかの項目がどうであっても、一覧、詳細、CSV、返信ボタンはこれを使います。 |
| プライバシーポリシーへの同意 | 同意のない送信は API が受け付けません。同意した時刻を保存します。 |
| honeypot の欄 | ボットだけが入力する、見えない欄です。問い合わせフォームの保護を参照してください。 |
| Turnstile | 保存の前にサーバーが確かめる、ボット対策です。 |
編集画面では、メールアドレスと同意の行に 固定 の印が付き、理由が添えてあります。
項目の種類と上限
| 種類 | 管理画面での表示 | 保存する値 | 補足 |
|---|---|---|---|
text | 1 行のテキスト | 1 行のテキスト | 制御文字、目に見えない書式用の文字、前後の空白を取り除きます |
textarea | 複数行のテキスト | 改行を含むテキスト | |
tel | 電話番号 | テキスト | 数字、+、-、(、)、空白。全角の数字も受け付けます |
url | URL | テキスト | http:// か https:// だけ |
number | 数値 | 数値 | |
date | 日付 | YYYY-MM-DD | 実在する日付であること |
select | プルダウン | 選択肢の値 1 つ | その項目の選択肢のどれかであること |
radio | ラジオボタン | 選択肢の値 1 つ | その項目の選択肢のどれかであること |
checkbox | チェックボックス | 選択肢の値のリスト(1 つ以上) | それぞれがその項目の選択肢のどれかであること |
| 上限 | 値 |
|---|---|
| 1 バージョンの項目数(メールアドレスと同意を除く) | 30 |
| 1 項目の選択肢の数 | 50 |
| 項目名・選択肢の名前(言語ごと) | 100 文字 |
| 入力例(言語ごと) | 100 文字 |
| 説明(言語ごと) | 200 文字 |
text、tel、url の最大文字数 | 1〜2,000 文字 |
textarea の最大文字数 | 1〜10,000 文字 |
| 問い合わせ 1 件の保存する値(JSON、UTF-8) | 16 KB |
POST /api/contact のリクエスト本文(読む前に確認) | 32 KB |
項目名は、少なくとも一方の言語で入力します。片方の言語が空のときは、もう一方を表示します。
16 KB の上限は文字数ではなくバイト数で、問い合わせ 1 件の全項目の回答を合わせて数えます。日本語は 1 文字 3 バイトなので、合計でおよそ 5,400 文字です。textarea の最大文字数を 10,000 にしていても、この上限が先に効きます。超えた送信は 413 payload_too_large になり、スターターのフォームは文字数を減らすよう表示します。管理画面では、textarea の最大文字数の設定の下にこの上限を表示します。
項目の ID
項目の名前を変えても、回答はそのままつながります。値を項目名ではなく、変わらない ID で保存するからです。
ID は f_k3v9q2m8x1b7c4d6 のような文字列です。項目を最初に保存したときにサーバーが作り、その後は変わりません。最初のバージョンの項目だけは、f_name のような読める ID です。データの形はフォームの定義にあります。
編集画面では、保存済みの項目に ID f_… と小さく表示されます。入力や変更はできません。まだ保存していない項目には、ID の代わりに 新規 の印が付きます。


ID がこのように決まるので、次のようになります。
- 項目名を変えてもデータはそのまま。 「会社名」を「団体名」に変えても ID は同じです。変更の前と後の回答は同じ列に入ります。
- 新しい項目が古い項目の代わりになることはない。 「ご予算」を削除し、後で「ご予算」という項目を追加すると、新しい項目には新しい ID が付きます。項目名が同じでも、古い回答と新しい回答は分かれたままです。
- ID はサーバーが決める。 編集画面は新しい項目を ID なしで送り、サーバーが ID を付けます。フォームのどのバージョンにもなかった ID は、400 で拒否します。
- 種類は、値の形が同じものの間でだけ変えられる。
text、textarea、tel、urlは互いに変えられます。selectとradioも同じです。それ以外(たとえばtextからnumber)は拒否します。その ID で保存済みの値が、新しい種類に合わなくなるからです。その場合は新しい項目を追加してください。 - 選択肢の値も自動で作る。 新しい選択肢には
o_p4n8w2k6c1のような内部の値が付きます。編集するのは選択肢の名前だけです。
バージョンと履歴
保存するたびに、バージョンが 1、2、3 と増えます。項目名、種類、選択肢、順番、設定のどれを変えても、新しいバージョンになります。何も変えずに保存したときは増えません。
- 変更履歴 に、バージョン番号、日時、保存した管理者が並びます。
- 復元 は、古いバージョンの項目を新しいバージョンとして保存します。履歴を書き換えたり、何かを消したりはしません。復元した項目は ID がそのままなので、前の回答と新しい回答がまたつながります。
- 編集中にほかの人が保存していたら、保存は拒否されます(409
form_conflict)。再読み込みして、もう一度変更してください。
保存したバージョンは、後から書き換えられません。form_definitions テーブルは追記だけで、データベースのトリガーが UPDATE と DELETE を拒否します。
項目を削除する、元に戻す
項目を削除すると、保存したときにアーカイブされます。何も消えません。
- その項目は古いバージョンに残ります。保存済みの回答も、問い合わせに残ります。
- 定義には、アーカイブした項目の一覧が残ります。最後の項目名と、最後にその項目があったバージョンも記録します。
- 編集画面の アーカイブした項目 に並びます。元に戻す を押すと、前と同じ ID でフォームに戻ります。
問い合わせ一覧
管理画面の お問い合わせ で、届いた問い合わせを一覧で見られます。


未読と既読
問い合わせの状態は 未読 と 既読 の 2 つだけです。
- 新しい問い合わせは未読です。詳細を開くと既読になります。
- 一覧の状態の横にある封筒のボタン(既読にする / 未読に戻す)か、詳細で切り替えられます。あとで対応したいものは、未読に戻しておけます。
- ほかの状態はありません。対応の記録が必要なら、メールで返信するか、CSV に書き出してください。
スパムの疑い は状態ではなく、別の印です。honeypot かスパムスコア(問い合わせフォームの保護を参照)で自動で付くほか、管理者が詳細の スパムとして扱う で付けることもできます。本物の問い合わせなら、詳細で スパムではない を選ぶと印が外れます。スパムの疑いがある問い合わせにも、未読と既読があります。
ヘッダーの未読件数は、スパムの疑いがない未読の問い合わせの数です。
列
一覧には、固定の列が 3 つあります。受信日時、メールアドレス、状態(当てはまればスパムの疑いの印も)です。その後に、現在のバージョンの項目が続きます。
- お問い合わせ一覧に表示する がオンの項目は、最初から列に出ます。バージョン 1 では、お名前、会社名、お問い合わせ内容です。長い文は 120 文字で切ります。
- 表示する列 で、列ごとに表示と非表示を切り替えられます。この選択は、そのブラウザにだけ保存します(
localStorage)。 - アーカイブした項目は、一覧には出ません。
タブと絞り込み
上部のタブで、表示する問い合わせを選びます。
| タブ | 表示する問い合わせ |
|---|---|
| 未読 | スパムの疑いがない未読の問い合わせ |
| 既読 | スパムの疑いがない既読の問い合わせ |
| すべて(最初に開くタブ) | スパムの疑いがないすべての問い合わせ |
| スパムの疑い | スパムの疑いがあるものだけ(未読と既読) |
未読 と スパムの疑い のタブには件数が出ます。未読、既読、すべて のタブには、スパムの疑いを別のタブに分けているという案内と、スパムの疑い のタブへのリンクが出ます。
スパムの疑いを分けるのは、混ぜると本物の問い合わせが埋もれ、未読件数も役に立たなくなるからです。消えるわけではありません。保存され、件数も出ていて、1 回の操作で見られます。
タブの中で、さらに次の条件で絞り込めます。
- フォームのバージョン
- 回答で絞り込む: 現在のバージョンにあるプルダウンかラジオボタンの選択肢
並び順
並び順は受信日時だけです。最初は新しい順で、受信日時 の列見出しを選ぶと古い順になります。
メールアドレス、状態、フォームの項目では並べ替えられません。問い合わせは、届いた順に読むことが多いからです。特定の問い合わせを探すときは、タブ、絞り込み、メールアドレスの検索を使ってください。
メールアドレスで検索
検索欄に入れた文字を含むメールアドレスの問い合わせを探します。大文字と小文字は区別しません。たとえば example.co で、taro@example.com も info@example.co.jp も見つかります。
- 検索するのは固定のメールアドレスの列だけです。フォームの項目は検索しません。
- 検索する文字は 254 文字(メールアドレスの最大の長さ)までです。
- 選んでいるタブと絞り込みの中で検索します。CSV の書き出しも同じ条件になります。
フォームの項目の値で探すときは、回答で絞り込む か CSV の書き出しを使ってください。
問い合わせの詳細
一覧で問い合わせを選ぶと、詳細が開きます。開いた問い合わせは既読になります。


右側のボタンでできること:
- 未読に戻す: あとで対応するときに使います。
- スパムとして扱う: スパムの疑いの印を付けます。印が付いた問い合わせでは スパムではない になります。
- メールで返信: 固定のメールアドレスあてに、メールアプリで返信を書きます。
- 削除: 確認のあとで削除します。プライバシーについてを参照してください。
詳細では、送信されたときのバージョンの項目名と順番で問い合わせを表示します。後で項目名を変えても、古い問い合わせの見え方は変わりません。
現在のフォームにない項目の値も表示します。その項目には「バージョン N の項目・アーカイブ済み」と表示します。
CSV の書き出し
一覧の CSV をダウンロード で、問い合わせを CSV で書き出します。
CSV の列は、まず固定の列です。id、received_at、status(unread か read)、suspected_spam(1 か 0)、email、locale、form_version、page_path、country、spam_score、spam_reasons の順です。
その後に、フォームにこれまであったすべての項目 ID の列が続きます。現在の項目(フォームの順)、アーカイブした項目の順です。
- 見出しは、表示中の言語(日本語か英語)の項目名です。
- アーカイブした項目の見出しには、最後にその項目があったバージョンを付けます。たとえば
ご予算 (v3)です。同じ名前の新しい項目は、別の列ご予算になります。 - それでも見出しが重なるときは、項目 ID を角かっこで付けます。
- プルダウン、ラジオボタン、チェックボックスの値は、選択肢の名前で書き出します。
- 表計算アプリが値を数式として実行しないよう、目に見える最初の文字が
=、+、-、@のセルと、タブや復帰文字で始まるセルには、先頭に'を付けます。全角の=、+、-、@も対象です。 - 書き出しは、一覧のタブ、メールアドレスの検索、絞り込みに従います。「すべて」にはスパムの疑いが入りません。必要なら「スパムの疑い」のタブで書き出してください。
- 200 行ずつ読んで順に送り出します。大きなテーブルでも、全体をメモリに載せません。
書き出しに含まれるのは、書き出しを始めたときのフォームのバージョンと、それより前のバージョンで送られた問い合わせです。途中で新しいバージョンが保存されたら、そのバージョンで送られた問い合わせは含めません。見出しに、そのバージョンで増えた項目の列がないからです。次の書き出しには含まれます。
なぜこう作るか
- 定義は D1 にあるので、コードを書かない人でも変えられる。 小さな会社では、エンジニアがそばにいないことがよくあります。「ご予算」の欄を足すのに、コードの変更、ビルド、デプロイが必要であるべきではありません。
- サーバーは D1 にある定義だけを信じる。
GET /api/contact/formの定義は、フォームを表示するためのものです。送信のときは、サーバーが D1 から定義を読み直して確かめます。書き換えたページや手で作ったリクエストで、項目を増やしたり確認を飛ばしたりはできません。 - 項目ごとの列ではなく、1 つの JSON の列。 項目ごとに列を作ると、フォームを変えるたびにスキーマのマイグレーションが必要です。マイグレーションはパッケージのリリースでしか届きません。項目 ID をキーにした JSON の列なら、マイグレーションはいりません。
- 変わらない ID とバージョンで、データを混ぜない。 項目名は変わり、項目は増えたり減ったりします。値は使い回さない ID で保存し、問い合わせごとにバージョンを記録します。だから、別の質問への回答が同じ列に入ることはなく、どの問い合わせも訪問者が見たフォームのとおりに表示できます。
- メールアドレスは固定。 どの問い合わせにも返信の手段が必要です。普通の列として持つので、フォームの中身に関係なく、一覧で検索でき、詳細に メールで返信 のボタンを出せます。
- Slack に個人情報は送らない。 フォームにどんな項目があっても、通知に含むのはサイト名、ページのパス、受信日時、リンクだけです。Slack 通知を参照してください。
開発者向けの詳細
フォームの定義
項目 ID の形と、バージョンごとに保存する定義の形です。項目名、入力例、説明は、ja と en の文を持ちます。
export const FIELD_ID_PATTERN = /^f_[a-z0-9]{1,32}$/;/** A field of the contact form. */
export interface FormField {
/** Immutable id, generated by the server. Matches {@link FIELD_ID_PATTERN}. */
id: string;
type: FormFieldType;
label: LocalizedText;
placeholder?: LocalizedText;
/** Help text shown under the field. */
hint?: LocalizedText;
required: boolean;
/** Text types only. Defaults to {@link DEFAULT_MAX_LENGTH}. */
maxLength?: number;
/** Choice types only: 1 to {@link FORM_LIMITS}.options entries. */
options?: FormFieldOption[];
/** Shown as a column in the admin list by default. */
showInList: boolean;
}/** The stored definition of one version. */
export interface FormDefinition {
/** The fields on the form, in order. */
fields: FormField[];
/** Fields removed in this or an earlier version, newest removal first. */
archived: ArchivedFormField[];
}一覧の絞り込みと検索のしくみ
選択肢での絞り込みは、保存した JSON に json_extract() を使います。項目の ID はまず保存済みの定義から探します。JSON のパスと値は、どちらもバインドされたパラメーターとして渡します。
choiceIs = sql`json_extract(${submissions.fields}, ${`$.${choice.fieldId}`}) = ${choice.value}`;並び順を受信日時だけにする理由は、もう 1 つあります。フォームの項目の値は 1 つの JSON の列に入っています。項目で並べ替えると、項目ごとに別のクエリプランになってしまいます。
メールアドレスの検索は、lower(email) に Drizzle の like を使い、値を 1 つのバインドされたパラメーターで渡します。検索する文字の中の %、_、\ はエスケープするので、ただの文字として一致します。
export const escapeLike = (term: string): string =>
term.replace(/[\\%_]/g, (char) => `\\${char}`);
/**
* Case-insensitive partial match on `email`. The pattern is one bound
* parameter; only the `ESCAPE` character is part of the SQL text.
*/
const emailContains = (term: string): SQL =>
sql`${like(
sql`lower(${submissions.email})`,
`%${escapeLike(term.toLowerCase())}%`,
)} escape '\\'`;メールアドレスの検索にインデックスを作らない理由。 LIKE '%文字%' の検索はインデックスを使えず、D1 は submissions のすべての行を読みます。問い合わせフォームなら、これで問題ありません。
- 問い合わせの多い小さな会社でも、件数は年に数千件です。
- 保持期間(既定は 180 日)を過ぎた行は削除されます。
- 短い行を数千件読むのは数ミリ秒で、D1 の読み取り行数もわずかです。
インデックスを増やすと INSERT のたびに遅くなるうえ、部分一致には役に立ちません。並び順と保持期間による削除には、created_at のインデックスを使います。
フォームの項目を検索しない理由。 自由に書かれた文を検索するには、保存したすべての値への LIKE か、フォームの変更のたびに追従する全文検索のインデックスが必要です。よくある用途は、絞り込みと CSV の書き出しで足ります。
問い合わせページがフォームを読み込む流れ
ページは、公開されている読み取り専用のエンドポイントから現在のフォームを読みます。
GET /api/contact/form応答に入っているもの:
- バージョン
- 項目(ID、種類、項目名、入力例、説明、必須、最大文字数、選択肢)
- 固定のメールアドレス欄
- フォームが使えるかどうか(Turnstile のサイトキー、action、honeypot の名前)
シークレットは含みません。応答は 30 秒までキャッシュされることがあります(Cache-Control: public, max-age=30)。
starter は、読み込み中の表示を出しながら、ブラウザでフォームの項目を表示します。読み込みに失敗したら、site.config.ts の会社のメールアドレスを添えた短いメッセージを出します。ブラウザ用のヘルパーは、@goodnight-web/server/client の getContactForm() です。
ページは、表示したフォームのバージョンを formVersion として送ります。その間にフォームが変わっていたら、API は 409 form_outdated を返します。starter はフォームを読み込み直し、残っている項目の入力内容はそのままにして、もう一度送信するよう案内します。
サーバーが送信内容を確かめる流れ
サーバーは、D1 にある現在のバージョンから zod のスキーマを組み立て、fields を確かめます。ブラウザから送られた定義は使いません。
組み立てたスキーマは、Worker の isolate ごと、バージョンごとにキャッシュします。リクエストごとに読むのは、現在のバージョン番号だけです。
- 受け付けるのは、現在の項目の ID だけです。それ以外のキーは、
__proto__も含めて 400 で拒否します。 - 必須、文字数、形式、選択肢の値を確かめます。テキストからは制御文字、双方向テキストの制御文字、ゼロ幅スペース、ワードジョイナー、バイトオーダーマーク、前後の空白を取り除きます(複数行のテキストは改行とタブを残します)。ゼロ幅接合子、ゼロ幅非接合子、異体字セレクタは、絵文字やペルシア語、インドの文字に必要なので残します。対になっていないサロゲートは U+FFFD に置き換えます。
- 空の任意項目は、保存する JSON に入れません。
ルートは現在のバージョンを読み、古いページからの送信を拒否し、そのバージョンのスキーマで値を確かめます。
const data = parsed.data;
const form = await getCurrentForm(env.DB);
if (data.formVersion !== undefined && data.formVersion !== form.version) {
return apiError(c, 409, 'form_outdated');
}
const fields = form.definition.fields;
const values = valuesSchemaFor(env.DB, form.version, fields).safeParse(
data.fields,
);
if (!values.success) {
return apiError(
c,
400,
'invalid_input',
contactFieldErrors(values.error, input, 'fields'),
);
}スキーマは、何かをコピーする前に、パースした JSON そのものでまずキーを確かめます。
const shape: Record<string, z.ZodType> = {};
for (const field of fields) shape[field.id] = valueSchema(field);
const ids = new Set(fields.map((field) => field.id));
// Checked by own keys first, on the parsed JSON itself: JSON.parse()
// creates `__proto__` as an own key, which copying into a new object or a
// key lookup with `in` would silently drop or accept.
return z
.custom<Record<string, unknown>>(
(value) =>
typeof value === 'object' && value !== null && !Array.isArray(value),
)
.superRefine((values, ctx) => {
const unknown = Object.keys(values).filter((key) => !ids.has(key));
if (unknown.length > 0) {
ctx.addIssue({
code: 'unrecognized_keys',
keys: unknown,
input: values,
});
}
})
.pipe(z.strictObject(shape))問い合わせは、値を項目 ID をキーにした JSON として submissions.fields に、バージョンを submissions.form_version に保存します。データベースを参照してください。