prelo
Plugins / Contact form

Contact form

Contact form with spam protection and email notifications, plus a submissions inbox in Studio.

Agithub.com/anderdigitalFormsprelo >= 0.4Updated Mar 2026Official
Use this plugin
1
2
or point your agent at the spec:
Your agent reads the spec and writes the code into your project. Review the diff like any change.

What this adds

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.

How it works

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.

The spec your agent followsexpand ▾
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.

After it's built

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.