Pass a FormData object as the body and Postboi converts it into a tidy HTML table. This
is what powers the SvelteKit form actions, but you can pass FormData to any mail() call.
body also accepts a plain object of fields (like Express/multer’s req.body), which
is normalised and parsed the same way:
And a promise resolving to any of these, so you can hand a framework’s request.formData() straight through without awaiting it yourself:
Special fields
These keys are extracted from the body and applied to the send options instead of appearing in the table:
_subject,_reply_to_form: which of your Postboi forms the submission is filed under, by name orform_…id. One route can serve every form on a site, each naming its own. A posted name can only pick a form your account already has: a post never creates one (see Naming the form)_to,_from,_cc,_bcc(the last two comma-separated or repeated): only withform_addressing: true
Values can be base64-encoded; they’ll be decoded automatically. Only a value that decodes
to readable text is decoded, so a plain one that happens to be valid base64 (Help, Test, Jobs) is left as typed.
Anything you pass to the send itself wins over the body, so a posted field can never replace
a recipient or subject your server chose. A blank string (to: '', say, from an empty CMS
field) counts as not passed, and falls through to the body or the defaults.
A form post is written by whoever submits it, so the fields that choose who a send goes to
are ignored unless you ask for them. Honouring _to on a public form would let any visitor
send your mail to anyone. Set the recipient on the send, or with default.to, and turn form_addressing on only when the body comes from your own code:
An ignored field is said once in the server’s log, so a form that relied on it doesn’t go quiet.
Set _reply_to to the submitter’s email so replies go back to them instead of your from address. A reply-to that isn’t an address (a typo in the email box) is dropped with
a warning, and the mail still goes, so replies go to your from address that time. See the SvelteKit form for the hidden-field
pattern. Typically that’s all a contact form needs: a reply-to is enough for replies to
reach the right person, with no per-message from or verified domain required.
Addresses may include a display name: "ACME Inc <hello@example.com>" arrives as ACME
Inc. That works anywhere an address does: _from, _reply_to, default.from, and the
CLI’s Default from prompt.
Two more fields are stripped before rendering: the _honey honeypot and Turnstile’s
token (cf-turnstile-response, or _captcha on SvelteKit remote forms). They drive the built-in spam protection and never appear in your
emails.
Grouped fields
Use the fieldset→field syntax to group related fields into sections:
This produces sectioned tables in the email body: one section per fieldset (contact, order), with a row per field.
Naming the form
On the Postboi provider, a send can say which form it came from:
Every submission that names a form is filed under it in the dashboard, and the fields it
carried become the columns of that form’s table, and of the CSV or spreadsheet exports
built from it. That works because the provider sends the submission’s fields as data
beside the rendered table (FormData’s own [name, value] entries, minus files and the _ specials), so the dashboard has the submission, not just the email.
A name is matched case-insensitively and created on first use, so nothing needs setting
up before the first send. bunx postboi sync then feeds your account’s forms (and their form_… ids) to form so they autocomplete. It still takes any other string, so you can
name a form in code before it exists on the account. Rename a form in the dashboard and
the API keeps answering to the old name, so what’s already shipped keeps landing in the
right place. The one exception: create a new form under a name an older one used to have, and that name now
means the new form.
A posted _form is weaker than the option. The visitor writes the post, so it can only
pick a form your account already has, and an unknown name files the submission under no
form rather than creating one. Otherwise anyone could file a submission under another of
your forms, setting off that form’s notifications, or invent names until the account hits
its form limit. Create the form first (name it from code once, or in the dashboard), or
pass form_addressing: true when the body comes from your own code and the name should
create it.
On a public form, set form on the send. A name pins it; form: true files it as a form
submission without naming one. Either way a posted _form is ignored, and said once in the
server’s log:
Every FormData (or form-fields) send is a form submission, captcha or not, and managed captcha gates it. Naming a form on an HTML body your server built files
it the same way, but isn’t gated: no visitor posted it, so there’s no widget token to
check. Forms are the Postboi provider’s: on a project whose postboi.config names another provider, sync records that too, and form becomes a type
error there rather than an option that silently does nothing.
Escaping
Field names and values are HTML-escaped before they reach the table, so a submission
can’t inject markup into the email. This matters because the form is usually public:
without escaping, anyone could put a <a href> or a tracking pixel into the
notification you read, arriving from your own sending domain.
Escape nothing yourself on the way in: you’d get visible < entities. The
derived plain-text body decodes back to exactly what the sender typed.
Multi-line values
Line breaks in a value become <br>, so a textarea arrives laid out the way it was
typed rather than collapsed into one run-on line. Browsers submit textareas with CRLF; \r\n, lone \r and lone \n are all handled, and blank lines survive as
consecutive breaks. A <br> someone submits is still escaped. Only the breaks
Postboi adds are real markup.
Hand-rolled bodies
Escaping applies to the table renderer only. An HTML string passed as body is
yours and is sent verbatim, so sanitise that yourself if any part of it came from a
user. The same two helpers are exported for that:
escape_html for single-line values and attributes, escape_lines when the value may
contain newlines.
Parsing a post yourself
The parser every provider uses is exported, for code that handles the same posts without sending them through postboi, so they render identically:
It takes its own escape_label and escape_value, a decode for special values, and the
same formatter as a send. It doesn’t check the honeypot or the captcha: providers strip
those first, and HONEYPOT_FIELDS and CAPTCHA_FIELDS name them. SPECIAL_FIELDS and FORM_ADDRESSING list the rest.
Attachments
Attachments work from file inputs (details→files) or via attachments: File | File[] on mail().
Customising the labels
The formatter option on mail() controls how fieldset and field
labels are rendered. Pass null or false to a part to leave those labels untouched.