# Postboi > Postboi — A framework-agnostic email library optimised for SvelteKit. One API, swappable providers, zero configuration — turn FormData into tidy HTML emails. This file contains the complete documentation as a single Markdown document. A per-page index is available at `/llms.txt`, and individual pages at `/raw/`. --- --- title: Introduction name: Introduction description: Postboi is a framework-agnostic email library optimised for SvelteKit — one API, swappable providers, zero configuration. category: Getting started --- Postboi is a framework-agnostic email library optimised for SvelteKit. It works with a variety of email providers and turns your `FormData` into tidy HTML emails, with **zero configuration**. Import `mail`, call it, done — the provider and credentials come from environment variables, so the same line of code works whether you're on Resend, Postmark, Mailgun, or any of the other supported providers. ```typescript import { mail } from 'postboi' await mail({ to: 'contact@example.com', subject: 'Hi', body: '

Hello

' }) ``` ## Features - 👨‍💻 **Zero configuration** — works out of the box with minimal setup. - 🔌 **Provider-based** — swap email providers without changing your code. - 📝 **Smart FormData parsing** — automatically converts `FormData` to HTML tables. - 🎯 **Grouped fields** — organise form fields with `fieldset→field` syntax. - 📎 **Attachments** — attach files directly from form inputs or `File` objects. - 📮 **Hosted forms** — no backend? Point any HTML `
` at a [hosted endpoint](/forms) and submissions land in your inbox, spam-checked. - 🎨 **Bring your own templates** — `body` takes any HTML (or a promise of it), and the optional `postboi/maizzle` helper renders [Maizzle](/templates) templates straight into it. - 🛡️ **Type-safe** — full TypeScript support with normalised error handling. ## How it fits together There are three ways to use Postboi, from least to most explicit: 1. **Zero-config `mail()`** — reads the provider and defaults from a committed `postboi.config.ts`, and secrets from the environment. Best for apps where one provider is set per environment. 2. **`postboi/kit` form actions** — a one-line SvelteKit action that reads `FormData`, sends it, and returns a result. 3. **A provider instance** — `new Resend({ ... })` when you want an explicit instance or you're on a runtime without ambient env vars. Start with the [Quick start](/quick-start) and let the CLI wire everything up for you, or set things up by hand with [Manual setup](/manual-setup). ## Next steps | Page | What you'll find | | ------------------------------------------ | ------------------------------------------------- | | [Quick start](/quick-start) | `postboi init` — pick a provider, send your first email | | [Manual setup](/manual-setup) | Wire Postboi up by hand without the CLI | | [SvelteKit form actions](/sveltekit) | One-line contact forms | | [Hosted forms](/forms) | Contact forms for static sites — no backend | | [Email templates](/templates) | Design emails with Maizzle, React Email, or MJML | | [Providers](/providers) | The full provider list and their options | | [API reference](/api) | `SendOptions`, types, and the provider surface | --- --- title: Quick start name: Quick start description: postboi init picks a provider, collects credentials, writes your env vars, and installs Postboi. category: Getting started --- One command, fully set up. The CLI picks a provider, asks for credentials, optionally sets defaults, and installs `postboi` if it isn't already there. ## What it does - Prompts you to choose one of the [supported providers](/providers). - Collects that provider's credentials (e.g. an API key, plus a domain for Mailgun). - Optionally collects `from` / `to` and other defaults applied to every send. - **Writes only secrets** (API keys, tokens) to your env file. - **Writes everything else** — the provider, defaults, and non-secret options like a Mailgun domain or SES region — to a committed [`postboi.config.ts`](/config). - Installs `postboi` if it isn't installed yet. - Offers to install the **postboi agent skill** into `.claude/skills/` — a condensed cheat-sheet that teaches AI coding agents the library's conventions. It ships inside the package, and `postboi sync` keeps an installed copy current across upgrades. The split means the best case (Resend, Postmark, SendGrid, Brevo, …) is **a single env var** — the API key — with the rest in version control. ```ts // postboi.config.ts (committed) import { config } from 'postboi' export default config({ provider: 'resend', default: { from: 'no-reply@example.com' } }) ``` ```bash # .env (gitignored — secrets only) RESEND_API_KEY=re_xxxxxxxx ``` After it finishes, `mail()` picks all of this up on every call — no further wiring needed. ```typescript import { mail } from 'postboi' await mail({ to: 'contact@example.com', subject: 'Hi', body: '

Hello

' }) ``` ## Prefer to do it yourself? Skip the CLI and write the config file plus the credential env vars by hand, or construct a provider instance directly. See [Manual setup](/manual-setup) and [Providers](/providers). --- --- title: The Postboi provider name: The Postboi provider description: Zero-config sending — one command, one token, no provider account, no DNS. category: Getting started --- The zero-config way to send. No provider account, no DNS records, no card — run one command, authorise in the browser, and `mail()` works. Pick **Postboi** when prompted. The CLI opens your browser to authorise the device, then writes a single env var — `POSTBOI_TOKEN`, your API key (keep it secret). Everything else is config, not environment: it offers to set defaults (`to`, `reply_to`, `cc`, `bcc` — and `from`, once you have custom domains to choose between) and writes them to a committed [`postboi.config.ts`](/config) — the same file where you can add [hooks](/hooks) later. The CLI knows your domains and their verification status, so it won't accept a default `from` at a domain that isn't on your account (listing the ones that are), and warns when the domain is still pending verification. That's the whole setup: ```ts import { mail } from "postboi" await mail({ to: "someone@example.com", subject: "Hello", body: "

Sent through the Postboi provider

", }) ``` `from` is optional — when omitted entirely, the API uses your account's sending address, which the token identifies. Env vars still override config for per-environment tweaks (`POSTBOI_FROM` beats `default.from`), but nothing needs to live in the environment except the token. The Postboi provider also includes **managed invisible captcha** for your forms — one script tag from the dashboard, no Cloudflare account, no keys. See [Spam protection](/spam). ## Your sending address Free-tier mail goes out from `you@send.postboi.email`, derived from your signup email. It's a real, deliverable address on our reputation-managed sending domain — but it isn't an inbox, so set `reply_to` if you want responses: ```ts await mail({ to: "someone@example.com", reply_to: "you@yourdomain.com", subject: "Hello", body: "

Replies come to you

", }) ``` You can rename the address (once a day) from the [dashboard](https://postboi.email/dashboard). ## Sending from your own domain Verify a domain in the dashboard — add a domain, publish the three DKIM CNAME records it shows you, and hit **Check**. Once verified, any address at that domain is a valid `from`: ```ts await mail({ from: "hello@yourdomain.com", to: "someone@example.com", subject: "Hello", body: "

From your own domain

", }) ``` ## Type-safe `from` `postboi init` (and `bunx postboi sync`) generate types from your account's sending address and domains, narrowing `from` so TypeScript rejects addresses you can't send from — before the API does it at runtime: ```ts await mail({ from: "foo@unknown-domain.com", ... }) // ^ Type error: must be your send.postboi.email address or an address // at one of your domains. Run `bunx postboi sync` to regenerate. ``` Display-name form works too (`"Joe Bloggs "`), and pending domains are included deliberately — you can write the code while DNS propagates; deliverability is enforced at send time either way (`from_not_allowed`). The generated types live *inside* the installed package (`node_modules/postboi`), so there's no file in your project — nothing to commit, gitignore, or see in diffs. Three consequences of that: - **A reinstall resets them.** `init` adds a `"prepare": "postboi sync"` script that restores them after every install (chained onto your existing prepare script, if any). - **They're always optional.** Without them (fresh clone, CI without a token, teammate who hasn't run init), `from` falls back to plain `string` — builds and deploys never fail because the types are missing. `sync` itself is a quiet no-op without a `POSTBOI_TOKEN` and always exits 0, so it's safe anywhere. - **They're a snapshot.** Re-run `bunx postboi sync` after adding or removing a domain (your editor may want a TS-server restart to pick the change up). This only applies to the Postboi provider (we can't know another provider's identities). If you mix Postboi with a bring-your-own provider in one project, remove `postboi sync` from your prepare script — the narrowing applies to `from` everywhere. ## Limits | Plan | Included | Daily cap | Overage | | ------- | ---------- | --------- | --------- | | Free | 3,000/mo | 100/day | — (hard) | | Starter | 20,000/mo | — | £0.40/1k | | Pro | 100,000/mo | — | £0.35/1k | | Scale | 500,000/mo | — | £0.30/1k | The free tier stops at its caps; paid tiers keep sending and meter the overage. Every plan has a burst rate limit. When a limit is hit, `mail()` throws a `PostboiError` with a machine-readable `code`: | Code | Meaning | | ------------------------ | --------------------------------------------------- | | `daily_limit_exceeded` | Free-tier daily cap — resets at midnight UTC | | `monthly_limit_exceeded` | Free-tier monthly wall — upgrade to keep sending | | `rate_limited` | Burst limit — back off and retry | | `from_not_allowed` | `from` isn't your address or a verified domain | | `sending_paused` | Bounce/complaint rate tripped the safety threshold | ## Delivery status Every send appears in the [message log](https://postboi.email/dashboard/messages) with its delivery status — bounces and complaints are tracked automatically. High bounce or complaint rates pause sending to protect deliverability for everyone; the dashboard shows when that happens. You can also look a message up from code with the id `mail()` returned: ```ts import Postboi from "postboi" const mail = new Postboi() const message = await mail.message(id) // { id, status: 'sent', to, subject, opened_at, open_count, … } ``` And a scheduled message can be moved (until it sends) — `reschedule` takes the same formats as `scheduled_at`: ```ts await mail.reschedule(id, { days: 2 }) // or a Date / ISO 8601 string ``` ## Batching & idempotency [Personalized batches](/bulk#personalized-batches) go out as **one request** to the batch endpoint (up to 100 recipients per call) instead of one per recipient: ```ts await mail.send({ to: ["ada@example.com", "linus@example.com"], subject: "Hey {name}", body: "

Hi {name}

", data: { "ada@example.com": { name: "Ada" }, "linus@example.com": { name: "Linus" }, }, }) ``` Single sends accept an [`idempotency_key`](/errors#retries): retrying a send with the same key returns the original message id instead of delivering a duplicate — pair it with `retries` for safe automatic retry. ## Lists & broadcasts The dashboard's recipient lists are available from code, so a newsletter signup can go straight onto a list and a broadcast can go out without leaving your app: ```ts const { id: list_id } = await mail.create_list("Newsletter") await mail.add_recipients(list_id, [ { email: "ada@example.com", name: "Ada", data: { plan: "Pro" } }, { email: "linus@example.com" }, ]) await mail.broadcast(list_id, { subject: "Hey {name}", body: "

News for our {plan} users…

", scheduled_at: { hours: 1 }, // optional — omit to queue immediately }) ``` `{key}` placeholders are filled per recipient from their `data` (plus `{name}` and `{email}` from the recipient row), and every broadcast automatically carries the one-click unsubscribe headers Gmail and Yahoo require for bulk mail. The rest of the surface: `lists()`, `list(id)` (with recipients), `rename_list(id, name)`, `delete_list(id)`, and `remove_recipient(list_id, email)`. ## Suppressions Hard bounces, complaints and unsubscribes land on your account's suppression list, and sends to those addresses are dropped automatically. Inspect and manage it from code: ```ts const rows = await mail.suppressions() // [{ email, reason, detail?, created_at }] await mail.suppress("noisy@example.com") // add by hand await mail.unsuppress("fixed@example.com") // allow sending again ``` ## Notes - `scheduled_at` schedules a send up to 30 days ahead. Scheduled messages appear in the dashboard's Messages → Scheduled tab, where they can be rescheduled or canceled until they send. Scheduling counts against the free tier's daily cap on the day it's accepted; the monthly quota is charged when the message actually sends. - `scheduled_at` accepts an ISO 8601 datetime string. Include an explicit timezone offset or `Z` (e.g. `2026-07-10T14:30:00Z` or `2026-07-10T09:30:00-05:00`) — a bare local time without an offset is interpreted as UTC. It must be in the future and at most 30 days ahead. - In runtimes without ambient env vars (e.g. Cloudflare Workers), pass the token explicitly: `new Postboi({ token })`. - The token can be revoked and reissued any time from the dashboard's API keys panel. --- --- title: Manual setup name: Manual setup description: Skip the CLI and wire Postboi up by hand — install, set a provider, and send. category: Getting started --- The [Quick start](/quick-start) CLI does all of this for you. Prefer to wire it up by hand? It's three steps. Pick the provider and non-secret config in a committed [`postboi.config.ts`](/config); keep the secret(s) in your env file. Each provider reads its own credential env var — see [Providers](/providers) for the full list. ```ts // postboi.config.ts (commit this) import { config } from 'postboi' export default config({ provider: 'resend', default: { from: 'no-reply@example.com' } }) ``` ```bash # .env (gitignore this — secrets only) RESEND_API_KEY=re_xxxxxxxx ``` No provider import, no constructor — credentials come from the environment. ```typescript import { mail } from 'postboi' await mail({ to: 'contact@example.com', subject: 'Hi', body: '

Hello

' }) ```
## What lives where Postboi splits config along a single line: **secrets in the environment, everything else in the committed config file.** Both are read by `mail()` on every call. | Setting | Home | Notes | | ------------------------------------------------ | ----------------------------- | ---------------------------------------------------------- | | Provider (`resend`, `mailgun`, …) | `postboi.config.ts` | as `provider` | | Defaults (`from` / `to` / `cc` / `bcc` / `reply_to`) | `postboi.config.ts` | as `default: { … }` | | Non-secret provider options (Mailgun domain, SES region, SMTP host/port) | `postboi.config.ts` | as `options: { … }` | | Secrets (API keys, tokens, passwords) | env file / host secrets | e.g. `RESEND_API_KEY`, `MAILGUN_API_KEY` | Everything in the config file can still be **overridden by an env var** — handy for per-environment values without editing committed config (see below). The override names are `POSTBOI_PROVIDER`, `POSTBOI_FROM` / `POSTBOI_TO` / …, and each provider's own field vars (e.g. `MAILGUN_DOMAIN`). Env always wins. ## Per-environment config Two portable patterns, in order of preference. **1. Override the provider with an env var.** Keep a safe default in the committed file and flip it on the host. This works on every runtime — Node, Bun, and edge — with no magic: ```ts // postboi.config.ts — safe default for local dev export default config({ provider: 'mock' }) // sends nothing locally ``` ```bash # production host env POSTBOI_PROVIDER=resend RESEND_API_KEY=re_xxxxxxxx ``` Local dev (nothing set) uses `mock`; production (env set) uses Resend. `POSTBOI_PROVIDER` always wins over the file. **2. Use SvelteKit's `dev` flag in a hook.** The config file is imported directly by Node, so it can't read `$app/environment`. Configure from SvelteKit's [`init` hook](https://svelte.dev/docs/kit/hooks#Shared-hooks-init) instead, where the flag is available — it runs once at startup and works on every adapter, including Workers: ```ts // src/hooks.server.ts import type { ServerInit } from '@sveltejs/kit' import { dev } from '$app/environment' import { configure } from 'postboi' export const init: ServerInit = () => { configure({ provider: dev ? 'mock' : 'resend' }) } ``` > Avoid `process.env.NODE_ENV` in the config file — a self-hosted Node deploy that > forgets to set it falls into the wrong branch silently. Prefer the two patterns above. ## On SvelteKit A contact-form action is a one-liner. See [SvelteKit form actions](/sveltekit). ```typescript // +page.server.ts import { mail } from 'postboi/kit' export const actions = { default: mail } ``` > On runtimes without ambient env vars (e.g. Cloudflare Workers), construct the provider > directly — see [Using a provider directly](/providers#using-a-provider-directly). --- --- title: SvelteKit name: SvelteKit description: Wire a contact form to Postboi with a one-line SvelteKit action. category: Frameworks --- `postboi/kit` reads `FormData`, sends it, and returns `{ success: true }` — or `fail(400, { error })` on failure. A contact-form action is a single line. ```typescript // +page.server.ts import { mail } from 'postboi/kit' export const actions = { default: mail } ``` ## The form Point a `multipart/form-data` form at the action. Field names use the [`fieldset→field`](/formdata#grouped-fields) syntax to group related fields, and `_subject` (and friends) set the email's [special fields](/formdata#special-fields). Wire up a hidden `_reply_to` field bound to the sender's email. Then, when the notification lands in your inbox, hitting **Reply** goes straight back to the person who filled in the form — not to your no-reply `from` address. For most contact forms, a reply-to address is all you need: keep `from` as your default sending address and let reply-to carry the conversation. Only set a per-message `from` when you want the email itself to appear from a specific address. ```svelte ``` The submitted `FormData` becomes a tidy HTML table in the email body. See [FormData](/formdata) for how the table is built. The hidden `🍯` field is the built-in honeypot: bots fill it, humans can't see it, and a filled honeypot skips the send while the action still returns `{ success: true }` — the bot learns nothing. Want more? [Spam protection](/spam) covers the invisible captcha too — fully managed on the Postboi provider (one script tag, no keys), or bring your own Turnstile with a single env var. ## Or use the Captcha component `postboi/svelte` ships that honeypot — plus the [managed invisible captcha](/spam) on the Postboi provider — as a single prop-free component. Your form stays a native `
` (`use:enhance` and form libraries keep working); just drop it inside: ```svelte ``` The publishable key is baked in by `bunx postboi sync` — see [Spam protection](/spam#the-captcha-component). A full, runnable version of this form lives in [`examples/sveltekit-provider-postboi`](https://github.com/postboi-mail/postboi/tree/main/examples/sveltekit-provider-postboi). ## Using a configured instance Got a configured provider instance (or no ambient env vars)? Wrap it with `action()`. You can pass `status` for the failure code and `fields` for defaults merged into every send. ```typescript import Resend from 'postboi/resend' import { action } from 'postboi/kit' import { RESEND_API_KEY, EMAIL_FROM_ADDRESS } from '$env/static/private' const mail = new Resend({ api_key: RESEND_API_KEY, default: { from: EMAIL_FROM_ADDRESS } }) export const actions = { default: action(mail, { status: 422, fields: { to: 'team@example.com' } }) } ``` --- --- title: Next.js name: Next.js description: Send email from a Next.js Server Action with the top-level mail(). category: Frameworks --- Postboi is framework-agnostic — read the request's `FormData` in a Server Action and hand it straight to [`mail()`](/api). Postboi extracts the [special fields](/formdata#special-fields) and renders the rest into a tidy HTML table. ```tsx // app/actions.ts 'use server' import { mail } from 'postboi' export async function submit(_prev: unknown, body: FormData) { await mail({ body }) return { ok: true } } ``` Point a client-component form at it. Include hidden `_subject` and `_reply_to` fields, and mirror the visitor's email into `_reply_to` (with `useState`) so replying reaches the sender: ```tsx // app/page.tsx 'use client' import { useActionState, useState } from 'react' import { submit } from './actions' export default function Page() { const [email, setEmail] = useState('') const [state, action] = useActionState(submit, null) return (
setEmail(e.target.value)} required />
``` Field names use the [`fieldset→field`](/formdata#grouped-fields) syntax. API routes need SSR, so set `output: 'server'` with an adapter in `astro.config.mjs`. The provider and default recipient come from [`postboi.config.ts`](/config) — a `POSTBOI_TOKEN` routes to [the Postboi provider](/provider), or pick any [provider](/providers). ## Or use the Captcha component `postboi/astro` ships the [spam protection](/spam) — honeypot plus, on the Postboi provider, the managed invisible captcha — as a single prop-free component. Your form stays a native `
`; just drop it inside: ```astro --- import Captcha from 'postboi/astro' --- ``` The publishable key is baked in by `bunx postboi sync` — see [Spam protection](/spam#the-captcha-component). **Runnable example:** [`examples/astro-provider-postboi`](https://github.com/postboi-mail/postboi/tree/main/examples/astro-provider-postboi). --- --- title: Nuxt (Vue) name: Nuxt (Vue) description: Send email from a Nuxt server route with the top-level mail(). category: Frameworks --- Postboi is framework-agnostic — read the request's `FormData` in a Nitro server route and hand it straight to [`mail()`](/api). Postboi extracts the [special fields](/formdata#special-fields) and renders the rest into a tidy HTML table. ```typescript // server/api/contact.post.ts import { mail } from 'postboi' export default defineEventHandler(async (event) => { await mail({ body: readFormData(event) }) return sendRedirect(event, '/?sent=1', 303) }) ``` `defineEventHandler`, `readFormData`, and `sendRedirect` are Nuxt auto-imports. Point a `multipart/form-data` form at the route. Include hidden `_subject` and `_reply_to` fields, and bind `_reply_to` to the email (`v-model` + `:value`) so replying reaches the sender: ```vue ``` Field names use the [`fieldset→field`](/formdata#grouped-fields) syntax. The provider and default recipient come from [`postboi.config.ts`](/config) — a `POSTBOI_TOKEN` routes to [the Postboi provider](/provider), or pick any [provider](/providers). ## Or use the Captcha component `postboi/vue` ships the [spam protection](/spam) — honeypot plus, on the Postboi provider, the managed invisible captcha — as a single prop-free component. Your form stays a native `
`; just drop it inside: ```vue ``` The publishable key is baked in by `bunx postboi sync` — see [Spam protection](/spam#the-captcha-component). **Runnable example:** [`examples/nuxt-provider-postboi`](https://github.com/postboi-mail/postboi/tree/main/examples/nuxt-provider-postboi). --- --- title: Remix name: Remix description: Send email from a Remix route action with the top-level mail(). category: Frameworks --- Postboi is framework-agnostic — read the request's `FormData` in a route `action` and hand it straight to [`mail()`](/api). Postboi extracts the [special fields](/formdata#special-fields) and renders the rest into a tidy HTML table. ```tsx // app/routes/_index.tsx import { Form } from '@remix-run/react' import { useState } from 'react' import { mail } from 'postboi' export async function action({ request }: { request: Request }) { await mail({ body: request.formData() }) return { ok: true } } export default function Index() { // Mirror the email into the hidden _reply_to field so replies reach the sender. const [email, setEmail] = useState('') return ( setEmail(e.target.value)} required />
``` Field names use the [`fieldset→field`](/formdata#grouped-fields) syntax. The same handler works on any Web-standard runtime (Bun, Deno, Workers, Node with an adapter). The provider and default recipient come from [`postboi.config.ts`](/config) — a `POSTBOI_TOKEN` routes to [the Postboi provider](/provider), or pick any [provider](/providers). **Runnable example:** [`examples/hono-provider-postboi`](https://github.com/postboi-mail/postboi/tree/main/examples/hono-provider-postboi). --- --- title: Express name: Express description: Send email from an Express route — pass req.body straight to mail(). category: Frameworks --- Express's built-in `express.urlencoded()` parses a submitted form into a plain object on `req.body` — and [`mail()`](/api) accepts that object directly, no extra dependency needed. Postboi extracts the [special fields](/formdata#special-fields) and renders the rest into a tidy HTML table. ```typescript // src/server.js import express from 'express' import { mail } from 'postboi' const app = express() app.use(express.urlencoded({ extended: true })) // parses form fields onto req.body app.post('/contact', async ({ body }, res) => { await mail({ body }) res.redirect(303, '/?sent=1') }) app.listen(3000) ``` Point a form at `/contact`. Include hidden `_subject` and `_reply_to` fields, and mirror the email into `_reply_to` with a one-line `oninput` so replying reaches the sender. Field names use the [`fieldset→field`](/formdata#grouped-fields) syntax. A urlencoded form can't carry files. For attachments, switch to `enctype="multipart/form-data"` and parse it with a multipart parser like [`multer`](https://github.com/expressjs/multer) — `req.body` still flows into `mail({ body })` the same way. The provider and default recipient come from [`postboi.config.js`](/config) — a `POSTBOI_TOKEN` routes to [the Postboi provider](/provider), or pick any [provider](/providers). **Runnable example:** [`examples/express-provider-postboi`](https://github.com/postboi-mail/postboi/tree/main/examples/express-provider-postboi). --- --- title: Cloudflare Workers name: Cloudflare Workers description: Send email from a Worker — no filesystem, so construct Postboi with the token binding. category: Frameworks --- Workers have no filesystem (so no `postboi.config.ts` to auto-load) and no ambient `process.env`. Construct the provider explicitly with the token from the `env` binding, then read the request's `FormData` and hand it to `send()`. Postboi extracts the [special fields](/formdata#special-fields) and renders the rest into a tidy HTML table. ```typescript // src/index.ts import Postboi from 'postboi' interface Env { POSTBOI_TOKEN: string } export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url) if (request.method === 'POST' && url.pathname === '/contact') { const mail = new Postboi({ token: env.POSTBOI_TOKEN }) await mail.send({ body: request.formData(), to: 'team@example.com' }) return Response.redirect(new URL('/?sent=1', url).toString(), 303) } return new Response(/* the contact form */) }, } ``` Point a `multipart/form-data` form at `/contact`. Include hidden `_subject` and `_reply_to` fields, and mirror the email into `_reply_to` with a one-line `oninput` so replying reaches the sender. Field names use the [`fieldset→field`](/formdata#grouped-fields) syntax. Set the token as a secret (`wrangler secret put POSTBOI_TOKEN`, or `.dev.vars` for local dev), and turn on `nodejs_compat` in `wrangler.jsonc`. Swap the provider by constructing a different one — see [Providers](/providers). **Runnable example:** [`examples/cloudflare-workers-provider-postboi`](https://github.com/postboi-mail/postboi/tree/main/examples/cloudflare-workers-provider-postboi). --- --- title: FormData name: FormData description: How Postboi turns submitted FormData into tidy, sectioned HTML email tables. category: Guides --- Pass a `FormData` object as the `body` and Postboi converts it into a tidy HTML table. This is what powers the [SvelteKit form actions](/sveltekit) — 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: ```typescript 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: ```typescript 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: - `_to`, `_from`, `_subject`, `_reply_to` - `_cc`, `_bcc` — comma-separated or repeated Values can be base64-encoded; they'll be decoded automatically. Set `_reply_to` to the submitter's email so replies go back to them instead of your `from` address — see the [SvelteKit form](/sveltekit#the-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 "` 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 `🍯` honeypot and Turnstile's `cf-turnstile-response` token — they drive the built-in [spam protection](/spam) and never appear in your emails. ## Grouped fields Use the `fieldset→field` syntax to group related fields into sections: ```html ``` This produces sectioned tables in the email body — one section per fieldset (`contact`, `order`), with a row per field. ## Attachments Attachments work from file inputs (`details→files`) or via `attachments: File | File[]` on [`mail()`](/api#sendoptions). ```html ``` ## Customising the labels The [`formatter`](/api#sendoptions) option on `mail()` controls how fieldset and field labels are rendered — pass `null` or `false` to a part to leave those labels untouched. --- --- title: Hosted forms name: Hosted forms description: Turn any HTML form into email — point it at a hosted endpoint, no backend required. category: Guides --- Everything else in these docs assumes you have a server for the library to run on. Hosted forms are for when you don't: static sites, landing pages, docs, portfolios — anything that's just HTML. Point a plain `
` at a Postboi endpoint and submissions arrive in your inbox as the same tidy email the library renders. No backend, no JavaScript, no `npm install`. ## Create a form In the [dashboard](https://postboi.email/dashboard/forms), create a form: give it a name, pick which team member's inbox receives submissions, and you get an endpoint URL like `https://postboi.email/f/form_k3v9x2m1p8q4`. Submissions can only be delivered to a **team member's sign-in email** — an address verified at sign-in (magic link or SSO), so a public endpoint can never be pointed at someone else's inbox. If that member leaves the team, the form stops delivering until you point it at a current member. ## The HTML ```html
``` That's the whole integration. On submit, the visitor lands on a hosted thank-you page — or set a **redirect URL** (https) on the form in the dashboard to send them back to your own site. The email arrives with fields rendered as a table, and an `email` field is automatically used as the **Reply-To** — hitting Reply in your inbox goes straight back to the sender. File inputs (`` with `enctype="multipart/form-data"`) become attachments, up to 5 MB per submission. ## Special fields The endpoint speaks the library's [FormData dialect](/formdata): | Field | Effect | | --------------- | ------------------------------------------------------------------- | | `_subject` | Sets the email's subject line | | `_reply_to` | Sets Reply-To explicitly (wins over an `email` field) | | `🍯` | The honeypot — include it hidden; filled submissions are dropped | | `fieldset→field`| Groups fields into titled sections in the email | ```html ``` `_to`, `_from`, `_cc` and `_bcc` are ignored — the recipient is fixed by the form's configuration, so a bot can't repoint the mail. ## Spam protection Two layers, same as the library's [spam protection](/spam): - **Honeypot** — include the hidden `🍯` field and bot submissions are silently dropped. The bot even receives a success response, so it learns nothing. - **Managed captcha** — enable *Require captcha* on the form and add the captcha `
``` ```tsx // React — Next.js, Remix, … import { Captcha } from 'postboi/react'
``` ```vue ``` ```astro --- import Captcha from 'postboi/astro' ---
``` **Where does the key come from?** `bunx postboi init` commits your account's publishable key to [`postboi.config.ts`](/config) (`captcha: { key: "pk_…" }` — it's publishable, so committing is safe), and `bunx postboi sync` — already in your `prepare` script — bakes it from there into the installed package on every install. Tokenless environments (CI) keep the captcha because the committed config is the source of truth; a `POSTBOI_TOKEN` just lets `sync` refresh the key (and the generated `from` types) from the Postboi provider. Upgrading from an earlier init? Run `bunx postboi sync` once with your token and it writes the key into your config for you. If the component warns that no key is baked, re-run `bunx postboi sync` (and restart the dev server), or pass one explicitly with `pk="pk_…"` from your [dashboard](https://postboi.email/dashboard/keys). Props, all optional: `pk` (key override), `origin` (loader origin), and `honeypot` (`false` to render no honeypot field). Without any key the component is honeypot-only and logs a console warning. No component? The rest of this page shows the plain-HTML pieces it renders. ## The honeypot — zero config Add a visually hidden field named `🍯` to your form: ```html ``` That's it. Humans never see the field, so it arrives empty. Bots auto-fill every input they find — and any submission with a filled `🍯` is treated as spam: - **No email is sent.** - The field never appears in your emails (it's stripped even when empty). - With `postboi/kit`, the action still returns `{ success: true }` — the bot sees a normal successful submission and learns nothing. > Prefer a class over inline styles if you like, but avoid `display: none` — smarter bots > skip fields they can detect as hidden that way. Off-screen positioning is more effective. Calling `mail()` directly instead of using `postboi/kit`? A tripped honeypot throws a `SpamError` (`code: "spam"`), so you decide what to do — usually pretend success: ```typescript import { mail, is_spam } from 'postboi' try { await mail({ to: 'contact@example.com', body: request.formData() }) } catch (error) { if (!is_spam(error)) throw error // spam: fall through and pretend it worked } ``` Spam skips are intentional, so they never fire the [`on.error` hook](/hooks) — your Sentry stays quiet. ## Managed captcha — the Postboi provider, zero keys On [the Postboi provider](/provider), invisible captcha is fully managed: no Cloudflare account, no keys to create, no env vars. Grab the snippet from your [dashboard](https://postboi.email/dashboard/keys), drop it on the page, and mark the form: ```html
``` That's the whole setup. Behind the scenes, Postboi mints a [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) widget for your account the first time the script runs (registering each hostname it sees, localhost included), renders an invisible challenge into every `form[data-captcha]`, and verifies the token server-side when the send arrives. The `data-key` is publishable — it's safe in your HTML; the widget secret never leaves Postboi. Once your account has a widget, form sends are **enforced**: a form submission arriving without a valid token — a bot POSTing straight to your action — is rejected with `captcha_failed`, which `postboi/kit` surfaces as `fail(400, { error })`. Sends you make from code (a string `body`) are never captcha-checked, so transactional email is unaffected. Got a form that shouldn't be verified? Opt it out per send: ```typescript await mail({ body, captcha: { turnstile: false } }) ``` ## Bring your own Turnstile — one env var Using your own provider (Resend, Mailgun, …), or want your own Cloudflare account? Turnstile is still baked in — two steps: **1. Add the widget to your form** ([get your keys here](https://dash.cloudflare.com/?to=/:account/turnstile)): ```html
``` **2. Set the secret key in your environment:** ```bash # .env TURNSTILE_SECRET_KEY=0x4AAAAAAA... ``` Done. The widget injects a `cf-turnstile-response` token into the form, and Postboi verifies it with Cloudflare before every FormData send. The token is stripped from the email, and invalid or missing tokens are rejected with a `PostboiError` (`code: "captcha_failed"`). A configured secret always wins — set one on the Postboi provider and verification happens locally instead of via managed captcha. > **Setting `TURNSTILE_SECRET_KEY` enforces Turnstile on every FormData send.** That's > deliberate — if a bot could skip the widget and POST directly, the captcha would be > decorative. Opt individual sends out with `captcha: { turnstile: false }`. On runtimes without ambient env vars (Cloudflare Workers), pass the secret explicitly: ```typescript await mail.send({ body, captcha: { turnstile: { secret_key: env.TURNSTILE_SECRET_KEY } } }) ``` Testing? Cloudflare provides [dummy keys](https://developers.cloudflare.com/turnstile/troubleshooting/testing/) — the `1x…` secret always passes, the `2x…` secret always fails. ## Configuration Both layers work with zero config, but everything is adjustable — globally in `postboi.config.ts`, per provider instance, or per send (most specific wins): ```typescript // postboi.config.ts import { config } from 'postboi' export default config({ captcha: { key: 'pk_…', // your publishable key, committed here by `postboi init` honeypot: 'nickname', // rename the honeypot field (default: "🍯") turnstile: false // or { secret_key }, or true to require it } }) ``` | Option | Values | Default | | ----------- | ------------------------------------ | -------------------------------------------------------------------------- | | `key` | your publishable `pk_…` key | written by `postboi init`; feeds the `` bake | | `honeypot` | field name, or `false` to disable | `"🍯"` | | `turnstile` | `{ secret_key }`, `true`, or `false` | auto — managed on the Postboi provider, local when `TURNSTILE_SECRET_KEY` is set | ## Error codes | Code | Meaning | | ----------------------- | ------------------------------------------------------------------ | | `spam` | The honeypot was filled (`SpamError` — an intentional skip) | | `captcha_failed` | Turnstile token missing, invalid, expired, or unverifiable | | `captcha_misconfigured` | A token arrived but no secret key is configured | Verification **fails closed**: if Cloudflare can't be reached, the send is rejected rather than waved through. --- --- title: Email templates name: Email templates description: Pair Postboi with Maizzle for Tailwind-styled, email-safe HTML — or bring React Email, MJML, or anything else that renders to a string. category: Guides --- Postboi deliberately doesn't ship a templating language. `body` is just HTML — a string, or a **promise resolving to one** — so any tool that renders HTML works, and swapping templating stacks never touches your sending code. For anything fancier than [FormData tables](/formdata), our favourite pairing is **[Maizzle](https://maizzle.com)** — enough so that Postboi ships an optional [`postboi/maizzle`](#send-it) helper for it. Think of it as Postboi's answer to Resend + `react-email` — the same "design in components, send the result" workflow, except it works across all [supported providers](/providers), not just one. > We're not affiliated with Maizzle in any way. It's just neat. ## Why Maizzle - **Tailwind CSS for email** — write utilities, get inlined, email-safe CSS out. - **Email-grade output** — table layouts, Outlook fallbacks, and battle-tested transformers, so you're not hand-rolling `` soup. - **Components included** — `Button`, `Container`, `Heading`, and friends, plus your own. - **A real dev loop** — `maizzle serve` gives you live-reloading previews in the browser. Since Maizzle 6, templates are authored as Vue single-file components. That's just the authoring format — rendering happens on the server and the output is plain HTML, so it slots into any app, SvelteKit included. No Vue ships anywhere near your frontend. ## Set up Add Maizzle to your app: Or scaffold a standalone project with `npx maizzle new` — handy if emails live in their own repo, or you want the [Maizzle CLI](https://maizzle.com/docs/installation) workflow with live previews. ## Write a template Templates live wherever you like — an `emails/` directory keeps things tidy. Declare the dynamic bits with `defineProps`: ```vue ``` ## Send it Postboi ships a helper for exactly this: `postboi/maizzle` wraps Maizzle's [`render()`](https://maizzle.com/docs/api/utilities) and resolves to the transformed, inlined HTML. Because `body` accepts a promise, it drops straight in — Resend-style ergonomics, no intermediate `await`: ```typescript import { mail } from 'postboi' import maizzle from 'postboi/maizzle' await mail({ to: 'ava@example.com', subject: 'Welcome to Acme', body: maizzle('./emails/welcome.vue', { name: 'Ava' }) }) ``` `@maizzle/framework` is an **optional peer dependency** — the helper needs it installed (see [Set up](#set-up)), but nothing else in Postboi does. The signature is `maizzle(template, props?, config?)`: - `template` — a path to a template file, or a raw SFC source string. - `props` — passed to the template's root component, mapping 1:1 to its `defineProps`. Type them with a generic: `maizzle('./emails/welcome.vue', { name: 'Ava' })`. - `config` — Maizzle config overrides forwarded to `render()`, e.g. `{ minify: true }`. Prefer to call Maizzle yourself? The helper is a thin convenience — `render()` works just as well: ```typescript import { mail } from 'postboi' import { render } from '@maizzle/framework' const { html } = await render('./emails/welcome.vue', { props: { name: 'Ava' } }) await mail({ to: 'ava@example.com', subject: 'Welcome to Acme', body: html }) ``` Pair it with [`auto_text`](/api#common-constructor-options) in your [global config](/config) to derive a plain-text alternative from the rendered HTML automatically. ## Edge runtimes Maizzle's `render()` — and therefore `postboi/maizzle` — spins up a lightweight Vite-based renderer under the hood, so it needs Node or Bun — fine in SvelteKit, Next.js, or Express server routes, but not on edge runtimes like [Cloudflare Workers](/cloudflare-workers). There, pre-build instead: put literal tokens like `%name%` in your template copy, compile to static HTML at deploy time with `maizzle build` (or the [`build()`](https://maizzle.com/docs/api/utilities) API), then import the HTML as a string and `.replace()` the tokens at send time. ## Prefer React Email or MJML? Same pattern, different renderer — anything that produces an HTML string (or a promise of one) drops straight into `body`. React Email's [`render()`](https://react.email/docs/utilities/render) resolves to an HTML string, so you can pass it through unawaited too; with MJML it's `mjml2html(template).html`. --- --- title: Providers name: Providers description: Every supported email provider, its import path, and constructor options. category: Guides --- Each provider is its own entry point, so you only bundle the one you import. Every provider exposes the same `send()` and `is_error()` methods — swap providers without changing your calling code. The **environment variables** are what [`postboi init`](/quick-start) writes and the zero-config [`mail()`](/api) reads; the **constructor options** are the same values passed explicitly when you [construct a provider directly](#using-a-provider-directly). Non-secret bits (a Mailgun domain, an SES region) can instead live in [`postboi.config.ts`](/config) — keep the secrets in the environment. | Provider | Import | Environment variables | Constructor options | | -------------- | -------------------- | ------------------------------------------------------- | ------------------------------------ | | Amazon SES | `postboi/ses` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` | `access_key_id`, `secret_access_key`, `region`, `session_token?` | | Brevo | `postboi/brevo` | `BREVO_API_KEY` | `api_key` | | Cloudflare | `postboi/cloudflare` | `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` | `api_key`, `account_id` | | Elastic Email | `postboi/elasticemail` | `ELASTICEMAIL_API_KEY` | `api_key` | | MailerSend | `postboi/mailersend` | `MAILERSEND_API_KEY` | `api_key` | | Mailgun | `postboi/mailgun` | `MAILGUN_API_KEY`, `MAILGUN_DOMAIN` | `api_key`, `domain`, `region?` | | Mailjet | `postboi/mailjet` | `MJ_APIKEY_PUBLIC`, `MJ_APIKEY_PRIVATE` | `api_key`, `api_secret` | | MailPace | `postboi/mailpace` | `MAILPACE_SERVER_TOKEN` | `api_key` | | Mailtrap | `postboi/mailtrap` | `MAILTRAP_TOKEN` | `api_key`, `sandbox?`, `inbox_id?` | | Mandrill | `postboi/mandrill` | `MANDRILL_API_KEY` | `api_key` | | Microsoft 365 | `postboi/microsoft365` | `MS365_TENANT_ID`, `MS365_CLIENT_ID`, `MS365_CLIENT_SECRET` | `tenant_id`, `client_id`, `client_secret` | | Plunk | `postboi/plunk` | `PLUNK_API_KEY` | `api_key` | | Postmark | `postboi/postmark` | `POSTMARK_SERVER_TOKEN` | `api_key`, `message_stream?` | | Resend | `postboi/resend` | `RESEND_API_KEY` | `api_key` | | Scaleway | `postboi/scaleway` | `SCALEWAY_SECRET_KEY`, `SCALEWAY_PROJECT_ID`, `SCALEWAY_REGION` | `secret_key`, `project_id`, `region` | | SendGrid | `postboi/sendgrid` | `SENDGRID_API_KEY` | `api_key`, `region?` | | SMTP (any) | `postboi/smtp` | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE` | `host`, `port?`, `user?`, `pass?`, `secure?` | | SparkPost | `postboi/sparkpost` | `SPARKPOST_API_KEY` | `api_key`, `region?` | | ZeptoMail | `postboi/zepto` | `ZEPTO_TOKEN` | `api_key` | | Mock (testing) | `postboi/mock` | _none_ | _none_ | Want another provider? Quit being a baby and [open a PR](https://github.com/postboi-mail/postboi). ## Using a provider directly Useful when you want an explicit instance, or you're on a runtime without ambient env vars (e.g. Cloudflare Workers): ```typescript import Resend from 'postboi/resend' const mail = new Resend({ api_key: process.env.RESEND_API_KEY!, default: { from: 'no-reply@example.com' } }) await mail.send({ to: 'someone@example.com', subject: 'hello', body: '

hello world

' }) ``` Every provider also accepts `default`, `timeout`, `retries`, `retry_delay`, `auto_text`, and `hooks` on top of its own credentials — see [Common constructor options](/api#common-constructor-options). ## Mock provider `postboi/mock` records messages in-memory instead of sending them — same normalisation as a real provider. Perfect for tests. ```typescript import Mock from 'postboi/mock' const mail = new Mock({ default: { from: 'no-reply@example.com' } }) await mail.send({ to: 'contact@example.com', subject: 'Hi', body: '

Hello

' }) expect(mail.sent).toHaveLength(1) ``` ## Custom headers & tags `headers` and `tags` on [`send()`](/api#sendoptions) are forwarded to each provider's native concept, and quietly ignored where unsupported: - **headers** → Resend, Postmark, SendGrid, Mailgun (`h:`), Brevo, SparkPost, Mandrill, Plunk, Mailtrap, Scaleway, Cloudflare, SES, Mailjet, Elastic Email. - **tags** → SendGrid (categories), Mailgun (`o:tag`), Brevo, MailerSend, Mandrill, MailPace, SES (`EmailTags`), Resend (`{name,value}` pairs). Postmark and Mailtrap use the **first** tag only. The same forwarded-where-supported convention applies to [`scheduled_at`](/scheduling), [`tracking`](/tracking) and [`unsubscribe_url`](/tracking#one-click-unsubscribe) — and most providers' delivery events can be received and normalized too, see [Webhooks](/webhooks). --- --- title: Global config name: Global config description: Set hooks, defaults, and behaviour once with postboi.config.ts — applied to every send. category: Guides --- Set your provider, defaults, and behaviour once and have them apply to every send. A **`postboi.config.ts`** at your project root is the committed home for everything except secrets — `bunx postboi init` writes it for you. It's also the only place [hooks](/hooks) can live, since they're functions. ```typescript // postboi.config.ts import { config } from 'postboi' export default config({ provider: 'resend', default: { from: 'no-reply@example.com' }, options: { domain: 'mg.example.com' }, // non-secret provider options retries: 2, hooks: { on: { error: ({ error }) => Sentry.captureException(error) } } }) ``` It auto-loads on the first `mail()` (Node/Bun). | Field | What it sets | | ---------- | --------------------------------------------------------------------------------- | | `provider` | which provider `mail()` uses (`resend`, `mailgun`, …) | | `default` | `from` / `to` / `cc` / `bcc` / `reply_to` applied when a send omits them | | `options` | non-secret provider constructor options (Mailgun domain, SES region, SMTP host…) | | `hooks` | lifecycle [hooks](/hooks) run around every send | | `captcha` | [spam protection](/spam): the publishable `key` for ``, honeypot name, Turnstile | | _behaviour_ | `retries`, `retry_delay`, `timeout`, `auto_text` | Keep **secrets out of this file** — API keys and tokens belong in the environment. To vary the provider per environment, see [Per-environment config](/manual-setup#per-environment-config). ## Precedence Lowest to highest — later sources win: 1. `postboi.config.ts` 2. environment variables — `POSTBOI_PROVIDER`, `POSTBOI_*` defaults, and each provider's own field vars (e.g. `MAILGUN_DOMAIN`) 3. options passed explicitly to `mail()` or a provider constructor ## Edge runtimes Edge runtimes (Cloudflare Workers, etc.) have no filesystem, so the config file can't be auto-loaded. Register config at startup instead with `configure({ ... })`. ```typescript import { configure } from 'postboi' configure({ default: { from: 'no-reply@example.com' }, retries: 2 }) ``` --- --- title: Hooks name: Hooks description: Run awaitable callbacks around every send — observe, rewrite, cancel, and report. category: Guides --- Pass `hooks` to any provider to run awaitable callbacks around every send. `before.send` can observe, rewrite, or cancel a message; the rest are best-effort observers — an error they throw is swallowed, so logging and telemetry can never break a send. In a bulk [`mail(array)`](/bulk), hooks run once per message. > Setting the same hooks on every instance? Define them **once** in > [`postboi.config.ts`](/config) and they apply everywhere — including the > zero-config `mail()`. ```typescript const mail = new Resend({ api_key: RESEND_API_KEY, default: { from: 'no-reply@example.com' }, hooks: { // observe, mutate, or throw to cancel — runs before the request before: { send({ message }) { if (process.env.NODE_ENV !== 'production') return { ...message, to: 'qa@example.com' } } }, // success — analytics / audit after: { send({ provider, message, duration_ms }) { track('email.sent', { provider, to: message.to, duration_ms }) } }, // any failure — report it on: { error({ error, message }) { Sentry.captureException(error, { tags: { provider: error.provider }, extra: { to: message?.to } }) }, // each retry — observe provider flakiness retry({ provider, attempt, status }) { console.warn(`${provider} retry ${attempt} (${status})`) } } } }) ``` ## The hooks | Hook | When it runs | Can it change the send? | | ------------- | ------------------- | ------------------------------------------ | | `before.send` | before the request | yes — return a new message, or throw | | `after.send` | after a success | no — observer only | | `on.error` | on any failure | no — observer only | | `on.retry` | before each retry | no — observer only | ## Cancelling a send Cancel a send from `before.send` by throwing `SkipSendError` (e.g. a suppressed or unsubscribed recipient). It's a `PostboiError` with `code: "skipped"`, and it does **not** trigger `on.error`: ```typescript import Resend from 'postboi/resend' import { SkipSendError } from 'postboi' const mail = new Resend({ api_key: RESEND_API_KEY, hooks: { before: { async send({ message }) { if (await isSuppressed(message.to)) throw new SkipSendError(`suppressed: ${message.to}`) } } } }) ``` --- --- title: Errors & retries name: Errors & retries description: One normalised PostboiError across every provider, plus opt-in retries. category: Guides --- ## Error handling Every provider throws the **same** normalised `PostboiError` on failure — HTTP errors, provider error envelopes, timeouts, and network failures all funnel through it. Error handling is identical no matter which provider you use, and the original provider payload is kept on `.raw`. ```typescript try { await mail.send({ to: 'bad@email', body: 'test' }) } catch (error) { if (mail.is_error(error)) { // error is a PostboiError console.error(error.provider) // e.g. "resend" console.error(error.status) // HTTP status, when applicable console.error(error.code) // provider-specific code, when available console.error(error.message) // normalised message console.error(error.raw) // the original provider payload } else { console.error(error) } } ``` ### Common codes Beyond provider-specific codes passed through on `.code`, postboi itself uses a few: | Code | Meaning | | ---- | ------- | | `skipped` / `spam` | The send was intentionally cancelled — a `before.send` hook or the [honeypot](/spam). | | `no_provider` / `unknown_provider` / `missing_env` | Zero-config resolution failed — see [Quick start](/quick-start). | | `cancel_not_supported` | [`cancel()`](/scheduling#cancelling-a-scheduled-send) on a provider without a cancellation API. | | `webhooks_not_supported` | [`receive()`](/webhooks) for a provider that emits no delivery events. | | `invalid_payload` | A webhook body that doesn't parse. | Webhook verification failures throw a `WebhookVerificationError` (a `PostboiError` subclass) with codes `invalid_signature`, `missing_secret`, `stale_timestamp` or `unsupported_runtime` — return a 401 when you catch one. ## Retries Every provider accepts `retries`, `retry_delay`, and `timeout`: ```typescript const mail = new Resend({ api_key: RESEND_API_KEY, timeout: 30000, // per-request timeout in ms (default 30000) retries: 2, // retries on 429/5xx and network errors (default 0) retry_delay: 500 // base backoff in ms, doubles each attempt (default 500) }) ``` Retries fire on `429`, `5xx`, and network errors. The delay doubles each attempt (exponential backoff). > **Retries are off by default on purpose.** Retrying a send that already reached the > provider can deliver a duplicate email, so enable `retries` only alongside an > `idempotency_key` (where the provider supports it). --- --- title: Bulk sending name: Bulk sending description: Pass an array to mail() for bounded-concurrency bulk sends that never throw. category: Guides --- Pass an array to `mail()` and each message becomes its own request, sent with bounded concurrency. The call **never throws** — you get one result per message. ```typescript const results = await mail([ { to: 'a@example.com', body: '…' }, { to: 'b@example.com', body: '…' } ]) ``` ## Concurrency and results On a provider instance you can tune concurrency (default `5`). Each result tells you whether that message succeeded: ```typescript const results = await mail.send(messages, { concurrency: 10 }) const failed = results.filter((r) => !r.ok) for (const r of failed) console.error(r.index, r.error.message) ``` Because the call never throws, you handle failures by inspecting `results` rather than with `try`/`catch`. Each failed result carries the same normalised [`PostboiError`](/errors) on `.error`. [Hooks](/hooks) run once per message, so `before.send`, `after.send`, and `on.error` fire for each item in the array. ## Personalized batches When every recipient gets the *same* message with a few values swapped in, pass one `to` array plus per-recipient `data` instead of building the array yourself. `{name}` placeholders in `subject` and `body` are filled from each recipient's variables: ```typescript const results = await mail({ to: ['a@example.com', 'b@example.com'], subject: 'Hey {name}', body: 'A message just for you, {name}.', data: { 'a@example.com': { name: 'Ada' }, 'b@example.com': { name: 'Linus' } } }) ``` When `to` is a literal array, the `data` keys are **type-checked against it** — a typo'd or missing-from-`to` address is a compile error. (Recipients written as `{ address }` objects or a non-literal `string[]` can't be inferred, so their keys relax to any string.) Providers with a native batch API send the whole thing as **one request** — [the Postboi provider](/provider) (up to 100 recipients per batch), Resend, Postmark and MailerSend pack the rendered messages; Mailgun, SparkPost, Mandrill, SendGrid and Brevo map your `{name}` tags onto their server-side merge fields. Every other provider falls back to one request per recipient. Either way you get one [`BatchResult`](/errors) per recipient, in order. > `before.send` runs per recipient (so suppression and staging redirects still work). On the > native-batch path, `after.send` / `on.error` fire per recipient only when a provider has no > batch endpoint and falls back to fan-out. Each message can also carry its own [`scheduled_at`](/scheduling) to send it later. --- --- title: Scheduling name: Scheduling description: Send later with scheduled_at — a relative duration, a Date, or an ISO 8601 string. category: Guides --- Pass `scheduled_at` to send a message at a future time instead of right away. The easiest way is a **relative duration** — an object of units added to the send time. Fields combine, so `{ days: 1, hours: 5 }` is 29 hours from now: ```typescript import { mail } from 'postboi' await mail({ to: 'contact@example.com', subject: 'Your weekly digest', body: '

Here is what you missed.

', scheduled_at: { days: 1, hours: 5 } }) ``` Every unit is optional: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, `years`. Months and years are calendar-aware (a real "+1 month"). You can also pass an absolute `Date`, or an ISO 8601 string (include an explicit timezone offset or `Z`, otherwise it's read as UTC): ```typescript await mail({ to: 'contact@example.com', subject: 'Launch day', body: '

We are live.

', scheduled_at: '2026-07-10T14:30:00Z' }) ``` An invalid or unparseable `scheduled_at` throws before anything is sent. ## Provider support Scheduling is done by the provider, so it only works where the provider offers it: | Provider | How it's sent | | ------------- | -------------------------------------------------------------------- | | the Postboi provider | Up to 30 days ahead; reschedule with `reschedule(id, when)` or cancel, from code or the dashboard — see [the Postboi provider](/provider). | | Resend | Passed through as `scheduled_at`. | | Brevo | Passed through as `scheduledAt`. | | Mailgun | Sent as `o:deliverytime`. | | SendGrid | Sent as `send_at`. | Every other provider **ignores** `scheduled_at` and sends immediately — so don't lean on it with a provider that doesn't support it. Scheduling composes with [bulk sending](/bulk): give each message in the array its own `scheduled_at`. ## Cancelling a scheduled send Keep the id `mail()` returned and pass it to `cancel()`: ```typescript import { mail, cancel } from 'postboi' const { id } = await mail({ to: 'contact@example.com', subject: 'Reminder', body: '

See you tomorrow.

', scheduled_at: { days: 1 } }) // Plans changed: await cancel(id) ``` `cancel()` resolves the same provider `mail()` uses; on a provider instance it's `provider.cancel(id)`. | Provider | How it cancels | | ------------- | ----------------------------------------------- | | the Postboi provider | `POST /v1/messages/:id/cancel` — also available from the dashboard. | | Resend | `POST /emails/:id/cancel`. | | Brevo | `DELETE /v3/smtp/email/:id`. | Providers **without** a cancellation API reject with a `PostboiError` code `cancel_not_supported` — loudly, so a send you believe is cancelled never quietly goes out. (The `postboi/mock` provider records cancellations on `.canceled` for tests.) ## Rescheduling On the Postboi provider a scheduled message can also be **moved** instead of cancelled, any time until it sends. `reschedule` accepts the same formats as `scheduled_at`: ```typescript import Postboi from 'postboi' const mail = new Postboi() await mail.reschedule(id, { days: 2 }) // or a Date / ISO 8601 string ``` --- --- title: Tracking & unsubscribe name: Tracking & unsubscribe description: Per-send open/click tracking flags, and one-click unsubscribe headers Gmail and Yahoo require. category: Guides --- ## Open & click tracking Turn tracking on or off **per send** with `tracking` — no dashboard digging, and the same option whatever the provider: ```typescript import { mail } from 'postboi' await mail({ to: 'contact@example.com', subject: 'Your receipt', body: '

Thanks for your order.

', tracking: { opens: true, clicks: false } }) ``` Only the flags you set are forwarded, so the provider's own defaults keep applying to anything you leave out. The resulting open/click events arrive via [webhooks](/webhooks) — including *which client and device* opened the mail. ### Provider support | Provider | Mapped to | | -------- | --------- | | the Postboi provider | Per-send tracking override | | Postmark | `TrackOpens` / `TrackLinks` | | SendGrid | `tracking_settings` | | Mailgun | `o:tracking-opens` / `o:tracking-clicks` | | Mandrill | `track_opens` / `track_clicks` | | SparkPost | `options.open_tracking` / `options.click_tracking` | | Mailjet | `TrackOpens` / `TrackClicks` | | Elastic Email | `Options.TrackOpens` / `Options.TrackClicks` | | ZeptoMail | `track_opens` / `track_clicks` | Providers whose tracking is configured at the **domain level** (Resend) or account level ignore the per-send flags — configure tracking in their dashboard instead. Every other provider without a tracking concept ignores the flags entirely, matching how `scheduled_at` behaves. ## One-click unsubscribe Bulk senders to Gmail and Yahoo are required to support **RFC 8058 one-click unsubscribe**. One option sets both headers on any headers-capable provider: ```typescript await mail({ to: 'subscriber@example.com', subject: 'March newsletter', body: newsletter_html, unsubscribe_url: 'https://example.com/unsubscribe?u=123&list=news' }) ``` That adds: ``` List-Unsubscribe: List-Unsubscribe-Post: List-Unsubscribe=One-Click ``` Your endpoint must accept the one-click `POST` (no confirmation page — mail clients POST it directly). If you set your own `List-Unsubscribe` header explicitly in `headers`, it wins over the generated one. ## Plain-text bodies are automatic Every HTML send also ships a derived plain-text alternative by default (`auto_text`) — better spam scores, and text-only clients get something readable. Provide `text` yourself to control it, or opt out globally: ```typescript // postboi.config.ts export default config({ auto_text: false }) ``` --- --- title: Webhooks name: Webhooks description: Receive delivery events — delivered, opened, clicked, bounced — normalized across every provider, with signatures verified. category: Guides --- Sending is half the story. Providers also *report back* — delivery confirmations, opens, clicks, bounces, spam complaints — via webhooks. `postboi/webhooks` receives those the same way postboi sends: **one normalized shape, any provider**. Point your provider's webhook at an endpoint, hand postboi the request, and get typed events out — signature verification included. ```typescript import { receive } from 'postboi/webhooks' export async function POST(request: Request) { const events = await receive(request) for (const event of events) { if (event.type === 'opened') { console.log(`${event.email} opened in ${event.client?.name} on ${event.client?.device}`) } if (event.type === 'bounced' && event.bounce?.category === 'hard') { await suppress(event.email) } } return Response.json({ received: events.length }) } ``` Like `mail()`, `receive()` is zero-config: the provider comes from `POSTBOI_PROVIDER` / `postboi.config.ts` / a `POSTBOI_TOKEN`, and the signing secret from the provider's `_WEBHOOK_SECRET` env var. Both can be passed explicitly: ```typescript const events = await receive(request, { provider: 'resend', secret: RESEND_WEBHOOK_SECRET }) ``` ## SvelteKit On SvelteKit, use the ready-made handler from `postboi/kit` — it verifies, normalizes, calls your handler once per event, and answers the provider correctly (200 on success, 401 on a bad signature, 400 on a bad payload, 500 when your handler throws so the provider retries): ```typescript // src/routes/webhooks/email/+server.ts import { webhook } from 'postboi/kit' export const POST = webhook(async (event) => { if (event.type === 'opened') { console.log(`${event.email} opened in ${event.client?.name}`) } }) ``` ## The event shape Every provider's payload normalizes to a `WebhookEvent`: ```typescript interface WebhookEvent { type: 'sent' | 'delivered' | 'delayed' | 'bounced' | 'complained' | 'opened' | 'clicked' | 'unsubscribed' | 'failed' provider: string // "resend", "postmark", … message_id?: string // matches the id send() returned, where the provider allows email?: string // the recipient this event is about timestamp?: Date subject?: string tags?: Array url?: string // clicked events — the link bounce?: { category: 'hard' | 'soft' | 'suppressed' | 'unknown'; detail?: string } client?: EmailClient // opened/clicked — see below ip?: string raw: unknown // the untouched provider payload } ``` ## Who opened it, and on what On opens and clicks, most providers report the recipient's user-agent. postboi parses it **locally** (a pure function — no lookup service, nothing leaves your server) into: ```typescript interface EmailClient { name?: string // "Apple Mail", "Gmail", "Outlook", "Chrome", … os?: string // "iOS", "macOS", "Windows", … device: 'desktop' | 'mobile' | 'tablet' | 'unknown' user_agent: string } ``` So `event.client` answers "opened in Apple Mail on an iPhone" out of the box. Two honest caveats: proxied opens (Gmail, Yahoo fetch the pixel on the recipient's behalf) identify the mailbox provider but hide the device, and Apple Mail Privacy Protection means open events generally are an approximation, whatever the provider. ## Verification Verification is **fail-closed**: if no secret is configured, `receive()` throws rather than silently accepting unauthenticated requests. Every comparison is timing-safe, and schemes with timestamps get replay protection. Providers fall into three camps: | Provider | Scheme | Secret to set | | -------- | ------ | ------------- | | the Postboi provider | Signed (standard-webhooks HMAC) | `POSTBOI_WEBHOOK_SECRET` — the `whsec_…` from the dashboard | | Resend | Signed (Svix HMAC) | `RESEND_WEBHOOK_SECRET` — the `whsec_…` from the dashboard | | SendGrid | Signed (ECDSA P-256) | `SENDGRID_WEBHOOK_SECRET` — the Signed Event Webhook *public key* | | Mailgun | Signed (HMAC timestamp+token) | `MAILGUN_WEBHOOK_SECRET` — the webhook signing key | | MailerSend | Signed (HMAC of body) | `MAILERSEND_WEBHOOK_SECRET` | | Mailtrap | Signed (HMAC of body) | `MAILTRAP_WEBHOOK_SECRET` | | Mandrill | Signed (HMAC of URL + params) | `MANDRILL_WEBHOOK_SECRET`; behind a proxy also set `MANDRILL_WEBHOOK_URL` to the exact configured URL | | MailPace | Signed (Ed25519) | `MAILPACE_WEBHOOK_SECRET` — the public verification key | | Postmark, Brevo, SparkPost, Mailjet, ZeptoMail, Elastic Email, Plunk | Shared secret | `_WEBHOOK_SECRET` — a token you make up; put the same value in the webhook URL as `?token=…` (or the provider's auth-header option) | | Amazon SES, Scaleway | Shared secret over SNS | `SES_WEBHOOK_SECRET` / `SCALEWAY_WEBHOOK_SECRET` as `?token=…` on the subscription URL; SNS subscription handshakes confirm automatically | SMTP, Microsoft 365 and Cloudflare don't emit delivery-event webhooks — `receive()` throws `webhooks_not_supported` for them. To skip verification deliberately (a local experiment, a payload replay), pass `{ verify: false }` — it's always an explicit opt-out, never a fallback. ## Testing without a provider You don't need a tunnel or a real provider to test your handler. `mock_event` builds a normalized event; `mock_request` builds a full, **correctly signed** HTTP request: ```typescript import { receive, mock_request, mock_event } from 'postboi/webhooks' // Unit-test handler logic directly: await my_handler(mock_event('opened', { email: 'user@example.com' })) // Or drive the whole path, signature verification included: const { request, secret } = await mock_request({ provider: 'resend', type: 'clicked' }) const events = await receive(request, { provider: 'resend', secret }) // events[0].url === "https://example.com/pricing" ``` ## Custom providers `receive()` accepts a custom adapter for anything postboi doesn't cover — implement `verify` and `normalize` and pass it as `provider`: ```typescript import { receive, type WebhookAdapter } from 'postboi/webhooks' const my_adapter: WebhookAdapter = { provider: 'internal', verify({ headers, secret }) { if (headers.get('x-api-key') !== secret) throw new Error('nope') }, normalize(body) { const payload = JSON.parse(body) return [{ type: 'delivered', provider: 'internal', email: payload.rcpt, raw: payload }] }, } const events = await receive(request, { provider: my_adapter, secret: INTERNAL_KEY }) ``` --- --- title: API reference name: API reference description: The provider surface, SendOptions, the Email type, and common constructor options. category: Reference --- ## Provider class Every provider exposes the same surface: ```typescript import Resend from 'postboi/resend' const mail = new Resend({ api_key: string, // see Providers for credential option names default?: { from?, to?, cc?, bcc?, reply_to? } }) await mail.send(options: SendOptions): Promise await mail.cancel(id: string): Promise<{ id: string }> // scheduled sends — see Scheduling mail.is_error(error: unknown): error is PostboiError ``` See [Providers](/providers) for each provider's credential option names. The zero-config equivalents are `mail()` and `cancel()` from the package root. ### Postboi provider methods [The Postboi provider](/provider) adds account methods on top of the common surface: ```typescript import Postboi from 'postboi' const mail = new Postboi() // reads POSTBOI_TOKEN // messages await mail.message(id) // status + content — see The Postboi provider await mail.reschedule(id, when) // Date | ISO string | Duration — see Scheduling // lists & broadcasts — see The Postboi provider await mail.lists() await mail.create_list(name) await mail.list(id) // includes recipients await mail.rename_list(id, name) await mail.delete_list(id) await mail.add_recipients(id, { email, name?, data? } | [...]) await mail.remove_recipient(id, email) await mail.broadcast(id, { from?, reply_to?, subject, body?, text?, scheduled_at? }) // suppressions — see The Postboi provider await mail.suppressions() await mail.suppress(email) await mail.unsuppress(email) ``` ## SendOptions ```typescript interface SendOptions { to?: Email | Email[] from?: Email reply_to?: Email | Email[] cc?: Email | Email[] bcc?: Email | Email[] subject?: string // default: "Mail sent from website" body: string | FormData text?: string formatter?: | { fieldset?: ((label: string) => string) | null | false name?: ((label: string) => string) | null | false } | null | false attachments?: File | File[] idempotency_key?: string headers?: Record unsubscribe_url?: string // sets RFC 8058 List-Unsubscribe headers — see Tracking & unsubscribe tags?: string[] scheduled_at?: Date | string | Duration // see Scheduling tracking?: { opens?: boolean; clicks?: boolean } // see Tracking & unsubscribe captcha?: { honeypot?: string | false // honeypot field name (default "🍯"), false disables turnstile?: { secret_key?: string } | boolean // default: on when TURNSTILE_SECRET_KEY is set } } ``` `body` accepts a string of HTML, a [`FormData`](/formdata) object, or a promise resolving to either — so you can pass a template renderer's output straight through; see [Email templates](/templates). `formatter` controls how grouped-field labels are rendered. `headers` and `tags` are forwarded to each provider's native concept — see [Custom headers & tags](/providers#custom-headers--tags). `captcha` adjusts the built-in [spam protection](/spam) for a single send. ## Common constructor options Every provider also accepts these, on top of its own credentials: ```typescript { // field defaults applied when a send omits them; to/cc/bcc accept a string or array default?: { from?, to?, cc?, bcc?, reply_to? } timeout?: number // per-request timeout in ms (default 30000) retries?: number // retries on 429/5xx and network errors (default 0) retry_delay?: number // base backoff in ms, doubles each attempt (default 500) auto_text?: boolean // derive a plain-text body from the HTML (default true) hooks?: Hooks // see Hooks captcha?: CaptchaOptions // see Spam protection } ``` See [Errors & retries](/errors) for retry behaviour, [Hooks](/hooks) for the `hooks` shape, and [Spam protection](/spam) for `captcha`. ## Email type ```typescript type Email = | string // plain address or "Name
" | { address: string; name?: string } ``` All accepted address formats: ```typescript 'user@example.com' { address: 'user@example.com', name: 'User Name' } 'User Name ' ['user1@example.com', 'user2@example.com'] ``` ## PostboiError The normalised error thrown by every provider. See [Errors & retries](/errors) for usage. ```typescript interface PostboiError { provider: string // e.g. "resend" status?: number // HTTP status, when applicable code?: string // provider-specific code, when available message: string // normalised message raw: unknown // the original provider payload } ``` ## postboi/webhooks Receive delivery events, normalized across providers — see [Webhooks](/webhooks). ```typescript import { receive, mock_event, mock_request, parse_user_agent } from 'postboi/webhooks' await receive(request: Request, options?: { provider?: ProviderKey | WebhookAdapter // default: same resolution as mail() secret?: string // default: _WEBHOOK_SECRET verify?: boolean // false skips verification (explicit opt-out) }): Promise ``` On SvelteKit, `webhook(handler, options?)` from `postboi/kit` wraps `receive` as a ready-made `+server.ts` request handler.