# Customizing the contact form

> Edit the contact form fields in the admin screen, read inquiries in the list, and export them as CSV.

You can add, remove, and reorder the contact form fields in the admin screen. Saved changes appear on your website within about 30 seconds, with no code change and no deploy.

This page also covers the inquiry list, the inquiry detail page, and the CSV export.

## Edit the form

1. Sign in to the admin screen and open **Form** in the menu. The **Contact form** page lists the fields in order.
2. Edit the fields. What you can change is listed after the screenshot.
3. Select **Save as version N**. The fields are saved as a new version.

<AdminScreen
  name='form-editor'
  alt='The form editor with the fixed email row, five fields with move buttons, the fixed privacy consent row, Add a field, and the live preview.'
  caption='Version 1 of the form. The save button at the top right stays disabled until you make a change.'
/>

In the editor, you can:

- **Add a field**, **Remove** it, and move it up or down
- Set its **Type**, its **Label**, and whether it is **Required**
- Set an optional placeholder and help text (**Placeholder and help text**), each in Japanese and English
- Set the **Maximum length** of text fields
- Add, remove, and reorder the **Choices** of dropdown, radio button, and checkbox fields
- Choose whether the field is a column in the inquiry list (**Show in the inquiry list**)
- **Preview** the form in Japanese or English

The contact page reads the current version when a visitor opens it. The form may be cached for up to 30 seconds, so visitors see the change within about that time. You do not edit `site.config.ts`.

The first version matches the form that comes with the starter: name (required), company, phone, category (a dropdown with five choices), and message (required).

## What is fixed

These parts are always on the form and cannot be edited:

| Part                     | Why it is fixed                                                                                                                                                                    |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Email address (required) | It is the address you reply to, and every inquiry stores it in its own column. The list, the detail page, the CSV, and the reply button rely on it, whatever the other fields are. |
| Privacy policy consent   | The API rejects a submission without it, and stores the time of consent.                                                                                                           |
| Honeypot field           | A hidden field that only bots fill in. See [Contact form protection](https://goodnight-web.dev/docs/how-it-works/contact-protection.md).                                                                      |
| Turnstile                | The bot check that the server verifies before it saves anything.                                                                                                                   |

In the editor, the email and consent rows are marked **Fixed**, with a short explanation.

## Field types and limits

| Type       | Shown in the admin screen as | Stored value                          | Notes                                                                      |
| ---------- | ---------------------------- | ------------------------------------- | -------------------------------------------------------------------------- |
| `text`     | Single-line text             | Text on one line                      | Control and invisible formatting characters are removed and spaces trimmed |
| `textarea` | Multi-line text              | Text with line breaks                 |                                                                            |
| `tel`      | Phone number                 | Text                                  | Digits, `+`, `-`, `(`, `)`, spaces; full-width digits allowed              |
| `url`      | URL                          | Text                                  | `http://` or `https://` only                                               |
| `number`   | Number                       | A number                              |                                                                            |
| `date`     | Date                         | `YYYY-MM-DD`                          | Must be a real date                                                        |
| `select`   | Dropdown                     | One choice value                      | Must be one of the field's choices                                         |
| `radio`    | Radio buttons                | One choice value                      | Must be one of the field's choices                                         |
| `checkbox` | Checkboxes                   | A list of choice values (one or more) | Each must be one of the field's choices                                    |

| Limit                                                       | Value                  |
| ----------------------------------------------------------- | ---------------------- |
| Fields per version (not counting email and consent)         | 30                     |
| Choices per field                                           | 50                     |
| Label of a field or a choice (per language)                 | 100 characters         |
| Placeholder (per language)                                  | 100 characters         |
| Help text (per language)                                    | 200 characters         |
| Maximum length of `text`, `tel`, `url`                      | 1 to 2,000 characters  |
| Maximum length of `textarea`                                | 1 to 10,000 characters |
| Stored values of one inquiry (as JSON, UTF-8)               | 16 KB                  |
| Request body of `POST /api/contact`, checked before parsing | 32 KB                  |

A label needs text in at least one language. When one language is empty, the other one is shown.

The 16 KB limit counts bytes, not characters, and covers all answers of one inquiry together. Japanese text takes 3 bytes per character, so that is about 5,400 Japanese characters in total, even when each `textarea` allows 10,000. A larger submission gets `413 payload_too_large`, and the starter's form asks the visitor to shorten the text. The admin screen shows this limit under the maximum length setting of a `textarea`.

## Field IDs

When you rename a field, its answers stay connected. Values are stored under a fixed ID, not under the label.

An ID looks like `f_k3v9q2m8x1b7c4d6`. The server creates it when the field is first saved, and it never changes. Only the fields of version 1 have readable IDs such as `f_name`. [The form definition](#form-definition) shows the data structure.

The editor shows the ID in small type on saved fields (`ID f_…`). You cannot type or change it. A field that is not saved yet is marked **New** instead.

<AdminScreen
  name='form-field'
  alt="A new field's settings: Japanese and English labels, type, maximum length, Required, Show in the inquiry list, and Remove."
  caption='A newly added Budget field. Its ID is assigned when you save.'
/>

Because of these IDs:

- **Renaming a label keeps the data.** A field labeled "Company" that you rename to "Organization" keeps its ID, so old and new answers stay in one column.
- **A new field never takes an old field's place.** If you remove "Budget" and later add a new field called "Budget", the new one gets a new ID. Old and new answers stay apart, even though the labels are the same.
- **The server picks the ID.** The editor sends new fields without an ID, and the server assigns one. An ID that no version of the form ever had is rejected with 400.
- **Types change only within the same kind of value.** You can switch among `text`, `textarea`, `tel`, and `url`, or between `select` and `radio`. Other changes (for example `text` to `number`) are rejected, because the values already stored under the ID would not fit the new type. Add a new field instead.
- **Choice values are generated too.** New choices get an internal value such as `o_p4n8w2k6c1`. You edit only the choice labels.

## Versions and history

Each save adds a version: 1, 2, 3, and so on. Changing a label, a type, a choice, the order, or a setting creates a new version. Saving without changes does not.

- **Version history** lists each version with its number, the time, and the admin who saved it.
- **Restore** saves the fields of an older version as a new version. It does not rewrite history or delete anything. The restored fields keep their IDs, so their old and new answers line up again.
- If someone else saved while you were editing, your save is rejected (409 `form_conflict`). Reload and make your change again.

A saved version never changes. The `form_definitions` table is append-only: database triggers reject `UPDATE` and `DELETE` on it.

## Remove and put back fields

A removed field is archived when you save. Nothing is deleted:

- The field stays in every older version, and its stored answers stay in the inquiries.
- The definition keeps a list of archived fields, with their last labels and the last version they appeared in.
- The editor lists them under **Archived fields**. **Put back** returns a field to the form with its old ID.

## The inquiry list

Open **Inquiries** in the admin screen to see the inquiries you received.

<AdminScreen
  name='inquiry-list'
  alt='The inquiry list in the Unread view with three unread inquiries, the view tabs, the email search box, and the Download CSV button.'
  caption='The Unread tab selected. Suspected spam has its own tab.'
/>

### Unread and read

An inquiry has one of two statuses: **unread** or **read**.

- A new inquiry is unread. Opening it marks it read.
- To change the status, use the envelope button next to the status in the list (**Mark as read** / **Mark as unread**), or the buttons on the detail page. Mark an inquiry unread again if you want to handle it later.
- There are no other statuses. To keep a record of what was done, reply by email or export the CSV.

**Suspected spam** is not a status. It is a separate mark. The honeypot or the spam score sets it automatically (see [Contact form protection](https://goodnight-web.dev/docs/how-it-works/contact-protection.md)), and an admin can set it with **Mark as spam** on the detail page. If it is a real inquiry, choose **Not spam** on the detail page to remove the mark. A suspected spam inquiry is also unread or read.

The unread count in the header counts unread inquiries that are not suspected spam.

### Columns

The list has three fixed columns: **Received**, **Email**, and **Status** (with the suspected spam badge when it applies). After them come the fields of the current version.

- Fields with **Show in the inquiry list** turned on are shown by default. In version 1, these are name, company, and message. Long text is truncated to 120 characters.
- Use **Columns** to show or hide each column. That choice is saved in your browser only (`localStorage`).
- Archived fields are not shown in the list.

### Views and filters

The tabs at the top pick the view:

| View           | Shows                                        |
| -------------- | -------------------------------------------- |
| Unread         | Unread inquiries that are not suspected spam |
| Read           | Read inquiries that are not suspected spam   |
| All (default)  | Every inquiry that is not suspected spam     |
| Suspected spam | Only suspected spam, unread and read         |

The **Unread** and **Suspected spam** tabs show their counts. The **Unread**, **Read**, and **All** tabs say that suspected spam is kept in its own tab, with a link to the **Suspected spam** tab.

Suspected spam has its own view because mixing it in would bury real inquiries and make the unread count useless. Nothing is hidden for good: the inquiries are saved, counted, and one click away.

Within a view, you can also filter by:

- **Form version**
- **Filter by answer**: a choice of a dropdown or radio button field in the current version

### Order

The list is sorted by received time only: newest first by default, oldest first when you select the **Received** column header.

It cannot be sorted by email address, status, or a form field, because inquiries are usually read in the order they arrive. To find a specific inquiry, use the tabs, the filters, or the email search.

### Search by email address

The search box finds inquiries whose email address contains the text you type, ignoring case. For example, `example.co` finds both `taro@example.com` and `info@example.co.jp`.

- It searches the fixed email column only, never the form fields.
- The search text is at most 254 characters (the longest email address).
- The search applies within the current view and filters. The CSV export follows it.

To find inquiries by the value of a form field, use **Filter by answer** or the CSV export.

## The inquiry detail

Select an inquiry in the list to open its detail page. Opening it marks it read.

<AdminScreen
  name='inquiry-detail'
  alt="An inquiry's detail page with the answers, the message, contact details, and the buttons Mark as unread, Mark as spam, Reply by email, and Delete."
/>

The buttons on the right:

- **Mark as unread**: keep it for later.
- **Mark as spam**: set the suspected spam mark. On a marked inquiry the button is **Not spam**.
- **Reply by email**: write a reply to the fixed email address in your mail app.
- **Delete**: delete it after a confirmation. See [Privacy notes](https://goodnight-web.dev/docs/privacy.md#deleted-data).

The detail page shows an inquiry with the labels and field order of the version it was sent with. A label that you rename later does not change how an old inquiry reads.

Values of fields that are no longer on the current form are still shown, marked "From version N · archived".

## CSV export

Select **Download CSV** in the list to export inquiries.

The CSV starts with the fixed columns: `id`, `received_at`, `status` (`unread` or `read`), `suspected_spam` (`1` or `0`), `email`, `locale`, `form_version`, `page_path`, `country`, `spam_score`, `spam_reasons`.

Then comes one column for every field ID the form ever had: the current fields in form order, then archived fields.

- Headers are labels in the current language (Japanese or English).
- An archived field's header ends with the last version that had it, for example `Budget (v3)`. A new field with the same label has its own column, `Budget`.
- If two headers are still the same, the field ID is added in brackets.
- Dropdown, radio button, and checkbox values are written as choice labels.
- So that a spreadsheet app does not run a value as a formula, a leading `'` is added to any cell whose first visible character is `=`, `+`, `-`, or `@` (full-width forms included), or that starts with a tab or carriage return.
- The export follows the list's view, email search, and filters. The All view leaves suspected spam out; export the Suspected spam view to get it.
- The file is read 200 rows at a time and streamed, so a large table is never held in memory.

The export holds inquiries sent with the form version that was current when it started, and with older versions. If someone saves a new version during the export, inquiries sent with that version are left out, because the header has no columns for its new fields. They appear in the next export.

## Why it is built this way

- **The definition is in D1, so people who do not write code can change it.** A small company often runs its website without an engineer on staff. Adding a "Budget" field should not need a code change, a build, and a deploy.
- **The server trusts only its own definition.** The definition from `GET /api/contact/form` is for drawing the form. On submit, the server reads the definition from D1 again and validates against that. A changed page or a crafted request cannot add fields or skip checks.
- **One JSON column instead of one column per field.** A column per field would need a schema migration for every form edit, and migrations ship only with package releases. A JSON column with values keyed by field ID needs none.
- **Fixed IDs and versions keep data apart.** Labels change and fields come and go. Values are keyed by an ID that is never reused, and every inquiry records its version. So answers to different questions never share a column, and every inquiry can be shown as the visitor saw the form.
- **Email is fixed.** Every inquiry needs a way to reply. Keeping it as a real column lets the list search it and the detail page offer a **Reply by email** button, whatever the other fields are.
- **Slack stays free of personal data.** Notifications contain only the site name, the page path, the received time, and a link, whatever fields the form has. See [Slack notifications](https://goodnight-web.dev/docs/how-it-works/notifications.md).

## Details for developers

### The form definition [#form-definition]

These types define a field ID and the definition stored for each version. Labels, placeholders, and help text each hold a `ja` and an `en` string.

```ts title="packages/server/src/features/contact-form/form-definition.ts"
export const FIELD_ID_PATTERN = /^f_[a-z0-9]{1,32}$/;
```

```ts title="packages/server/src/features/contact-form/form-definition.ts"
/** 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;
}
```

```ts title="packages/server/src/features/contact-form/form-definition.ts"
/** 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[];
}
```

### How the list filters and searches

The choice filter runs `json_extract()` on the stored JSON. The field ID is looked up in the stored definition first. Both the JSON path and the value are bound parameters:

```ts title="packages/server/src/features/submissions/submission-store.ts"
choiceIs = sql`json_extract(${submissions.fields}, ${`$.${choice.fieldId}`}) = ${choice.value}`;
```

There is one more reason to sort by received time only. The values of form fields live in one JSON column, so sorting by a field would mean a different query plan for every field.

The email search uses Drizzle's `like` on `lower(email)` with one bound parameter. `%`, `_`, and `\` in the search text are escaped, so they match as plain characters:

```ts title="packages/server/src/features/submissions/submission-store.ts"
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 '\\'`;
```

**Why there is no index for the email search.** A `LIKE '%text%'` search cannot use an index, so D1 reads every row of `submissions`. For a contact form this is fine:

- A busy small company gets a few thousand inquiries a year.
- Rows older than the retention period (180 days by default) are deleted.
- Reading a few thousand short rows takes milliseconds and costs few D1 row reads.

An extra index would slow every insert and still not help a partial match. The `created_at` index serves the order and the retention cleanup.

**Why there is no search over the form fields.** A search over free text would need `LIKE` over every stored value, or a full-text index that has to follow every form change. Filters and the CSV export cover the common needs.

### How the contact page loads the form

The page reads the current form from a public, read-only endpoint:

```http
GET /api/contact/form
```

The response contains:

- the version
- the fields (ID, type, labels, placeholder, help text, required, maximum length, choices)
- the fixed email field
- whether the form is available (Turnstile site key, action, and honeypot name)

It contains no secrets. It may be cached for 30 seconds (`Cache-Control: public, max-age=30`).

The starter renders the fields in the browser, with a loading state. If the request fails, it shows a short message with the company email address from `site.config.ts`. The browser helper is `getContactForm()` from `@goodnight-web/server/client`.

The page sends the version it rendered as `formVersion`. If the form changed in the meantime, the API returns 409 `form_outdated`. The starter then loads the form again, keeps what the visitor typed in fields that still exist, and asks them to submit again.

### How the server checks a submission

The server builds a zod schema from the current version in D1 and checks `fields` against it. It never uses a definition sent by the browser.

The built schema is cached per Worker isolate and version. Each request reads only the current version number.

- Only the IDs of the current fields are accepted. Any other key, including `__proto__`, is rejected with 400.
- Required fields, lengths, formats, and choice values are checked. Text loses control characters, bidi control characters, zero width spaces, word joiners, byte order marks, and surrounding whitespace (multi-line text keeps line breaks and tabs). Zero width joiners, zero width non-joiners, and variation selectors stay, because emoji, Persian, and Indic scripts need them. An unpaired surrogate is replaced with U+FFFD.
- Empty optional fields are left out of the stored JSON.

The route reads the current version, rejects an outdated page, and checks the values with the schema of that version:

```ts title="packages/server/src/features/contact/contact-routes.ts"
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'),
  );
}
```

The schema checks the keys first, on the parsed JSON itself, before anything is copied:

```ts title="packages/server/src/features/contact-form/form-values-schema.ts"
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))
```

Each inquiry stores its values as JSON in `submissions.fields`, keyed by field ID, and the version in `submissions.form_version`. See [Database](https://goodnight-web.dev/docs/database.md).
