Add contact forms with built-in anti-spam and configurable dispatch.
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.
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.
::: form
field_name | Label text | rules
submit | Button label
:::
required - field must be filled in, adds required attributeoptional - field is optional (default)email - renders as type="email" inputtel - telephone input; gets a default validation pattern of
[0-9\s\(\)\+\.\-]{6,30} - digits, spaces, brackets, +, . and -, six to
thirty characters (override with pattern:)date / time - date / time picker (type="date" / type="time")number - numeric input; min:N / max:N set the value boundsurl - URL input (type="url")password - masked inputtextarea - renders as a textareaselect:opt1,opt2,opt3 - renders as a dropdownradio:opt1,opt2,opt3 - the same choice as a radio group, with every option
visible. Better than a dropdown on a page of short questions answered in
sequence, where a hidden list makes a mis-pick easy and silentchecklist:opt1,opt2 - checkboxes; the submission carries every ticked valuechecklist-qty:opt1,opt2 - checkboxes with a quantity box beside each. The
submission reads opt1=60; opt2=40, and an option ticked without a quantity
keeps its bare label. This is the shape of a "which ones, and how many"
answer, and it removes the free-text box people otherwise type it intoselect:"Smith, John","Jones". Quoting is
only needed for a label containing a comma - spaces and brackets have
always worked unquoted. Put any of these list rules LAST among a field's
rules, since each takes the rest of the linepattern:REGEX - HTML5 validation pattern. It takes the rest of the line, as
the list rules do, so put it LAST among a field's rules (or quote it - see below).
Example: phone | Phone | tel pattern:[0-9 ()+.-]{6,30}
Escape the punctuation inside a character class. A browser compiles this
attribute with the regular-expression v flag, under which (, ), {, },
[, ], /, | and a hyphen at either end of a class must be written with a
backslash - so [0-9 ()+.-]{6,30} is a syntax error and
[0-9\s\(\)\+\.\-]{6,30} is not. A pattern that will not compile is ignored
entirely, which means the field validates nothing and no browser says so. Run
lazysite-validate.pl on the page (or validate_page over MCP) and it names the
field and the character; lazysite check reports the ones already published.
placeholder:TEXT - greyed-out hint text inside the fieldmax:N - maxlength for text inputs (default 1000); the max value for numbermin:N - the min value for numberfile - renders a file picker (<input type="file">) for binary uploads
(images, PDFs, ...). Add multiple to allow several files in one field, and
accept:LIST to hint the browser's picker (accept:image/* or
accept:.png,.pdf). The form automatically switches to
enctype="multipart/form-data" when it contains a file field.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.
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).
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.
---
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
:::
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:
lazysite/forms/.secret