Forms

Add contact forms with built-in anti-spam and configurable dispatch.

Forms

The :::form block defines an inline form with honeypot, HMAC timestamp, and rate limiting built in. Submissions are dispatched to configured targets (email, API, Slack) via a CGI handler.

Front matter

Enable forms on a page with the form: key:

---
title: Contact
form: contact
---

The name must be alphanumeric with hyphens and underscores. Without form: in front matter, :::form blocks are not rendered.

Field syntax

::: form
field_name | Label text | rules
submit | Button label
:::

Field rules

Rules are whitespace-separated, so a value that needs spaces is either last on the line (select:, radio:, checklist: and pattern: each take the rest of it) or quoted: placeholder:"Your full name", pattern:"[0-9\s\(\)\+\.\-]{6,30}". The quoted form is what to use when a pattern cannot go last - a rule written after an unquoted pattern becomes part of the pattern.

Validation is enforced in the browser (HTML5 attributes); the handler accepts the submitted fields.

Handler configuration

Create lazysite/forms/FORMNAME.conf:

targets:
  - type: smtp
    url: http://localhost/plugins/form-smtp.pl

Target types: smtp (email via helper), api with format: json (webhook), api with format: slack (Slack notification).

File uploads

A form accepts binary uploads only when its .conf declares upload limits (so a form never accepts files by accident). Add any of these keys to lazysite/forms/FORMNAME.conf:

targets:
  - handler: jsonl          # a "file" target is required to STORE the files
upload_max_files: 3         # max files per submission (default 5)
upload_max_kb: 5120         # max size of EACH file, KiB (default 5120 = 5 MiB)
upload_accept: png, jpg, pdf  # allowed extensions (default: any)

A submission that breaks a limit is rejected before any handler runs, with a specific message to the visitor ("File 'x.png' is too large...", "File type not allowed...", "Too many files...").

Over the API, form-uploads-save writes those keys, so an upload form can be finished without hand-editing the conf:

POST action=form-uploads-save
{ "form": "contact", "uploads": true,
  "max_kb": 2048, "max_files": 3, "accept": "png, jpg" }

uploads: false removes every upload_* key, which turns uploads off. Omit the limits and you get the defaults above. accept here is a list of file EXTENSIONS matched against the filename - png, jpg, pdf - and a media type like image/* is refused, naming the difference: the accept: rule in the page grammar is the media-type one, and the two sit one page apart in the same job.

Uploaded files are stored by the file (jsonl) target, in a per-submission subdirectory next to the FORMNAME.jsonl:

lazysite/forms/submissions/FORMNAME.jsonl
lazysite/forms/submissions/FORMNAME.files/<submission-id>/photo.png

The submission record names the files (it never stores the bytes inline):

{ "name": "Ada", "_files": ["photo.png"],
  "_files_dir": "FORMNAME.files/20260629T101500-1a2b", ... }

Filenames are sanitised to a safe basename (any path component is stripped, so a crafted ../../etc/passwd cannot escape the submission directory). A form with a file field but no file target validates uploads but does not keep them - add a file target to store them.

Emailing uploads. The SMTP (email) handler has an Attach uploaded files option (attach_files, off by default). When on, the uploaded files are attached to the notification email and listed (name + size) below the message. Leave it off to keep emails small and just store the files; mind your mail server's attachment size limits when enabling it.

Acknowledging the submitter. By default an email handler writes to the one address you configured. Set Also acknowledge the submitter (mail_the_submitter_field) to the name of a form field holding an email address, and the site sends a second, separate message to whatever that field contains - a receipt, a booking confirmation, a copy of what somebody signed. Separate, not a second To:, so your own address never lands in a stranger's inbox.

The address comes from the submission, which means a stranger chooses it, so two caps come with it and both count per hour:

mail_the_submitter_field: email
submitter_per_destination_hour: 3     # to any one address
submitter_per_site_hour: 60           # from this site, across every form

If a cap is reached, or the field is missing, empty or not an address, your own notification still goes and the reason is recorded against that delivery in the audit trail - the submission is not failed for it. Raise the caps if a real burst needs more; the default site cap sits above one lead form's busiest hour rather than at it.

A quarantined submission is never acknowledged. The spam controls hold a suspect submission back from your notification bell; writing back to the address it supplied would be the louder act, and the address is the one the sender chose. The reason appears against that delivery like any other.

Example

---
title: Contact
form: contact
---
::: form
name    | Your name     | required max:200
email   | Email address | required email
message | Your message  | required textarea max:5000
submit  | Send message
:::

Multi-step (wizard) forms

Split a long form into steps with a --- step --- line. Add a title with --- step: Title ---. The visitor moves through the steps with Back / Next (each step is validated before advancing) and submits once at the end.

::: form
name    | Your name     | required max:200
email   | Email address | required email
--- step: Your enquiry ---
subject | Subject       | required max:200
message | Your message  | required textarea max:5000
submit  | Send
:::

Notes:

Notes