Contact form with spam protection and email notifications, plus a submissions inbox in Studio.
A contact form on any page you choose, styled to match your site. Visitors' messages land in a submissions inbox inside Studio and, if you've configured email, in your inbox too. Spam is filtered with a honeypot field and rate limiting — no CAPTCHA, no third-party service.
The spec below tells your agent to add a submission content type, a custom API route that accepts the form POST, and a form component in your frontend. Submissions are stored as drafts, so they are never publicly readable — the public content API only ever returns published entries. Everything is plain code in your repository afterwards — change any of it.
You are adding a contact form to a Prelo project. Run `prelo docs config` and `prelo docs api` first if you are unsure about any surface below.
1. **Content type.** In `cms.config.js`, add a `submission` type:
```js
submission: {
label: "Submissions",
fields: {
name: { type: "text", required: true },
email: { type: "text", required: true },
message: { type: "text", required: true },
flag: { type: "select", options: ["new", "read", "spam"] },
},
studio: { columns: ["name", "email", "flag"] },
},
```
Submissions are created as **drafts and never published** — that is what keeps them out of the public API. The generic Studio lists the type automatically; no Studio changes are needed.
2. **Rate-limit storage.** In `cms.config.js`, add a `hooks.boot` function that creates an app-data table (app tables must be `x_`-prefixed; the core never touches them):
```js
hooks: {
boot: (db) => db.exec(
"CREATE TABLE IF NOT EXISTS x_contact_hits (ip TEXT, at INTEGER)"
),
},
```
3. **API route.** Add a custom route (custom routes are mounted under `/api/x/`):
```js
routes: {
"POST /contact": async (req, res, db) => { /* … */ },
},
```
The handler must:
- Validate `name`, `email` (basic format check), `message` server-side; reject with `400` and a JSON error naming the bad field.
- Read the honeypot field `website`: if it is non-empty, respond `200 {"ok":true}` and store nothing (silent discard).
- Rate-limit by IP using `x_contact_hits`: max 5 submissions per 10 minutes; prune rows older than 10 minutes on each request; over the limit respond `429`.
- Insert the submission into the `content` table as the `submission` type with `status = 'draft'`, `title` = the sender's name, and the fields in the JSON `fields` column — match the shape existing rows use (inspect one via the API or the db).
- Send a notification email if the project has SMTP configured (check the env vars `prelo doctor` reports for email); if not configured, skip sending and `console.log` a one-line setup note. Never fail the request because email failed.
4. **Form component.** In the frontend (`web/`), add a `ContactForm` component using the project's existing form/input styles. Fields: name, email, message, plus a visually hidden `website` honeypot input (`autocomplete="off"`, hidden with CSS — not `display:none` on the input's wrapper label pattern the project uses, an `aria-hidden` absolutely-positioned input is fine). Client-side required validation; POST JSON to `/api/x/contact`.
5. **States.** Success replaces the form with "Thanks — we'll reply within a day." Failure shows an inline error and **preserves the user's input**. Disable the submit button while the request is in flight.
6. **Placement.** Mount the form where the user asked (typically a `/contact` page). If the project uses page sections, also register a `contactForm` section in `cms.config.js` `sections:` (no fields, or a single optional `heading` text field) so the client can place it from Studio.
7. **Verify.** Run `prelo check`, POST a test submission with `curl`, confirm it appears in Studio under Submissions as a draft, and confirm `GET /api/submission` unauthenticated returns an empty list.Submissions appear in Studio under Submissions, newest first, with the flag column for read/spam triage. Replies happen from your own inbox — Prelo only stores and notifies.
Appointment requests with availability windows and email confirmation.
Filterable gallery with lightbox, fed straight from the media library.
Signup form with double opt-in, delivering to Resend, Mailchimp or Buttondown.