Skip to main content
Guides File 030

FormData

How Postboi turns submitted FormData into tidy, sectioned HTML email tables.

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:

await mail({ body: req.body })

And a promise resolving to any of these, so you can hand a framework’s request.formData() straight through without awaiting it yourself:

await mail({ body: request.formData() })

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 or form_… 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 with form_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:

await mail({ body: form, form_addressing: true })

An ignored field is said once in the server’s log, so a form that relied on it doesn’t go quiet.

<input type="hidden" name="_form" value="Contact">

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:

<input name="contact→name" />
<input name="contact→email" />
<input name="order→product" />
<input name="order→quantity" />

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:

await mail({ to: "housing@example.org", body: request.formData(), form: "Home Ownership Query" })

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:

export const actions = { default: action({ form: "Contact" }) }
// or, with no form to name:
export const actions = { default: action({ form: true }) }

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 &lt; 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:

import { escape_html, escape_lines, mail } from "postboi"

await mail({
	subject: "New enquiry",
	body: `<p>From ${escape_html(name)}</p><p>${escape_lines(message)}</p>`,
})

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:

import { parse_form, HONEYPOT_FIELDS, CAPTCHA_FIELDS } from "postboi"

const { options, fields, attachments } = parse_form(await request.formData(), {
	max_fields: 100,
	max_value_length: 50_000,
	max_files: 20,
})
// options.subject, options.reply_to, options.form … and options.body, the table

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().

<input type="file" name="details→attachments" multiple />

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.