Forms
Add contact forms and data collection to any page.
Overview
lazysite forms are defined inline in page content using :::form blocks.
The processor generates an HTML form with built-in anti-spam protection.
Submissions are handled by a CGI script that validates them and hands
each one to the handlers its form names. A handler is a named function -
send an email, keep a file, store a row in a data table, send through a
connector - and the schedule calls the same handlers on a timer. Handlers are
configured on the manager's Handlers page, over the control API and MCP, or
with lazysite-handlers.pl; they are stored in lazysite/forms/handlers.conf.
Architecture
Three config files work together:
lazysite/forms/FORMNAME.conf- Per-form config. Lists the handler IDs that receive submissions - and nothing else: a target names a handler, never a destination.
lazysite/forms/handlers.conf-
The handlers (email, file, data table, connector). Each has an
id, atype, aname, and its type's settings. lazysite/forms/smtp.conf- SMTP connection settings shared by all SMTP-type handlers.
A form targets one or more handlers by ID. Multiple forms can share the same handler, and a form can dispatch to multiple handlers at once.
Quick start
- Add
form: formnameto the page's front matter - Add a
:::formblock with field definitions - Create the handler it delivers through, on the Handlers page - reached from
Extensions, or from a form on Submissions - (or with
save_handler/handler-save) - Bind the form to it - the Forms list on the same page,
bind_form, orform-targets-save - For email handlers, configure the Form SMTP extension (
lazysite/forms/smtp.conf)
Front matter
The form: key enables form processing for the page and names the form.
The name must be alphanumeric with hyphens and underscores only:
---
title: Contact
form: contact
---
Without form: in front matter, :::form blocks render as an HTML
comment and a warning is logged.
Field syntax
::: form
field_name | Label text | rules
submit | Button label
:::
Each line defines a field. Fields are separated by pipe characters:
- field_name - the HTML
nameattribute (alphanumeric, hyphens, underscores) - Label text - displayed above the field
- rules - space-separated modifiers (see below)
The submit keyword as field name renders a submit button with the
label as button text.
Field rules
These rules are enforced by the server as well as the browser. Until 0.15.2 they were rendered as HTML attributes and checked by the browser alone, so a submission posted directly - which is most automated traffic - was held to none of them. A breach is now refused with a message naming the field and the rule.
Two consequences worth knowing as an author:
- the server holds a submission to what was rendered, so a rule you change takes effect when the page next renders, exactly as the visible form does;
- a
pattern:a browser cannot compile is ignored at submission too, because that is what the browser does with it.validate_pagerefuses such a pattern andlazysite checkreports ones already published, so you find out at authoring time rather than from a form that quietly accepts anything.
A refusal for breaking one of these rules is counted separately from the spam controls, so "controls stopped N" on the Stats page still means spam.
required-
Field must be filled in. Adds HTML
requiredattribute and shows an asterisk after the label. Enforced at submission on the fields the form declares - never on a field a visitor was not shown. optional- Field is optional. This is the default if no rule is specified.
email-
Renders as
type="email"input with browser validation. textarea-
Renders as a
<textarea>instead of a single-line input. select:opt1,opt2,opt3-
Renders as a
<select>dropdown with the given options. radio:opt1,opt2,opt3- The same choice with every option visible, rather than hidden behind a click.
checklist:opt1,opt2- Checkboxes. The submission carries every ticked value.
checklist-qty:opt1,opt2-
Checkboxes with a quantity box beside each; the submission reads
opt1=60; opt2=40. An option ticked with no quantity keeps its bare label.
Option labels containing a comma must be quoted - select:"Smith, John",Jones.
Spaces and brackets need no quoting. A list rule takes the rest of the line, so
put it last among a field's rules.
max:N-
On a text field, sets the
maxlengthattribute - a length limit. On anumberfield,min/maxare value bounds instead. Default is 1000 characters if not specified. value:"TEXT"-
A default the field opens with. The visitor can change it; it is an
ordinary starting value, not a fixed one. To put a double quote inside
the text, write it as
\"— sovalue:"the \"Old Barn\" room"opens with the "Old Barn" room. A quote left unescaped ends the value early, and the site log says which field and which rule it could not read. prefill:PARAM-
Fills the field from a query parameter, so a code the link already
carries does not have to be typed. The parameter must be declared in the
page's
query_params:list - the allowlist is the gate, and aprefill:naming a parameter the page never declared fills nothing and says so in the render log. Where both are given,value:is the fallback and the URL wins when the parameter is present.
```markdown
title: Apply form: apply query_params: - c
:::form code | Your registration code | required max:12 prefill:c name | Your name | required ::: ```
/apply?c=ODX-4417 opens with the code already in the field - one tap from a
QR code or an emailed link.
The value is escaped where the field is written, so Smith & Sons arrives
in the field as typed. Text that looks like a template tag - [% ... %] - is
shown as written, never run. The same is true of a value: default.
A prefilled value proves nothing. It arrives from the URL, so a visitor can change it exactly as they could change something they typed. Treat it as what somebody claims, never as something the site has verified - if the value must be trusted, check it after the submission arrives.
Example
---
title: Contact
form: contact
---
## Get in touch
::: form
name | Your name | required max:200
email | Email address | required email max:254
phone | Phone number | optional max:30
subject | Topic | required select:General,Support,Sales
message | Your message | required textarea max:5000
submit | Send message
:::
Handler configuration
Per-form config
lazysite/forms/FORMNAME.conf lists the handlers that receive
submissions:
targets:
- handler: email-delivery
- handler: local-storage
Each entry references a handler by id. All listed handlers are
called on each submission. If one handler fails, the others still
run, and the visitor is thanked when at least one delivered. Every
handler's outcome is its own line in the audit trail - the form, the
handler and whether it delivered, never the fields.
What the visitor is told
Two sentences, both written by the engine:
| What happened | What the visitor sees |
|---|---|
| The submission reached a store, a table or a mailbox | Thank you - your message has been sent. |
| A connector took it onward to a service | Thank you - your submission has been received and sent for processing. |
The second applies when any target on the form is a connector handler, because then the submission left the site for a service to act on rather than arriving for somebody to read - "your message has been sent" would not be true. A form with both kinds of target gets the second sentence: both things happened, and the onward one is the one the visitor could not otherwise know about. It promises neither a reply nor a page to come back to, because only you know whether your site shows the answer anywhere.
The wording is not configurable, and that is deliberate. A submission without JavaScript is answered by a redirect back to the page, and the sentence is chosen from the outcome the redirect carries in the query string. A query string is writable by whoever composes the link, so anything travelling in it could be put on your page by a crafted URL. What travels is a short token naming one of the two sentences above; an attacker choosing between them gains nothing, and a token that is not one of them is shown as a refusal rather than as text.
The file's other keys (rate_limit, upload_*, quarantine and the rest,
below) are the form's own and survive every binding. A config written over
WebDAV is checked the same way the binding actions check it: a target that
is not - handler: <id>, or names a handler that does not exist, is refused
with 422 and the reason.
Named handlers
lazysite/forms/handlers.conf defines the handlers:
handlers:
- id: email-delivery
type: smtp
name: Email delivery
enabled: true
from: webforms@example.com
to: admin@example.com
subject_prefix: "[Contact] "
- id: local-storage
type: file
name: Local file storage
enabled: true
path: lazysite/forms/submissions
- id: slack-notify
type: connector
name: Slack notification
enabled: false
connector: slack
A handler with enabled: false delivers nothing; a form whose only handler is
off refuses the visitor rather than thanking them. An absent enabled is on,
everywhere.
Who may configure a handler
Where a handler sends decides who may create, change or delete it (SM842):
| Type | Needs |
|---|---|
smtp, file |
manage_forms |
table |
manage_data |
connector |
manage_connectors |
notify |
manage_notifications (off by default; the sysop groups hold it) |
Changing a handler's type needs both. Binding a form to a handler that
already exists needs manage_forms alone - the vetting happened when the
handler was made. A refusal names the capability that would work.
A handler may belong to one domain (SM1051): give it domain: with the
host as list_domains names it. A partner whose grant is confined to some
domains creates, changes and deletes only handlers of its own domains, is shown
the instance's handlers and its own domains', and binds its forms to either. A
handler with no domain: is the instance's: only an unconfined manager (the
operator) creates, changes or deletes one.
Handler types
smtp-
Sends form data as a formatted email. Requires
from,to, andsubject_prefix. Connection settings come fromlazysite/forms/smtp.conf. See Forms SMTP. file-
Writes each submission to a file under
path. Useful for logging, offline processing, or testing without email infrastructure. connector-
Sends the submission through a named connector -
the only way data leaves the site over HTTP. A connector with nothing but
a URL is the simple case; it may add a credential, a rate cap and a record
of every call, and
format: slacksends the Slack message shape. For a form, the connector must permit public invocation (modes.public, off by default) - binding a form to one that does not is refused, because it would refuse every submission. Give the form fixed choices rather than free text for any field that reaches the remote. table-
Inserts each accepted submission as a row in a declared data table, under
a
fields:mapping (form_field=column,...) that decides which field goes in which column - a field nobody mapped is dropped. Withkeep_copy: true(the default) the JSONL submissions store is written alongside, so the Submissions page, exports and bulk delete keep working; a submission the table's types refuse leaves no row, the visitor is told it failed, and the stored copy is marked_row_refused.keep_copy: falsestores the row only.row_policy: personalmakes each row belong to the account that submitted it, so one person maintains their own row and nobody else's - it needs the table to declarerow_policy: true, and it needs the submitter to be signed in, because a public form records no account and the row would belong to nobody. Leave it out and rows are shared, as before.files_column:names a column of the same table to receive the submission's uploaded files, as site-relative paths, comma separated - so the row somebody works from reaches the photograph instead of only the submissions store. It needs the copy, which is what stores the files, and the column is checked against the table when the handler is saved. See Data tables. notify-
A notification rule: tells people that something happened - a comment on
a post they follow, say - by email and the site bell. Bind it as a form's
second target (after the handler that stores the submission) or call it
from the schedule. It does not send while the visitor waits: it queues the
event, and the daemon's
notify-outboxjob sends within a minute, so the site must run the daemon and its job account must holdmanage_notifications. A quarantined submission notifies nobody.
recipients is a JSON list on one line, each item one source:
{"accounts":["ann"]}, {"owners_of":{"table":"subscriptions","where":{"post":"post"}}}
(the accounts that wrote the matching rows - the table needs
timestamps: true), {"addresses_from":{"table":"members","column":"email","where":{"list":"list"}}},
{"fixed":["desk@example.org"]}, {"submitter":"account"} (the signed-in
submitter; needs notify_actor: true) or {"submitter":{"field":"email"}}.
A where maps a column to a field of the event; if the event lacks that
field, the source matches nobody. channels is email, bell or both;
the bell reaches accounts only, each seeing their own notices. The account
that caused the event is left out unless notify_actor: true.
template: comment reads lazysite/notify-templates/comment.email.tt -
a first line Subject: ..., then the body - and comment.bell.tt, with
the event's fields as fields.<name> (each at most 400 characters),
actor, recipient, rule, unsubscribe_url and manage_url. A non-ASCII subject is
encoded for mail (RFC 2047). Without a template the message lists the
fields.
unsubscribe is on by default: every email carries a one-click link that
stops THIS rule writing to THAT recipient, with no sign-in, and the
List-Unsubscribe and List-Unsubscribe-Post headers (RFC 8058), so a
mail client can offer it too. Opening the link shows a page asking to
confirm - a mail scanner fetching it stops nothing - and the button (or the
mail client's one-click request) records the stop; asking again changes
nothing. A stopped recipient gets nothing more from that rule, on any
channel, including what the cap was holding for them; other rules are not
affected. The link names the rule and a hash of the recipient, never an
address, and is signed with a key only the site holds
(lazysite/notify/.unsubscribe.key; deleting it voids every link sent).
Every stop, repeat and refused link is in the audit trail. The link is
made absolute with the site's site_url: set it on the Config page
("Use this address") so a link sent by the scheduler has an address.
unsubscribe_path, if given, is a page of this site linked beside it as
the place to manage subscriptions.
per_recipient_hour (default 3) caps how many messages of this rule one
recipient gets in an hour. Past it, the next event is held, and so is every
later one while something is held: when the hour moves on, the recipient
gets ONE message carrying them all, in order - each part as the template
renders it, the subject saying how many more are folded in - and that
message counts against the cap like any other. A held event is an audit row
of its own (held). One message holds at most 50 events; past that an event
is refused and recorded. A cap of 0 sends nothing.
The Handlers page shows each rule's queue on its row - queued, retrying,
held by the cap, failed - and what its last run did, with the last
failure and when. When events wait more than three minutes and the
scheduler is not taking them (the daemon is not running, the
notify-outbox job is refused, or it last ran long ago) the row says
waiting for the scheduler and why. The Notification outbox card
lists the queued, held and failed events, and for each event who it
resolves to under the rule as it stands and what was decided for each
(sent, refused, held, pending). It shows recipients, so it needs
manage_notifications, and an account confined to some domains sees only
their rules (notify-outbox-list on the control API, read_notify_outbox
over MCP).
Retry now (SM1107): after a mail fix, an entry in backoff can be made due
at once - the button on its row in the card, notify-outbox-retry on the
control API (a rule or an event id), retry_notify_outbox over MCP. It sends
nothing itself: the job does on its next run, so the per-recipient cap and a
held message still apply and an event already sent is not sent again. The
audit trail records it.
Who may be written to is the operator's decision, and complying with privacy and consent rules is the operator's responsibility (ruled 2026-10-12). The engine guards the technical side only: every recipient is exactly one address, refused if it names more; each message is sent once, even across retries; the cap above; and one audit row per recipient, naming the account or address and the outcome.
There are no webhook, api or db handlers any more: a webhook is a
connector, and db is table with keep_copy: false. The upgrade to
0.13.13 converts them, and converts inline form targets into named
handlers; lazysite-check reports anything it could not.
Handlers on a timer
The schedule calls handlers too: an entry names a handler, how often (in
seconds, at least 300) and the fixed fields it is called with. It lives in
lazysite/forms/schedule.conf, and is edited on the Handlers page,
save_schedule / schedule-save, or lazysite-handlers.pl schedule-save.
Nothing a visitor sends reaches a scheduled call.
schedule:
- id: nightly-export
handler: crm
every: 86400
enabled: true
payload: {"source":"timer"}
The daemon runs each entry as its job account, which must hold the capability of the handler's destination - the same rule as above. An entry it may not run is refused by name in the run record and retried on the next tick once granted; a delivery that fails waits its interval.
Where a submission is POSTed
Only /cgi-bin/form-handler.pl accepts a submission. The generated form carries
it in its action attribute, so a visitor's browser does the right thing without
anyone thinking about it.
It matters when you are testing a form by hand, or driving it from a script.
POSTing the fields to the page URL instead - /contact, say - returns
HTTP 200 and the rendered page, and stores nothing. A 200 with a page body is
indistinguishable from success to anything checking status codes, so a test can
report a form working when nothing was ever stored.
Confirm a submission by what the store holds, not by the status code: form_list
shows the row count, and read_form_submissions reads the rows back.
What happens when a submission arrives
Once a submission is stored, the site raises a notification. You do not need to poll for one, and nothing is required to make this happen - it is automatic.
The notification appears in the manager's notification bell, which is the
record. Where the notify-xmpp extension is configured, the same notice is also
delivered as a chat message, so you hear about it without being logged in.
By default (notify: on) the message names the form and when it arrived, and
carries none of the submitted content, so it is safe to receive on a phone in
a public place. To see what was submitted, follow its link or use the actions in
the next section.
With notify: full the notice carries the submitted fields (long values are
truncated) - to the bell and, where XMPP is configured, to the chat account the
site names. That sends the submission off the site, as an email handler does by
mail; XMPP delivery is encrypted in transit by default. Choose it for a form
whose submissions you would be content to receive by email.
Delivery is best-effort by design: the chat send is time-boxed so a slow or unreachable server can never delay the visitor's submission, and if it fails the stored notice is still there. The bell is authoritative; chat is a convenience.
To check where a form's notice will go, open the manager's Handlers page: each
form says which endpoints it is announced to, and for any it is not, the one
setting that stops it - the form's own notify: off, the site's
lazysite/notify.conf, or the XMPP extension and its account.
The form's own setting can be changed there too: This form's notice is Off
(no notice), On (one line) or Full (the notice carries the submission). It is
written to the form's conf as notify:. Over the API the same change is
form-notify-save ({ "form": "contact", "notify": "off" }), or
set_form_notify over MCP; the reply says where a submission notice now goes.
A notice for a form that keeps its submissions in a file links to that
submission: following it from the bell opens the one row that arrived, with a
link on to every submission in the store. An agent reading the bell can follow
the same link with read_form_submission (MCP) or form-submission (API), using
the file and id in the notice's url.
Quarantined submissions do not notify. A submission held back by the spam controls is recorded but does not raise a notice, so a spam run cannot flood you.
What the spam controls look at. Quarantine is on unless you turn it off
(quarantine: off in the form's conf) and is content-based - no puzzle for the
visitor, no tracker, nothing sent anywhere. It holds a submission back when:
- it carries
spam_url_thresholdlinks or more (two by default). A link is counted whether or not it was written withhttps://: awww.host and a host with a path count too, one host counts once however many ways it is written, and an email address is not a link. - it contains a link written to dodge a filter -
example .com/offer, with a space inside the host. One is enough; nobody types that by accident. - it links to a link shortener (
bit.ly,tinyurl.comand similar - a shipped list). One link is enough: a stranger hiding where a link goes is close to a sure sign. Naming a shortener in a sentence is not a link. - every field holds the same value, or one run of five or more digits appears in every field, on a form with three or more filled in. That is a bot checking whether the form delivers, not a message.
- it matches one of your own
spam_keywords(a comma-separated list, empty by default).
The reason is stored with the submission and shown on the quarantine filter, so you can see which control held it and tune the threshold rather than guess.
Is it on for this form? The Handlers page says, on each form's row ("quarantine on" or "quarantine off"), and the form's expanded view lists what holds a submission.
One control you have to ask for: spam_own_domain: on. With it, a submission
whose sender field holds an address at this site's own domain is quarantined -
nobody outside an organisation fills in its public enquiry form as one of its own
people, so the address is either forged or a member of staff testing the form, and
neither needs announcing. It is off unless you turn it on, unlike the controls
above, because a form that genuinely is for internal use would otherwise start
holding back its real traffic.
It reads the field's whole value, so a customer who merely mentions one of your
addresses in their message is unaffected, and it matches your domain and its
subdomains - not a domain that merely ends with yours. The site's own domain comes
from site_url and from the host the request arrived on; if your site is on a
subdomain (shop.example.com), only that name and its subdomains count.
Configuring chat delivery
Chat delivery needs the notify-xmpp extension enabled (the Extensions
page, or the extensions: list in lazysite.conf) and a client config at
lazysite/notify-xmpp.conf:
jid: site-bot@example.com # required - the account the site sends AS
password: secret # required
to: you@example.com # required - an individual JID, or a room
host: xmpp.example.com # optional - leave BLANK to use the SRV record
port: 5222 # optional - leave BLANK to use the SRV record
tls: 1 # optional - defaults to on
muc: 0 # set 1 when `to` is a group chat room
nick: My-Site # optional - defaults to the site name
All three of jid, password and to must be present or delivery is skipped -
and the reason is logged, naming which key is missing.
Test it before relying on it. Send a test notification on the extension's
row signs in, sends, and then waits three seconds for the server to refuse the
recipient - a JID that does not exist is accepted at first and refused a moment
later, and the test reports that refusal with the server's reason. No refusal
is not proof of arrival: a server can still drop a message silently, so check
it came. Every delivery failure - from the test or from a real notice - is
written to the audit log (action notify, status failed, with the cause), so
it can be exported. A real notice does not wait for a refusal, because it is
sent while the visitor's form submission waits for its reply.
Leave host and port both blank unless you know better. With neither set,
the engine asks the account's own domain where its client endpoint is
(_xmpp-client._tcp.<domain>, the SRV record), which is how most servers publish
an endpoint that is not the JID's domain and not port 5222. Setting either one
means exactly that: nothing is looked up, and the other takes its usual default.
One client and one recipient per site; use a room (muc: 1) when several people
should see the notices.
The connector needs Net::XMPP and Net::DNS - on Debian, libnet-xmpp-perl
and libnet-dns-perl.
Testing it before anything happens
The Extensions page's row for XMPP notifications carries Send a test notification. It signs in and sends one message that says it is a test, then reports every stage it reached - the client library, the endpoint it chose and where that came from, the connection, the sign-in, and the send - along with the message body exactly as it went out.
Read the body as well as the verdict: it is rendered by the same templates a real notice uses, so a link that comes out wrong on this site comes out wrong here, where you can see it. A group chat join is reported as sent, not confirmed - nothing answers a join on this path, so a room that refuses one shows up as the message never arriving.
Reading what a form collected
A form with a file handler writes each submission to a store under
lazysite/forms/submissions/<name>.jsonl. Two actions read it, and both need
the read_submissions capability - a deliberate least-privilege grant that
permits reading submissions without permitting any edit to forms or
handlers.
form_list(MCP) /form-list(control API)-
Which forms exist, which handler types they use, whether a store exists, and
row_count- the number of submissions. Counts only; it never returns content. A form that reports a count is a form whose content you can read with the action below, given the grant. read_form_submissions(MCP) /form-submissions(control API)-
The submitted rows themselves - columns, rows, a stable
_idper row, most recent 500. Values are the raw submitted data and should be treated as untrusted.
If those actions are not offered to your account, the capability has not been granted rather than the feature being absent. Ask the operator.
Deleting a submission
Deleting is a separate capability, delete_submissions, and it is off by
default. Reading and deleting are different authorities: an agent that processes
leads needs the first and usually not the second.
It permits deleting any submission on the site, not just the ones your own testing produced. A stored submission records no author - deliberately, because an unauthenticated form cannot produce a trustworthy one - so there is nothing on the row to narrow the grant to. Grant it to an agent that tidies up after itself, and withhold it from one that only reads.
form-submission-delete removes one row and form-submissions-delete-bulk
removes a set. The manager's Submissions page deletes without this capability, as
it always has: it is for a person who signed in, and this grant is for a client
that did not.
Approving an account from a submission
A form that collects requests for an account says which of its fields holds the address, in its own conf:
targets:
- handler: local-storage
account_email: email
The manager's Submissions page then shows Approve on each of that form's
rows. Approving creates the account in one step, with the same rules as
account-approve (see Letting people ask for an account): no
password, the groups flagged to take registrations, the address recorded on the
account, and a set-password link the person can also fetch for themselves from
the Forgot password page.
- The address comes from the stored row, never from the request. The operator checks it and decides; the system does the typing.
- The username is the address's local part, lowercased -
Ada.Lovelace@example.orgbecomesada.lovelace. Nothing is stripped or added. When that name cannot be used, the approval is refused with the reason and nothing is created: a character a username may not hold (letters, digits,.,_and-only), a name already taken in any letter case, or an address another account already records. Create that account on the Users page with a name you choose. - A row is approved once. It then shows the account it became, and approving it again is refused. A second submission from the same address makes the same name, so it is refused too.
- A quarantined row is not offered. Confirm it first if it is genuine.
- A form without
account_email:offers no Approve, and its submissions stay ordinary submissions.
Approving needs approve_accounts (SM979), a grant made for this job: its holder
sees the submissions of the forms that declare account_email: - the account
requests - and no other form's, approves them, and manages no account, group or
file. Full user management (manage_users) approves as well, as it always has.
It is offered only on the manager's Submissions page: no token or MCP client can
approve an account. Each approval is in the audit trail -
who approved, which submission, and the account it became.
Forms as an intake mechanism
A form is the supported way for an anonymous browser to send something to a lazysite site. It needs no sign-in, no credential and no software, and it works on a phone.
That extends further than a contact form. A field declared textarea accepts a
long passage of typed or pasted text, with the maximum set per field
(max:20000), so a transcript, a questionnaire answer or a pasted document
arrives intact. Where a file is easier than a paste, a handler may accept
uploads with per-file and per-submission limits - see
Form helpers for upload_max_kb and upload_max_files.
Submissions are append-only and each field is validated on its own terms, which suits capture and does not suit a large document being edited repeatedly. Treat the store as a capture surface: material that matters should be read out and kept wherever your records are managed.
Client-side behaviour
Forms submit via fetch() (AJAX). On success, the form is replaced
with a success message. On error, an error message appears below the
submit button. The page does not reload.
The form status area uses aria-live="polite" for screen reader
accessibility.
Security
All security measures are automatic - no configuration needed:
Honeypot field - a hidden field (_hp) that must be empty.
Bots that fill all fields are rejected.
HMAC timestamp token - submissions must arrive between 3 seconds and 2 hours after the form was rendered. Prevents replay attacks.
Rate limiting - maximum 5 stored submissions per form, per address, in any
rolling hour. Each form has its own allowance, so posts to one form never
refuse another, and only a submission the form stores spends it: a post
refused for a missing or malformed field costs the visitor nothing. Uses
DB_File for persistence.
The ceiling is per form: rate_limit: 200 in the form's config raises it, and rate_limit: off removes it. Five an hour is right for a public contact form and wrong for an authenticated team working through a set of data-entry pages from one office address. Set it only on a form whose access is already controlled another way - the limit is what protects an open form from being used as a relay.
Submission validity window - a form's page carries a timestamp signed at render, and a submission arriving more than two hours later is refused.
The window is per form: timestamp_window: 86400 in the form's config widens
it, and timestamp_window: off removes the age ceiling. Two hours is right for
a contact form and wrong for a long, careful one - crossing it is the ordinary
case there, and the refusal lands after the typing rather than before it. off
disables the age check ONLY: the signature must still match, so a timestamp
cannot be forged or lifted from another form, and a submission arriving within
three seconds of render is still refused as automated.
Header injection prevention - CR/LF characters stripped from all fields.
The HMAC secret is auto-generated and stored at
lazysite/forms/.secret (chmod 0660 - owner + group, never world,
so both the site user's tools and the web-server CGI can use it
whichever minted it first).
Installation
The installer places both extensions under {docroot}/../plugins/
and symlinks form-handler.pl into cgi-bin/ so Apache can route
/cgi-bin/form-handler.pl at it. form-smtp.pl does not need
cgi-bin/ presence - it is invoked as a subprocess by
form-handler.pl.
For manual installation:
mkdir -p /path/to/plugins
cp plugins/form-handler.pl plugins/form-smtp.pl /path/to/plugins/
chmod 755 /path/to/plugins/form-handler.pl /path/to/plugins/form-smtp.pl
ln -s /path/to/plugins/form-handler.pl /path/to/cgi-bin/form-handler.pl
Further reading
- SMTP configuration - email delivery setup
- Form helpers - writing custom dispatch targets