# Postboi > Postboi — A framework-agnostic messaging library optimised for SvelteKit. Email, SMS, WhatsApp, push and chat behind one API — swappable providers, zero configuration. 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 messaging library optimised for SvelteKit. Email, SMS, WhatsApp, push and chat behind one API, with swappable providers and zero configuration. category: Getting started --- Import a channel, call it, done. The provider and credentials come from environment variables, so the same line of code works whichever provider is behind it — and on the email side, handing `mail()` a `FormData` renders it as a tidy HTML table. ```typescript import { mail, sms } from 'postboi' await mail({ to: 'contact@example.com', subject: 'Hi', body: '

Hello

' }) await sms({ to: '+44 7788 223344', message: 'Your code is 4291' }) ``` ## Features - 👨‍💻 **Zero configuration**: works out of the box with minimal setup. - 🔌 **Provider-based**: swap providers on any channel without changing your code. - 💬 **Five channels, one shape**: [`sms()`](/sms), [`whatsapp()`](/whatsapp), [`push()`](/push), [`slack()`](/slack) and friends resolve, hook and error exactly like `mail()`. - 📡 **Multi-channel [`send()`](/send)**: fan out to every channel, or stop at the cheapest one that works. - 📝 **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 channel calls** — `mail()`, `sms()`, `whatsapp()`, `push()`, `slack()` and friends: each reads its provider and defaults from a committed `postboi.config.ts`, and secrets from the environment. Best for apps where one provider per channel 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({ ... })` or `new Twilio({ ... })` when you want an explicit instance, or credentials that don't come from the environment. 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 | | [Multi-channel send()](/send) | One call across email, SMS, WhatsApp, push and chat | | [SMS](/sms), [WhatsApp](/whatsapp), [Push](/push), [Slack](/slack), [Discord](/discord), [Teams](/teams), [Telegram](/telegram) | Each channel's setup and quirks | | [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 signs you in, writes your token, and installs Postboi — or collects your own provider's credentials. category: Getting started --- One command, fully set up. The first prompt is the only real decision: send with **Postboi**, or **bring your own provider** (Resend, SES, Mailgun, Postmark, …). Everything after that is the same, and so is every line of code you write. ## Sending with Postboi Pick **Postboi** and the CLI opens your browser, authorises the device, and writes one env var. No provider account, no API key to copy from a dashboard, no DNS, no card: ```bash # .env (gitignored — the only secret) POSTBOI_TOKEN=… ``` ```typescript import { mail } from 'postboi' await mail({ to: 'contact@example.com', subject: 'Hi', body: '

Hello

' }) ``` You don't need the token to start building: until one is set, `mail()` [prints each message to the console](/providers#no-credential-yet) in development instead of failing, and only throws once you deploy. That's the setup. Mail sends from your account's `you@send.postboi.email` address — a real deliverable address, but not an inbox, so set `reply_to` if you want replies — until you [verify a domain](/provider#sending-from-your-own-domain) of your own. `init` offers that too, at the end: it detects your project's domain (astro `site`, `package.json` `homepage`, a `CNAME` file, wrangler routes, `SITE_URL`-style env vars), prefills the prompt, registers the domain on a yes, and opens the one-click DNS link for your registrar. Skippable with one Enter — nothing waits on it. Because Postboi knows your account, `init` can wire up things a bring-your-own provider can't: - **Typed `from`** — narrowed to your sending address and verified domains, so a wrong one is a type error instead of a runtime `from_not_allowed`. The types are generated [inside `node_modules`](/provider#type-safe-from): nothing to commit, and always optional. - **Managed captcha** — the publishable key is baked in, so [``](/spam#the-captcha-component) works with no Cloudflare account and no keys. - **Webhook secrets** — every endpoint secret written as `POSTBOI_WEBHOOK_SECRET`, so [`receive()`](/webhooks) verifies signatures without a copy-paste. Re-running `init` is safe: a working `POSTBOI_TOKEN` is reused rather than replaced, so you can walk the prompts again any time to revisit defaults. `bunx postboi sync` refreshes the generated pieces (types, captcha key, webhook secrets) after adding a domain. The same token also covers the rest of the platform from the same import — no second SDK: ```typescript import { mail } from 'postboi' await mail.recipients.add('Newsletter', 'Ada Lovelace ') ``` [Message status and the log](/provider#delivery-status), [lists, broadcasts and double opt-in](/provider#lists--broadcasts), [notifications](/provider#notifications), [suppressions](/provider#suppressions), [scheduling](/scheduling) and [batch sends with idempotency keys](/provider#batching--idempotency) are all documented in [The Postboi provider](/provider), along with plan limits. ## Zero setup — no sign-in at all There's a version of the Postboi path with **no browser, no sign-in and no prompts**, built for AI coding agents, CI, and anyone who wants working mail before deciding anything: ```bash bunx postboi init --agent ``` One round trip provisions a **claimable project**: the token lands in `.env`, the config is written, and your sending address is derived from your `package.json` name (`@acme/mail-site` sends from `mail-site@send.postboi.email`). Sends work immediately but are **sandboxed** — they run the full pipeline and land in your message log without delivering — until you open the printed **claim URL** and sign in once. Claiming lifts the sandbox on the spot; nothing else changes. The details (expiry, limits, how agents should surface the claim link) are in [Zero setup for agents & CI](/provider#zero-setup-for-agents--ci). ## Bringing your own provider Pick **Bring your own provider** and the CLI asks which one, then collects its credentials (an API key, plus a domain for Mailgun or a region for SES) — printing the exact dashboard page each one comes from. If you're signed in and your team has [synced the credential](/provider#team-credentials) before, the prompt answers itself: the key is pulled from your account and you type nothing. What you do type is synced up for the next teammate, so any credential is typed once, on one machine, ever. Either way it **writes only secrets** to your env file and **everything else** — the provider, defaults, and non-secret options — to a committed [`postboi.config.ts`](/config). The best case is a single env var: ```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 ``` `mail()` picks that up on every call, exactly as it does with a `POSTBOI_TOKEN` — the sending code never names a provider, so swapping later is a one-line config change. ## What it does either way - Optionally collects `from` / `to` and other defaults applied to every send, and writes them (with hooks, later) to the committed [`postboi.config.ts`](/config). - Installs `postboi` if it isn't installed yet. - Offers to push your env vars to your host (Vercel, Cloudflare, Netlify, Railway) — no globally installed host CLI required, and it offers to link the project first when it isn't yet — and to gitignore the env file if it isn't already. - 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 what's installed is a symlink to it — so upgrading postboi upgrades the skill, with no diff in your repo — it dangles on a fresh clone until dependencies are installed, so the skill is absent rather than stale. (Where symlinks aren't available the file is copied instead, and `postboi sync` keeps that copy current.) Already have postboi installed and never ran `init`? Install the skill on its own: ```bash bunx postboi skill ``` ## Beyond email The same `init` sets up the other channels — `--sms`, `--whatsapp`, `--push`, `--chat` — and each channel's call works exactly the way `mail()` does. Start with [Multi-channel send()](/send), or jump straight to [SMS](/sms), [WhatsApp](/whatsapp), [Push](/push), [Slack](/slack), [Discord](/discord), [Teams](/teams) or [Telegram](/telegram). Signed in to the Postboi provider, `init` also **syncs the credentials it collects to your account**, so a teammate's `postboi sync` fills in their env file with no ceremony — see [Team credentials](/provider#team-credentials). ## 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. Re-running `init` is safe: when a working `POSTBOI_TOKEN` is already in your environment it's reused rather than replaced, so you can walk through the CLI again any time to revisit your defaults. 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). ## Zero setup for agents & CI `postboi init --agent` removes the last human step. No browser, no sign-in, no prompts — one API round trip mints a **claimable project** and the CLI wires everything exactly as the interactive flow would: ```bash bunx postboi init --agent ``` - **The token lands in `.env`** (gitignored automatically), the config is written, the package installed, the agent skill linked. - **Your sending address is your project's name.** The `package.json` name seeds the slug: `@acme/mail-site` sends from `mail-site@send.postboi.email` (a suffix is added if it's taken, and a friendly random slug stands in when the name can't be an address). - **Sends work immediately, sandboxed.** Every send runs the full pipeline — message log, webhooks, status — but nothing is delivered and nothing can reach a real inbox. The response says so: `{ id, sandbox: true, claim_url }`. - **One sign-in makes it real.** The CLI prints a **claim URL**; opening it and signing in (a magic link — that's the account creation too) transfers the project to you and lifts the sandbox. Everything the agent built keeps working unchanged, and the sandboxed sends are waiting in your message log. - **Unclaimed projects expire.** A project nobody claims *and* nobody uses for 14 days is deleted. Activity counts — while your token keeps sending sandboxed mail, the project stays alive for you to claim — but 60 days unclaimed is the ceiling, active or not. In development none of this even matters yet — with the [dev inbox](/dev-inbox) running, mail is captured locally before the provider is consulted, so the sandbox only shows up in deployed or production-mode sends. **If you're an AI coding agent:** run `init --agent` when no `POSTBOI_TOKEN` exists and no human is available to sign in, then **surface the claim URL prominently** in your summary — it's the one thing only your human can do. `postboi whoami` re-prints it any time. The other channels accept the flag too (`init --push --agent` mints VAPID keys and wires the service worker with no prompts); channels that need paid credentials (SMS, WhatsApp) will tell you exactly which env var they're missing instead of prompting. `--agent` also **detects the project's domain** (astro `site`, `package.json` `homepage`, a `CNAME` file, wrangler routes, `SITE_URL`-style env vars) and prints it as a suggestion rather than registering it — ownership is the human's to assert, and the API refuses domains on unclaimed projects outright, so an anonymous token can never squat one. After the claim, `bunx postboi domains add ` prints the records and the one-click registrar link. ## 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. Set `reply_to` when you want responses to go straight to a particular inbox: ```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.app/dashboard). ## Replies Your sending address is also a mailbox. Anything sent to it — including a reply to a message that never set `reply_to` — lands in **Messages → Received** in the dashboard, with the body, the sender, and a link back to the send it answers. That happens whether or not you configure anything. Two things you can add on top: - **Forward to your own inbox.** On the dashboard overview, under *Replies*, give an address and click the verification link Cloudflare emails it. From then on replies arrive in your normal inbox as well as the dashboard. (Replying from there goes out from your own address, not your Postboi one.) - **Handle them in code.** Subscribe a [webhook](/webhooks) to `email.received` and each reply arrives as a normalized `received` event — the basis for a support inbox, a reply-to-confirm flow, or ticket creation. Plus-tags route to the same mailbox, so `you+order-1234@send.postboi.email` is a per-thread address you can hand out and match on the way back: ```ts await mail({ to: "someone@example.com", reply_to: "you+order-1234@send.postboi.email", subject: "Your order", body: "

Just reply to this email

", }) ``` Inbound is free on every plan. Mail to an address nobody owns is rejected rather than swallowed, and inbound HTML is sanitised before it's stored. ## Receiving on your own domain Your domain receives as well as sends, on a dedicated subdomain — and for a new domain it's part of standard setup: the two records it needs (a TXT and an MX, both on `reply.yourdomain.com`) are in the same list as the sending ones, covered by the same one-click registrar apply where yours supports it. Once they're live, mail to **any** address on that subdomain — `support@reply.yourdomain.com`, `ada+ticket-42@reply.yourdomain.com` — lands in Received, fires `email.received`, and can be answered from the dashboard, where the reply goes out from the address the mail was written to. The switch on the domain turns receiving off (and back on for domains added before this was the default, or via `POST /v1/domains/{id}/inbound`). The MX lives on the subdomain on purpose. Pointing your domain's own MX at us would take over your real mailboxes, so that is never asked for — `you@yourdomain.com` keeps working exactly as before, wherever it's hosted. ## Sending from your own domain `init` offers this at the end of setup — it detects your project's domain (astro `site`, `package.json` `homepage`, a `CNAME` file, wrangler routes, `SITE_URL`-style env vars) and prefills the prompt, so accepting is one Enter. Or do it any time from the terminal (`bunx postboi domains add yourdomain.com` — prints the records and a one-click registrar link) or the dashboard: add a domain, publish the three DKIM CNAME records it shows you, and hit **Check**. Every plan includes one custom domain — Free included — and paid plans are uncapped (`domain_limit_exceeded` past the cap). 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

", }) ``` ## Team credentials Your channel credentials — a Resend key, a Twilio SID, a Slack webhook — sync through your Postboi account, so a teammate (or your next machine) gets a working setup from one command: ```bash bunx postboi sync # pulls every synced credential your local env is missing ``` `postboi init` pushes credentials up as it collects them **and pulls them back down as it asks**: a prompt whose value the team already synced answers itself. So for most projects this is invisible — one person runs `init --sms` and types `TWILIO_AUTH_TOKEN` once, ever; every teammate's `init` or `sync` (already in the `prepare` script) fills it in from the team. Zero ceremony. The rules, because these are secrets: - **Local values always win.** `sync` only writes keys your environment is missing; a deliberate local override is never clobbered. `postboi env pull --force` is the explicit way to take the team's values wholesale. - **`POSTBOI_TOKEN` never syncs.** It's per developer, and it's the credential that unlocks the rest — the store must not contain its own key. - **Encrypted at rest**, and only ever decrypted for a bearer of your account token: the same trust that could already send with those credentials. - **Used server-side for exactly three things.** The library itself sends with whatever is in your process environment, servers included — the synced store never feeds your send path. On Postboi's side, synced credentials are decrypted only to poll a provider that can't push delivery webhooks (SMTP, Microsoft 365, Cloudflare — on by default once synced, pausable per provider on the credentials page); to send on your behalf when you've turned on **send via** there; and, for a provider whose delivery events you point at us, to verify those webhooks with its signing secret — plus to register the webhook with that provider when you press Register, which stores any key it hands back. Never otherwise. See what's synced, push a hand-set var, or remove one: ```bash bunx postboi env # list (values masked) bunx postboi env push # push every known credential from your local env bunx postboi env remove OLD_KEY ``` ## Relay: send via your own provider With credentials synced, the credentials page grows a **Send via** setting: pick a provider and the account's sends go out through *it* — Postboi keeps the message log, the suppression list, webhooks and the dashboard timeline, while your Resend, Postmark, Cloudflare (or any other synced provider) does the delivering. Your code doesn't change: you keep sending through the Postboi provider exactly as before. ```ts // Or per send, without touching the account setting: const mail = new Postboi({ send_via: "resend" }) ``` What flows back depends on the target: - **SMTP, Microsoft 365, Cloudflare** — the providers without webhooks — get their delivery and bounce events polled (see [webhooks](/webhooks)) and correlated into the timeline, so a relayed send shows Delivered/Bounced like a native one. - **Webhook-capable providers** (Resend, Postmark, …) report events to whatever webhooks you configure *with them*; the Postboi timeline shows Sent only. Cloudflare's send response reports per-recipient verdicts immediately, bounces included. Two honest notes: set `from` to an address the relay provider can send as (the usual `from` rule is skipped for relayed sends — your provider enforces its own sender authentication, and anything it can't authenticate fails with its error), and relayed sends still count toward your Postboi plan like any other send. ## 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. `form` is typed the same way: `sync` reads your account's forms, so `mail({ form: "Home Ownership Query" })` autocompletes and a renamed or misspelled form is a type error. A raw `form_…` id is always accepted. See [Naming the form](/formdata#naming-the-form). ## Limits | Plan | Included | Daily cap | Overage | Custom domains | | ------- | ---------- | --------- | ----------- | -------------- | | Free | 3,000/mo | 100/day | none (hard) | 1 | | Starter | 20,000/mo | none | £0.40/1k | unlimited | | Pro | 100,000/mo | none | £0.35/1k | unlimited | | Scale | 500,000/mo | none | £0.30/1k | unlimited | 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 | | `domain_limit_exceeded` | Free includes 1 custom domain: upgrade to add more | | `sending_paused` | Bounce/complaint rate tripped the safety threshold | ## Delivery status Every send appears in the [message log](https://postboi.app/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 const message = await mail.messages.get(id) // { id, status: 'sent', to, subject, opened_at, open_count, … } ``` And a scheduled message can be moved (until it sends). `mail.messages.reschedule` takes the same formats as `scheduled_at`: ```ts await mail.messages.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" }, }, }) ``` 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. A batch takes **one** key and gives each recipient its own, suffixed with that recipient's position — `order-42` becomes `order-42:0`, `order-42:1` and so on. One key names one message, so a batch whose items shared a key would be a batch claiming to be a single message; the suffix is what keeps a retry replaying item by item, returning the ids the first attempt got and sending only what never went. The position is the one in your original `to` array, so a `before.send` hook skipping a different recipient the second time round doesn't shift the keys onto other people's messages. Keys are capped at 256 characters *including* the suffix — a base key too long to carry one is refused before anything is sent, rather than silently truncated into a key that could collide with another. ## Lists & broadcasts The dashboard's recipient lists are available from code, so a newsletter signup can go straight onto a list without leaving your app — one import, one call: ```ts import { mail } from "postboi" await mail.recipients.add("Newsletter", "Ada Lovelace ") ``` `mail.recipients.add` upserts on both sides. The first argument is a list **name or id** — an unknown name creates the list — and re-adding an address updates its name and `data` instead of duplicating it. Recipients take the same shapes as `to`: a bare address, `"Name "`, `{ email, name?, data? }`, or an array mixing all three. (List names are unique per account, so `mail.lists.create` rejects a taken name with code `name_taken`.) Every list method accepts a name or an id — only `mail.recipients.add` creates a missing list; everything else 404s on an unknown name. The response reports `added` (genuinely new addresses) and `updated` (existing ones refreshed), so calling it twice with the same address adds once: ```ts await mail.recipients.add("Newsletter", [ { email: "ada@example.com", name: "Ada", data: { plan: "Pro" } }, "Linus ", ]) // → { added: 2, updated: 0, list: { id, name } } await mail.lists.broadcast("Newsletter", { subject: "Hey {name}", body: "

News for our {plan} users…

Unsubscribe

", 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. For a visible opt-out link in the body, drop in `{unsubscribe_url}` — a reserved variable filled with that recipient's signed one-click link (the same target as the header). The rest of the surface: `mail.lists.all()`, `mail.lists.get(id)` (with recipients), `mail.lists.rename(id, name)`, `mail.lists.delete(id)`, and `mail.recipients.remove(list_id, email)`. ## Contacts (the audience) A **contact** is one address on your account — its `name` and `data` live once and are shared across every list it's on (not copied per list). Lists are how you *segment* that audience; a `mail.recipients` call upserts the contact and its membership together, so you rarely touch contacts directly. When you do, `mail.contacts` is the whole audience: ```ts await mail.contacts.add("ada@example.com", { name: "Ada", data: { plan: "pro" } }) const ada = await mail.contacts.get("ada@example.com") // contact + its memberships await mail.contacts.update("ada@example.com", { data: { plan: "team" } }) // global; last write wins await mail.contacts.update("ada@example.com", { phone: "+447788223344" }) // the number SMS and WhatsApp reach await mail.contacts.lists("ada@example.com") // which lists is Ada on? await mail.contacts.all({ list: "Newsletter", status: "subscribed", search: "ada" }) // page (and follow) the audience await mail.contacts.remove("ada@example.com") // drops the contact and all its memberships ``` A contact's `phone` is its **delivery profile** beyond email: one mobile number in E.164 (`+447788223344` — a national number is rejected rather than guessed at), the one an [`sms()`](/sms) or [`whatsapp()`](/whatsapp) to that person goes to. Email stays the handle; the number is a fact about the contact, searchable alongside the name. Because `data` is the contact's, setting it through `mail.recipients.add(list, { email, data })` writes the **contact's** global `data` — the same values fill `{key}` in a broadcast from any list. Deleting a contact removes it from every list but does **not** suppress it; a hard bounce or complaint is suppressed separately (see Suppressions). > **New in 0.19 (breaking).** Recipients became contacts: `name`/`data` are now the > contact's, shared across its lists (last write wins) rather than stored per list; the > per-list status enum is `subscribed | pending | unsubscribed` (bounced/complained are > suppressions, not a status); and `mail.contacts.*` is a new namespace. `mail.recipients.*` > keeps the same signatures — it's contact-backed now. ## Confirmation (double opt-in) Lists can require **confirmation**: new recipients start `pending` and receive an email with a personal confirm link; they only receive broadcasts (and count as new subscribers for notifications) once they click it. Manage it from the list's Confirmation tab, or from code: ```ts await mail.lists.update("Newsletter", { confirmation: true }) // or on create: await mail.lists.create("Digest", { confirmation: true }) await mail.recipients.add("Newsletter", "ada@example.com") // → { added: 1, updated: 0, pending: 1, list: … } — Ada gets the confirmation email ``` A membership carries a **status** — `subscribed`, `pending` or `unsubscribed`. Only subscribed members receive broadcasts and digests; an unsubscribe keeps the membership (with history) but out of every send. Hard bounces and complaints aren't a per-list status — they suppress the address account-wide, and the send path drops suppressed addresses on its own (see Suppressions). Set a membership's status explicitly too: ```ts await mail.recipients.add("Newsletter", "ada@example.com", { status: "pending" }) await mail.recipients.set_status("Newsletter", "ada@example.com", "unsubscribed") ``` Two knobs, patchable via an object: `enabled` (send confirmation emails) and `default_status` (what new recipients start as). `confirmation: true` is shorthand for strict double opt-in (email + `"pending"`); a courtesy email without gating is `{ enabled: true, default_status: "subscribed" }`; off again is `confirmation: false`. The object also takes `subject`, `body` (HTML with `{key}` variables plus `{list}` and `{confirm_url}` — put it in a link) and `from`. Settings come back on `mail.lists.get()`, and new members start `"subscribed"` or `"pending"` per the list's `default_status`. ## Notifications Each list can carry **notifications** — digests of new subscribers emailed to whoever should know, on a schedule or the moment someone joins. The dashboard's Notifications tab manages them visually; the same objects are available from code: ```ts await mail.notifications.create("Newsletter", { recipients: "Darby ", schedule: "subscribe", // fire when someone new joins }) await mail.notifications.create("Newsletter", { recipients: ["darby@uilo.co", "team@uilo.co"], schedule: { frequency: "weekly", days: [1, 4], send_time: "09:00", timezone: "Europe/London" }, }) ``` `schedule` takes a bare frequency (`"daily"`, `"weekly"`, `"monthly"`, `"subscribe"`) or an object with `days` (JS weekday numbers, weekly), `month_day` (monthly), `send_time` and an IANA `timezone` — defaults are Mondays, 09:00, UTC. Subject and body default to a starter template; bodies are HTML with `{key}` variables plus `{#if}`/`{#each}` blocks over `new_subscribers`. The rest of the surface: `mail.notifications.all(list)`, `mail.notifications.update(list, id, changes)` (partial — absent fields keep their values), and `mail.notifications.delete(list, id)`. ## 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.all() // [{ channel, email | phone, reason, detail?, created_at }] await mail.suppressions.add("noisy@example.com") // add by hand await mail.suppressions.remove("fixed@example.com") // allow sending again ``` The list is **per channel**. An email address is one entry; a phone number is suppressed for SMS and for WhatsApp separately, because "stop texting me" and "stop messaging me on WhatsApp" are two different things a person can say. A bare string is an email; a number goes in as `{ phone }`, SMS unless told otherwise: ```ts await mail.suppressions.add({ phone: "+447788223344" }) // SMS await mail.suppressions.add({ phone: "+447788223344", channel: "whatsapp" }) await mail.suppressions.all({ channel: "sms" }) // just the numbers, just that channel ``` Each row says which it is — narrow on `channel` before reading the address, because an email row carries `email` and a text row carries `phone`, never a number in a field called `email`: ```ts for (const row of await mail.suppressions.all()) { if (row.channel === "email") console.log(row.email, row.reason) else console.log(row.channel, row.phone, row.reason) } ``` A texted **STOP** reaches the list on its own when Twilio delivery receipts are polled for the account — see [opt-outs on the SMS page](/sms#opt-outs) — and the same `{ phone }` shape adds one by hand from wherever else a reply arrives. ## 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. - On Cloudflare Workers a `POSTBOI_TOKEN` binding is read automatically — see [Cloudflare Workers](/cloudflare-workers). Pass `new Postboi({ token })` only to override it. - 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 Cloudflare Workers this works the same way — bindings are read as env vars — but the > config file needs importing by hand. See [Cloudflare Workers](/cloudflare-workers). --- --- 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 `_honey` 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). ## Remote functions SvelteKit's [remote functions](https://svelte.dev/docs/kit/remote-functions) (experimental) let a library ship the whole backend. `postboi/remote` exports a ready-made remote form — no `+page.server.ts`, no action, no endpoint. The component *is* the app: ```svelte
{#if mail.result?.success}

Thanks — we'll be in touch!

{:else if mail.result}

{mail.result.error}

{/if} ``` Two lines of setup, since the feature is experimental: ```js // svelte.config.js — or inline in the sveltekit() vite plugin kit: { experimental: { remoteFunctions: true } } ``` ```ts // vite.config.ts — remote modules must reach the SvelteKit transform, // not Vite's dependency prebundle. `bunx postboi init` adds this for you. optimizeDeps: { exclude: ['postboi/remote'] } ``` > The [`postboi/vite`](/config#bundled-and-deployed-servers) plugin sets that exclude itself, so if you're > already using it (for edge deploys, say) you can drop the line. Worth knowing: - **Field names nest instead of using arrows.** Remote-form names must be valid JS paths, so `fields.contact.name` replaces `contact→name` — the email's [grouped table](/formdata#grouped-fields) comes out identical. Special fields (`_subject`, `_reply_to`, …) work unchanged. - **No schema required.** A contact form is arbitrary fields by design, so the handler accepts whatever the form sends — spam checks and parsing happen in the send pipeline, same as the classic action. Want client-side validation? Compose kit's own preflight: `
` with any Standard Schema (Zod, Valibot). - **Enhancement is built in.** The spread progressively enhances the form — no `use:enhance` import — and resets it after a successful submit. `mail.pending` counts in-flight submissions, `mail.result` is the (ephemeral) return value. Custom submit behaviour uses the form's own method: ` { await form.submit(); … })}>`. Without JavaScript the form still posts and renders the result, exactly like a native action. - **Spam protection carries over.** `` renders the honeypot under a remote-safe name, and the managed captcha detects remote forms automatically. A tripped honeypot still reports `{ success: true }` to the bot. ### Writing your own remote form `remote()` uses SvelteKit's `unchecked` mode, so it accepts whatever the form submits and the spam fields arrive intact. Writing your own `form(schema, ...)` instead — for typed fields and `issues()` — changes that: **SvelteKit validates against your schema first and drops anything undeclared, silently.** Leave the spam fields out and the honeypot is inert and the captcha token never arrives, with no error to tell you. Declare them, and forward them to `mail()`: ```ts // contact.schema.ts export const schema = v.object({ name: v.pipe(v.string(), v.minLength(1)), email: v.pipe(v.string(), v.email()), _honey: v.optional(v.string()), // HONEYPOT_FIELD _captcha: v.optional(v.string()) // TURNSTILE_REMOTE_FIELD }) ``` ```ts // contact.remote.ts export const send = form(schema, async (data) => { const body: Record = { name: data.name, email: data.email } if (data._honey) body._honey = data._honey if (data._captcha) body._captcha = data._captcha await mail({ body }) }) ``` `_captcha` rather than `cf-turnstile-response`: remote-form field names must be valid JS paths, so the managed loader renames the token field. postboi strips both before rendering. > Sending through a **bring-your-own provider** while `` is on the page fails > with `captcha_misconfigured` — a token arrived with no secret to verify it against. > Managed captcha needs the Postboi provider; on any other one, configure Turnstile > yourself or opt out with `captcha: { turnstile: false }`. The **mock** provider is the > exception: it's credential-free by design, so it drops the token and sends anyway. For a custom provider or forced fields, build your own in a `.remote.ts` file with `remote()` from `postboi/kit` — the remote counterpart of `action()`: ```ts // src/lib/mail.remote.ts import { remote } from 'postboi/kit' import Resend from 'postboi/resend' import { RESEND_API_KEY } from '$env/static/private' const resend = new Resend({ api_key: RESEND_API_KEY, default: { from: 'no-reply@example.com' } }) export const contact = remote(resend, { to: 'team@example.com' }) ``` ## Using a configured instance Got a configured provider instance? Wrap it with `action()`. Any send option you pass is merged into every send, and `status` sets the failure code. ```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, 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 by passing 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, push and every other channel from a Worker. Bindings are read automatically, so there's nothing to pass in. category: Frameworks --- Workers pass configuration as bindings rather than ambient env vars, but Postboi reads them off `cloudflare:workers` for you — a `POSTBOI_TOKEN` binding is picked up exactly like a `POSTBOI_TOKEN` env var anywhere else, so there's nothing to wire up. Read the request's `FormData` and hand it to `mail()`. Postboi extracts the [special fields](/formdata#special-fields) and renders the rest into a tidy HTML table. ```typescript // src/index.ts import { mail } from 'postboi' export default { async fetch(request: Request): Promise { const url = new URL(request.url) if (request.method === 'POST' && url.pathname === '/contact') { await mail({ 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 providers with a `POSTBOI_PROVIDER` binding plus that provider's credential — or construct one explicitly, which still works: `new Postboi({ token: env.POSTBOI_TOKEN })`. See [Providers](/providers). ## The config file Bindings arrive on their own, but a Worker has no filesystem, so `postboi.config.ts` can't be read at runtime. If you build with Vite — SvelteKit, Nuxt, Astro, Remix, or plain Vite — add the plugin and it travels in the bundle instead: ```typescript // vite.config.ts import { postboi } from 'postboi/vite' export default defineConfig({ plugins: [sveltekit(), postboi()] }) ``` That's the whole setup: `mail()` picks up your `default.from`, hooks and captcha settings with nothing imported anywhere. The plugin also adds the `optimizeDeps` exclude that [remote forms](/sveltekit#remote-functions) need, so it replaces that line too. Building with wrangler alone (no Vite), import the config file once from your entry point — `config()` registers it as a side effect and esbuild inlines it: ```typescript // src/index.ts import '../postboi.config' import { mail } from 'postboi' ``` Or skip the file and call [`configure()`](/config) at startup. ## The other channels Nothing above is email-specific — [`push()`](/push), [`sms()`](/sms), [`whatsapp()`](/whatsapp) and the chat functions read their bindings the same way. Web Push is the one worth spelling out, because it's the channel a Worker most often *is*: a background job that notifies someone. ```bash bunx postboi vapid # mint the pair wrangler secret put VAPID_PUBLIC_KEY wrangler secret put VAPID_PRIVATE_KEY wrangler secret put VAPID_SUBJECT ``` That's the whole setup — three secrets and no `POSTBOI_PUSH_PROVIDER`. A full VAPID trio can only mean Web Push, so postboi infers the provider from it. Set the var anyway if you also carry FCM or APNs credentials in the same Worker, where the credentials no longer answer the question on their own. ```typescript // src/index.ts import { push } from 'postboi' export default { async scheduled() { await push({ to: subscription, title: 'Build finished', message: 'main is green' }) .catch((error) => { // The routine failure: the browser dropped the subscription. Forget your copy. if (push.expired(error)) forget(subscription) else throw error }) }, } ``` No `nodejs_compat` needed for Web Push — VAPID signing and payload encryption are Web Crypto. (Email needs it, and so does APNs, which speaks HTTP/2.) ### Bundle size `push()` from the package root carries the resolution graph and all four push providers, so a bundler that can't split adds roughly 30 KB raw / 9 KB gzipped over importing the one provider directly: ```typescript import WebPush from 'postboi/webpush' const notify = new WebPush({ public_key: env.VAPID_PUBLIC_KEY, /* … */ }) ``` Immaterial against a 3 MB Worker limit, and the zero-config form is the one to reach for. Worth knowing if you're counting bytes — the same trade exists on every channel. **Runnable example:** [`examples/cloudflare-workers-provider-postboi`](https://github.com/postboi-mail/postboi/tree/main/examples/cloudflare-workers-provider-postboi). --- --- title: send() — every channel name: Multi-channel send() description: One call that reaches someone on email, SMS or chat — fanning out, or falling back to the cheapest channel that works. category: Channels --- ```typescript import { send } from "postboi" await send({ to: { email: "ada@example.com", sms: "+447788223344" }, subject: "Your order shipped", message: "Your order shipped", }) ``` `to` is **keyed by channel**, always. Nothing is inferred from the shape of a value — an address is only ever used on the channel you named it under. ## Two modes **Fan out** (the default) attempts every channel in `to`, concurrently. Each gets its own result, so one failing never loses the others. **Fall back** (`channels`) walks the list in order and **stops at the first success** — which is what you want for a code or an alert that only needs to arrive once. ```typescript const result = await send({ to: { chat: hook, sms: "+447788223344" }, channels: ["chat", "sms"], message: "Your code is 4291", }) result.delivered // "chat" — sms was never attempted ``` ### Which chat platform the chat leg uses A `to.chat` that is a recognisable Slack or Discord webhook URL names its own platform — that's the whole setup, nothing else required. Otherwise the platform comes from `POSTBOI_CHAT_PROVIDER` or `chat.provider` in the config, and you should set one. It is deliberately **not** inferred from a lone `SLACK_WEBHOOK_URL`, `DISCORD_WEBHOOK_URL`, `TEAMS_WEBHOOK_URL` or `TELEGRAM_BOT_TOKEN`. Those are the names every CI notification action already sets, so inferring from them would quietly post your application's messages into somebody's build-notification channel. The per-platform [`slack()`](/slack), `discord()`, `teams()` and `telegram()` functions are unaffected — they never ask which platform you meant. ## Cheapest first `channels: "cheapest"` uses the built-in order — **push → chat → email → whatsapp → sms** — narrowed to the channels you actually have an address for. ```typescript await send({ to: { email: "ada@example.com", sms: "+447788223344" }, channels: "cheapest", subject: "Your code", message: "4291", }) // tries email first; only falls to SMS if it fails ``` That ordering is worth having because the spread isn't marginal, it's **total**. Push and chat cost nothing per message. Email is fractions of a penny. An SMS into Western Europe is 2.8p or more, and can exceed 7p. Preferring a cheaper channel doesn't shave a percentage off — it saves the entire cost of the message. Nobody else will do this for you, either: a hosted orchestrator meters the fan-out itself, and no SMS vendor is going to route you to a channel it doesn't bill for. ## Reading the result ```typescript const result = await send({ to: { email: "…", sms: "…" }, subject: "…", message: "…" }) result.ok // did anything get through? result.delivered // the channel that did for (const r of result.results) { if (!r.ok) console.error(r.channel, r.error.message) } ``` `send()` **only rejects when `to` names no reachable channel at all.** Anything else resolves, because a partial delivery is information you need rather than an exception to catch. Every failure carries the `channel` it came from, so you never have to work out which leg broke. ## Content Shared fields map onto each channel's natural shape: | Field | email | sms | chat | push | whatsapp | | --- | --- | --- | --- | --- | --- | | `message` | `text` part | the body | the body | the body | the text | | `subject` | the subject | — | the title | the title | — | | `body` | HTML body | — | — | — | — | So the simplest useful call is one string: ```typescript await send({ to: { sms: "+447788223344", chat: hook }, message: "Deploy finished" }) ``` When only `message` is given, email uses it as the body too rather than sending an empty one. ### Per-channel overrides Where the copy genuinely differs — and it usually does, because SMS is billed by the character — override just that channel: ```typescript await send({ to: { email: "ada@example.com", sms: "+447788223344" }, subject: "Your order shipped", body: "

Track it here: …

", message: "Your order shipped. Track it: example.com/t/abc", sms: { message: "Order shipped: example.com/t/abc" }, }) ``` ## Hooks fire per channel Each leg runs through the [hooks](/hooks) for its own channel, so a `before.send` sees three separate calls for a three-channel fan-out — each with its own `ctx.channel`. That's what you want for suppression: skipping the SMS leg shouldn't skip the email. **Runnable example:** [`examples/scripts/notify.ts`](https://github.com/postboi-mail/postboi/tree/main/examples/scripts/notify.ts) runs both modes against real providers — the fan-out, and the cheapest-first chain — printing which leg delivered. --- --- title: SMS name: SMS description: Send a text with sms() — the same zero-config resolution, hooks and error handling as mail(), on a different channel. category: Channels --- ```typescript import { sms } from "postboi" await sms({ to: "+447788223344", message: "Your code is 4291" }) ``` Same shape as [`mail()`](/quick-start): the provider and its credentials come from your environment, [hooks](/hooks) run around every send, and failures throw a normalized [`PostboiError`](/errors). The first question is **where you're sending**, because unlike email the right SMS provider depends on the destination — a UK-native provider is materially cheaper into the UK and no use anywhere else. Your answer also becomes the default country, which is how national numbers like `07788 223344` get resolved. ## Phone numbers Anything unambiguous works without configuration: ```typescript await sms({ to: "+447788223344", message: "…" }) // international await sms({ to: "00447788223344", message: "…" }) // 00 prefix await sms({ to: ["+447788223344", "+353871234567"], message: "…" }) await sms({ to: "+447788223344, +353871234567", message: "…" }) // comma-separated ``` National formats need a country, either as a default or per send: ```typescript // postboi.config.ts → sms.default.country, or POSTBOI_SMS_COUNTRY await sms({ to: "07788 223344", message: "…" }) // → +447788223344 await sms({ to: "07788 223344", message: "…", country: "GB" }) // per send ``` Give it an ISO country code (`"GB"`) or a dialling code (`"+44"`) — the dialling code always works, including for countries the ISO table doesn't list. ### Numbers, and why they're risky A bare number reads nicely and is accepted: ```typescript await sms({ to: 447788223344, message: "…" }) ``` But a JavaScript number **cannot carry a leading `+` or a leading `0`**, so `07788 223344` becomes `7788223344` and nothing downstream can tell a UK number from a US one. We resolve what we safely can and **throw rather than guess** otherwise: ```typescript await sms({ to: "7788223344", message: "…" }) // PostboiError: Cannot tell what country "7788223344" belongs to. // Write it in full international form ("+447788223344"), or set a default // country via POSTBOI_SMS_COUNTRY or `sms.default.country` in postboi.config. ``` A wrong guess texts a stranger, so there isn't a silent fallback. **Pass `+`-prefixed strings and none of this applies.** ## Development sends nothing In development, texts are **captured and logged, never sent** — even with a fully configured provider: ``` postboi (mock sms): +447788223344 from: POSTBOI cost: 1 segment (gsm7) Your code is 4291 ``` This is stricter than email, where the [dev inbox](/dev-inbox) only intercepts when it's actually running. The asymmetry is deliberate: a stray email is embarrassing, a stray text costs money, reaches a real handset, and cannot be recalled. When you genuinely need real delivery locally: ```bash POSTBOI_SMS_DEV=send ``` ```typescript // or, permanently, in postboi.config.ts export default config({ dev: { sms: false } }) ``` ## Sender Most providers need a sender — either a number you've purchased, or an **alphanumeric sender ID**: up to 11 characters, shown to the recipient in place of a number. ```typescript export default config({ sms: { provider: "smsworks", default: { from: "POSTBOI", country: "GB" } }, }) ``` In the UK alphanumeric sender IDs are free and need no registration, which makes SMS setup about as light as email. Two things to know: they are **one-way** — a recipient cannot reply to one, so use a purchased number for conversations — and they must look like your brand, because generic IDs get filtered. In the US neither applies: sending needs 10DLC brand and campaign registration first, which takes weeks and is arranged with your provider, not here. ## Cost, and message length SMS is billed per **segment**, not per message. A GSM-7 message fits 160 characters in one segment, then 153 per segment after that. A single character outside GSM-7 — an emoji, a curly quote, an em dash — switches the whole message to UCS-2, where a segment is **70** characters: ```typescript import Mock from "postboi/sms-mock" const text = new Mock() await text.send({ to: "+447788223344", message: "…" }) text.last?.segments // { count: 1, encoding: "gsm7", units: 17 } ``` That's usually the difference between one segment and three, so it's worth knowing before you paste in a “smart quote”. ## Scheduling Where a provider supports it, `scheduled_at` takes a `Date`, an ISO string or a relative [duration](/scheduling): ```typescript await sms({ to: "+447788223344", message: "…", scheduled_at: { hours: 2 } }) ``` Providers that **can't** schedule reject the send rather than delivering immediately — a text meant for Tuesday arriving now is worse than an error, and silent. ## Sending many Pass an array. Each message gets its own result, so one failure never loses the rest: ```typescript const results = await sms([ { to: "+447788223344", message: "one" }, { to: "+353871234567", message: "two" }, ]) for (const result of results) { if (!result.ok) console.error(result.index, result.error.message) } ``` ## Providers | Provider | Import | Best for | | --- | --- | --- | | The SMS Works | `postboi/smsworks` | UK — bills only for delivered messages | | PureSMS | `postboi/puresms` | UK — flat-rate pay-as-you-go, hosted in the EU | | Twilio | `postboi/twilio` | Global, and automatic RCS upgrade via a Messaging Service | | Amazon SNS | `postboi/sns` | Already on AWS | **SMS always names its provider** — there is no inferring it from credentials: ```bash POSTBOI_SMS_PROVIDER=twilio ``` `bunx postboi init --sms` writes that line for you. The reason it isn't optional is the same one behind [development interception](#development-sends-nothing): a text costs money and reaches a real handset, and the credentials that would do the guessing aren't evidence you meant to send one. `AWS_ACCESS_KEY_ID` is set by anything near AWS; `TWILIO_ACCOUNT_SID` and `TWILIO_AUTH_TOKEN` are the Twilio SDK's zero-argument defaults, so a project using Voice or Verify already has them. [WhatsApp](/whatsapp) works the same way. Push and chat, which cost nothing and can be recalled, do infer. Construct one directly instead of using the environment, exactly like an email provider: ```typescript import Twilio from "postboi/twilio" const text = new Twilio({ account_sid: process.env.TWILIO_ACCOUNT_SID, auth_token: process.env.TWILIO_AUTH_TOKEN, default: { from: "+15550001111" }, }) await text.send({ to: "+447788223344", message: "…" }) ``` ## RCS — an upgrade, not a channel RCS is the carrier-native successor to SMS: branded sender, delivery receipts, long messages billed once instead of per segment. Since iOS 18.1 it covers both platforms, and **with Twilio it needs no code at all** — add an RCS-capable sender to a [Messaging Service](https://www.twilio.com/docs/messaging/services) and put its SID in the environment. Twilio routes each message by device capability with automatic SMS fallback, and your sending code doesn't change: ```bash # .env TWILIO_MESSAGING_SERVICE_SID=MG… # has an RCS sender attached ``` ```typescript import { sms } from "postboi" await sms({ to: "+447788223344", message: "…" }) // RCS if the device can, SMS if not ``` Constructing the provider yourself instead? Instances don't read the environment — pass `messaging_service_sid` to the `new Twilio({ … })` constructor. Worth knowing before you switch it on: - **Sender registration is console-side** — brand verification through your provider, with a one-time onboarding fee and a lead time of days to weeks. - **Pricing is parity-to-higher for short messages** (RCS carries carrier fees too), but a message over 160 characters bills **once** rather than per segment — the crossover where RCS gets cheaper than cheap UK SMS is around 3 segments. - **Which rail delivered** arrives on Twilio's status callbacks, not the send response — the message is queued before the routing decision happens. ## Delivery receipts A send response says the provider *accepted* the message. Whether it reached the handset comes later, and the two providers report it in opposite ways — both landing in the same normalized [webhook events](/webhooks#text-messages), with `channel: "sms"` and the number in `phone`: - **The SMS Works pushes.** Delivery reports go to one account-wide URL, so point it at `receive()` like an email provider: on the dashboard, **Delivery Reports → Webhook Configuration**, paste your endpoint with a token you make up as `?token=…`, and set the same value as `SMSWORKS_WEBHOOK_SECRET`. There is no signature scheme to verify — the token is compared timing-safe, and that is honestly weaker than the HMAC most email providers offer. Their delivery reports come from three published source addresses (listed on their [developer page](https://thesmsworks.co.uk/developers)) if you want a second check in front. - **Twilio is polled.** Its status callbacks are set per message at send time, so `poll({ provider: "twilio" })` reads the Message resource instead — nothing to configure and no public endpoint. See [polling](/webhooks#providers-that-dont-push-poll). ```typescript // src/routes/webhooks/sms/+server.ts — the same one-liner as an email webhook import { webhook } from "postboi/kit" export const POST = webhook( async (event) => { if (event.type === "delivered") await mark_delivered(event.message_id) if (event.type === "failed") console.warn(`${event.phone}: ${event.bounce?.detail}`) }, { provider: "smsworks" } ) ``` A text that reached the handset is `delivered`; one the carrier gave up on (`UNDELIVERABLE`, `REJECTED`, `EXPIRED`) is `failed`, with the carrier's code and words in `bounce.detail`. The SMS Works also says whether a failure is permanent, so `bounce.category` is `"hard"` or `"soft"` rather than the `"unknown"` a Twilio failure carries; a `SENT` still carrying a temporary error is the carrier retrying, and arrives as `delayed`. `message_id` is the per-message id — for a [batch send](#sending-many) the id `send()` returned was the batch's, which each report carries as `batchid` in `raw`. A message the provider is still holding (`SCHEDULED`) is never an event. Test the whole path without a tunnel: `mock_request({ provider: "smsworks", type: "failed" })` builds a signed-in delivery report. ## Opt-outs Someone who texts **STOP** back has opted out, and the law in most places says so before any provider does. The keyword list is the one every carrier recognises — `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`, and `ARRET` for Canada — and it is a whole-message test: `stop.` opts out, `please stop texting me` is a person to reply to. Four things surface it, so you needn't parse replies yourself: - **`poll()` on Twilio** reads the same Message resource it reads receipts from, and an inbound reply that is an opt-out keyword comes back as an `unsubscribed` event — `channel` is `"sms"` or `"whatsapp"` (whichever they replied over), and `phone` is their number. Nothing else anyone texts becomes an event; a conversation is not a delivery receipt. See [polling](/webhooks#providers-that-dont-push-poll). - **The SMS Works' reply webhook** — configured per reply number or keyword on their dashboard — goes to the same endpoint as its [delivery reports](#delivery-receipts), and `receive()` draws the same line: a STOP is `unsubscribed` for the number that sent it, and anything else is left for whatever reads your replies. - **`receive()` on Meta** does the same for [WhatsApp via the Cloud API](/whatsapp#delivery-receipts-and-replies), where the reply arrives as a webhook: a STOP is `unsubscribed`, and anything else they write is `received`. - **`is_opt_out(text)`**, from the package root, is the same test on its own — for an inbound webhook you already handle. ```typescript import { poll } from "postboi/webhooks" import { is_opt_out, mail } from "postboi" const { events } = await poll({ provider: "twilio", cursor: saved }) for (const event of events) { if (event.type === "unsubscribed" && event.phone) { await mail.suppressions.add({ phone: event.phone, channel: event.channel }) } } is_opt_out("STOP") // true is_opt_out("Can you stop by tomorrow?") // false ``` On the [Postboi provider](/provider#suppressions), the suppression list is per channel and a polled STOP lands on it without the loop above — the number is suppressed for the channel it replied over, and `mail.suppressions.all({ channel: "sms" })` shows it. Twilio's own opt-out handling still applies where it exists (US and Canadian long codes block the number at their end); this is how the fact reaches *you*. An alphanumeric sender ID can't be replied to, so a STOP never arrives — one more reason the [sender](#sender) section suggests a number for anything conversational. ## Hooks [Hooks](/hooks) run on every channel, so narrow on `channel` before reading fields that only one of them has: ```typescript export default config({ hooks: { before: { send: ({ channel, message }) => { if (channel === "sms") console.log(message.to, message.message) if (channel === "email") console.log(message.subject) }, }, }, }) ``` **Runnable examples:** [`examples/scripts/sms.ts`](https://github.com/postboi-mail/postboi/tree/main/examples/scripts/sms.ts) is the whole channel in a file you can run — one text, a batch, and the errors worth catching. Every framework app's `POST /notify` route sends `sms()`, `whatsapp()` and `slack()` side by side; the [SvelteKit one](https://github.com/postboi-mail/postboi/tree/main/examples/sveltekit-provider-postboi) is the shortest read. --- --- title: WhatsApp name: WhatsApp description: Send WhatsApp messages with whatsapp() — templates that deliver anytime, free-form text inside the 24-hour window. category: Channels --- ```typescript import { whatsapp } from "postboi" await whatsapp({ to: "+447788223344", template: "order_shipped", variables: { name: "Ada", tracking: "AB123" }, }) ``` Same shape as [`mail()`](/quick-start) and [`sms()`](/sms) — zero-config resolution, [hooks](/hooks), normalized [errors](/errors) — with one constraint the others don't have, and it shapes everything: **the 24-hour customer service window**. ## The 24-hour window A business may send **free-form text** only within 24 hours of the user's last inbound message. Outside that window — which is where most transactional sends happen — only **pre-approved templates** deliver. That's why `template` sits beside `message` as a first-class field rather than a provider option: template-only is the normal case, not the edge case. ```typescript // Inside the window (the user messaged you recently) — free-form works: await whatsapp({ to: "+447788223344", message: "Thanks — on its way!" }) // Anytime — a template approved with Meta, filled with variables: await whatsapp({ to: "+447788223344", template: "order_shipped", variables: { name: "Ada", tracking: "AB123" }, }) ``` A free-form send outside the window fails with `code: "outside_window"`, and the check hangs off `whatsapp` itself — no extra import: ```typescript import { whatsapp } from "postboi" try { await whatsapp({ to, message }) } catch (error) { if (!whatsapp.closed(error)) throw error await whatsapp({ to, template: "re_engage", variables: { name } }) } ``` (Holding a provider instance directly? The same check is `WhatsappProvider.is_outside_window()`.) Exactly one of `message` or `template` per send — passing both is rejected rather than guessed at, because a template's content is fixed at approval time. ## Providers | Provider | Import | Templates are | | --- | --- | --- | | Twilio | `postboi/whatsapp-twilio` | Content SIDs (`HX…`), or their names | | Meta Cloud API | `postboi/whatsapp-meta` | Approved names + language code | **Name your provider** — like [SMS](/sms#providers), WhatsApp never infers one from credentials, because a wrong guess is a billable message to a real handset and Twilio's credentials are shared with every other Twilio product you might be using: ```bash POSTBOI_WHATSAPP_PROVIDER=meta ``` **Twilio** reuses your Twilio SMS credentials and the same Message resource — addresses get the `whatsapp:` prefix added for you. Templates are created in the Content Template Builder and addressed by their `HX…` SID — though once they've been [synced](#typed-template-names) you can use the friendly name instead, the same as on Meta. ```bash # .env POSTBOI_WHATSAPP_PROVIDER=twilio TWILIO_ACCOUNT_SID=AC… TWILIO_AUTH_TOKEN=… ``` **Meta's Cloud API** is the direct route — no platform fee on top of Meta's own pricing, at the cost of Business verification. The sender is the `phone_number_id` from your app dashboard, and templates are addressed by the name they were approved under plus a language code (`language`, default `"en"`) that must match an approved translation. ```bash POSTBOI_WHATSAPP_PROVIDER=meta WHATSAPP_ACCESS_TOKEN=… WHATSAPP_PHONE_NUMBER_ID=123456789 WHATSAPP_BUSINESS_ACCOUNT_ID=987654321 # optional — types your template names ``` ### Template variables Named keys for templates approved with named parameters, numeric keys for positional ones: ```typescript variables: { name: "Ada", tracking: "AB123" } // {{name}}, {{tracking}} variables: { 1: "Ada", 2: "AB123" } // {{1}}, {{2}} ``` Which of the two a template uses is fixed when it's approved and applies to the whole template, so the keys you write are really you saying which kind it is. `variables` fills the template's **body**. A placeholder in the header or in a button's URL is its own field, because Meta sends each as a separate component. Those hold one value each, so they take it bare: ```typescript await whatsapp({ to, template: "order_shipped", header: "#1234", // the header's one variable variables: { name: "Ada" }, // the body buttons: ["orders/1234"], // one entry per dynamic button, in order }) ``` A named template's header placeholder has a name of its own, unrelated to the body's, and nowhere else to go — so those take the map form instead, and a send that omits the name comes back as error `132000`: ```typescript header: { membershiptype: "Gold" }, buttons: [{ promo: "summer_2025" }], ``` Twilio numbers every placeholder in a single namespace, so there they all go in `variables` and `header`/`buttons` are ignored. ### Typed template names A misspelled template comes back from the platform as a failed send, which is a slow way to find a typo. `bunx postboi init --whatsapp` and `bunx postboi sync` read your approved templates from Meta or Twilio and narrow `template` to them, exactly the way [type-safe `from`](/provider#type-safe-from) narrows your sending addresses: ```typescript await whatsapp({ to, template: "order_shiped" }) // ^ Type error: not one of your approved templates. // Run `bunx postboi sync` to regenerate. ``` **It reads each template's placeholders too**, so `variables` knows what *that* template takes — including that it takes them at all: ```typescript await whatsapp({ to, template: "order_shipped", variables: { name: "Ada" } }) // ^ Type error: `tracking` is missing await whatsapp({ to, template: "order_shipped" }) // ^ Type error: this template needs variables ``` The templates live on the platform, not on your Postboi account, so the sync runs against Meta or Twilio with the credentials already in your env — no Postboi account needed. Meta needs one extra id to list them, `WHATSAPP_BUSINESS_ACCOUNT_ID`, which sits beside the phone number id in the API Setup panel; Twilio needs nothing you don't already have. **On Twilio this also earns you names.** Twilio sends a `ContentSid`, so the sync bakes the name→SID map alongside the types and the provider resolves it — the same `template: "order_shipped"` works on both platforms, and a raw `HX…` still goes through untouched. Like the `from` types, this lives inside `node_modules` (nothing to commit) and is entirely optional: with nothing generated, `template` accepts any string and `variables` any record. A raw `HX…` stays valid whatever's been generated, and a template whose body the sync couldn't read keeps accepting any variables rather than rejecting them — a stale list should never fail code that works. Re-run sync after getting a new template approved; `init` adds a `prepare` script so installs restore it. ## Development sends nothing Like [SMS](/sms) and for the same reason — a template send costs real money and reaches a real handset with no recall — WhatsApp messages are **captured and logged, never sent** in development, even with a configured provider. Opt out explicitly when you need real delivery: ```bash POSTBOI_WHATSAPP_DEV=send ``` ```typescript // or, permanently, in postboi.config.ts export default config({ dev: { whatsapp: false } }) ``` The mock can also simulate the window for tests: ```typescript import MockWhatsapp from "postboi/whatsapp-mock" const wa = new MockWhatsapp({ outside_window: true }) await wa.send({ to: "+447788223344", message: "hi" }) // rejects: outside_window await wa.send({ to: "+447788223344", template: "order_shipped" }) // delivers ``` ## In a fallback chain [`send()`](/send) slots WhatsApp between email and SMS in its `"cheapest"` order, and an `outside_window` failure is just a signal to advance — so a code or alert falls through to SMS rather than failing: ```typescript await send({ to: { whatsapp: "+447788223344", sms: "+447788223344" }, channels: "cheapest", message: "Your code is 4291", whatsapp: { template: "login_code", variables: { 1: "4291" } }, }) ``` The `whatsapp` override carries the template so that leg stays deliverable outside the window, while the plain `message` rides the channels that can always carry it. ## Delivery receipts and replies Both providers report back, in the same [normalized events](/webhooks#the-event-shape) as email: `channel` is `"whatsapp"`, the number is in `phone` (never `email`), a read receipt is `opened` because it is the same fact as an email open, and a message Meta or Twilio couldn't deliver is `failed` with the provider's code and words in `bounce.detail`. **Twilio** is polled, because its status callbacks are set per message at send time — `poll({ provider: "twilio" })` covers SMS and WhatsApp in one row and needs no public endpoint. See [polling](/webhooks#providers-that-dont-push-poll). **Meta** pushes a real webhook, so it's [`receive()`](/webhooks) like an email provider. Two values from the app dashboard make it work: the **app secret** (Basic Settings) signs every delivery as `X-Hub-Signature-256`, and a **verify token** you make up is what Meta presents when it checks the endpoint is yours before subscribing it — a `GET` that `webhook()` answers, so route both methods to the same handler. Name the provider: the zero-config default is your email provider (a project without one falls through to `POSTBOI_WHATSAPP_PROVIDER=meta` on its own). ```bash META_WEBHOOK_SECRET=… # the app secret META_WEBHOOK_VERIFY_TOKEN=… # the string you typed into the webhook form ``` ```typescript // src/routes/webhooks/whatsapp/+server.ts — or the same line in any framework import { webhook } from "postboi/kit" import { mail } from "postboi" const handle = webhook( async (event) => { if (event.type === "unsubscribed" && event.phone) { await mail.suppressions.add({ phone: event.phone, channel: "whatsapp" }) } if (event.type === "received") { // They wrote to you — the 24-hour window just opened for event.phone, // and event.body?.text is what they said. } }, { provider: "meta" } ) export { handle as GET, handle as POST } ``` Subscribe the app to the **messages** field of the WhatsApp Business Account, and one endpoint hears about every number the account owns. What arrives: | Meta sends | You get | | --- | --- | | `sent`, `delivered`, `read` statuses | `sent`, `delivered`, `opened` | | a `failed` status | `failed`, with the error code and reason in `bounce.detail` — `131047` is a free-form message outside the window | | a message that is an [opt-out keyword](/sms#opt-outs) (`STOP` and friends, typed or as the label of a button or list row they tapped) | `unsubscribed` for the number that sent it | | any other message a person sends | `received` — `phone` is them, `body.text` their words when they were words, and `message_id` the send they replied to when they used WhatsApp's reply | Reactions, `system` notices and `deleted` statuses aren't delivery events and produce nothing — a [custom adapter](/webhooks#custom-providers) is the route to them if you need one. And because a `received` is the moment the customer service window opens, it's also the signal that free-form `message` sends to that number will deliver for the next 24 hours. Two honest caveats. The suppression call in the example is the [Postboi provider's](/provider#suppressions); with another email provider, write the number wherever you keep opt-outs instead. And `phone` is Meta's `wa_id` with a `+` in front, which is the E.164 number everywhere except Mexico and Argentina, where WhatsApp inserts a digit after the country code (`+52 1 …`, `+54 9 …`) — the send response's `contacts[].wa_id` is the same form, so match on that rather than the number you dialled. ## Phone numbers The same [E.164 rules as SMS](/sms#phone-numbers): international forms pass through, national forms need a default country (`whatsapp.default.country` or `POSTBOI_WHATSAPP_COUNTRY`), and anything ambiguous throws rather than guesses. **Runnable examples:** [`examples/scripts/whatsapp.ts`](https://github.com/postboi-mail/postboi/tree/main/examples/scripts/whatsapp.ts) covers both shapes — a free-form message inside the window and a template outside it. The framework apps' `POST /notify` route sends WhatsApp alongside SMS and chat; see the [SvelteKit app](https://github.com/postboi-mail/postboi/tree/main/examples/sveltekit-provider-postboi). --- --- title: Push name: Push description: Web Push, FCM, APNs, Huawei and Expo with push() — the only channel that costs nothing per message, and the only one where the address has to be registered first. category: Channels --- ```typescript import { push } from "postboi" await push({ to: subscription, title: "Order shipped", message: "On its way" }) ``` Push is the odd one out in two ways, and both shape how you use it. **It costs nothing.** FCM, APNs, Push Kit, Expo's push service and Web Push are free from Google, Apple, Huawei, Expo and the browser vendors — there is no carrier and no termination fee anywhere in the chain. That's why push sits first in [`send()`'s cost ordering](/send#cheapest-first): routing a message to push instead of SMS doesn't save a percentage, it saves the entire cost. **The address has to be registered first.** An email address or a phone number is something you can be told. A push target only exists once the device has subscribed and handed it to you — so you have to store it, and it will expire. ## Setup ```bash # .env VAPID_PUBLIC_KEY=… VAPID_PRIVATE_KEY=… VAPID_SUBJECT=mailto:you@example.com ``` No `POSTBOI_PUSH_PROVIDER` — a full VAPID trio can only mean Web Push, so postboi infers it. Set it when you carry another push provider's credentials in the same deploy, where the credentials stop answering the question on their own; the same inference covers FCM, APNs and HMS from theirs. The VAPID key pair identifies **you** to the push service. No dashboard hands one out — `bunx postboi init --push` generates it for you. The public half is also what the browser subscribes with, so the two must match — mismatched keys are rejected on every send with a 401 that explains nothing. When the secrets don't belong in a `.env` — a Worker's `wrangler secret put`, a CI secret store, a password manager — `bunx postboi vapid` prints a pair to stdout instead of writing one, and `generate_vapid_keys()` from `postboi/webpush` mints the same pair in code. Mint once: a second pair orphans every subscription collected under the first, silently. ```bash bunx postboi vapid ``` On Cloudflare Workers the three vars are read straight off your bindings, so a push Worker is three `wrangler secret put`s and no config file at all — see [Web Push on Workers](/cloudflare-workers#the-other-channels). `VAPID_SUBJECT` is required by RFC 8292 so a push service operator can reach you about misbehaving traffic. `mailto:` or an https URL — your address on its own is fine too and becomes a `mailto:`. Anything else throws when the provider is built, rather than reaching a push service that answers 401 without saying why. | Provider | Import | Reaches | | --- | --- | --- | | Web Push | `postboi/webpush` | Every modern browser, desktop and mobile | | FCM | `postboi/fcm` | [Android](#android) apps — the only route to them, and it reaches iOS too | | APNs | `postboi/apns` | Apple apps, [direct](#ios) — no Firebase in the middle | | HMS | `postboi/hms` | [Huawei](#android) phones, which have no Play Services | | Expo | `postboi/expo` | [Expo and React Native](#expo-and-react-native) apps on both platforms, with Expo holding the FCM and APNs credentials | Those are the **server** imports — the senders. The client half is a different import on purpose: `postboi/push`, next, carries no provider and no private key, so neither can end up in a client bundle by accident. A phone gets the same helper as `postboi/push/expo`, [below](#in-the-app). ## Subscribing, in the browser ```typescript import { subscribe } from "postboi/push" async function enable() { const subscription = await subscribe() await fetch("/api/push/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(subscription), }) } ``` No key in sight because `bunx postboi sync` bakes `VAPID_PUBLIC_KEY` from your env into the package — the same trick that makes `` prop-free. That kills the whole per-framework ceremony of smuggling the public key to the browser (server-component props, loaders, runtime config, data attributes). Pass `{ key }` to override, and rerun sync after rotating the pair. One import for every framework: `postboi/push` is plain DOM, so Svelte, React, Vue and no framework at all get the same line. **Call it from a click.** Browsers auto-deny a permission prompt that isn't tied to a user gesture, and once denied **you cannot ask again** — the user has to change it in site settings. That's the single most common way to permanently lose a subscriber. The helper requests permission if needed, registers your service worker (found on its own — an already-registered worker first, then `/sw.js` and SvelteKit's `/service-worker.js`), waits for it to be *active* rather than merely registered, and reuses an existing subscription — so calling it on every page load is safe. Three questions a UI wants answered before it can render the button, none of which prompt: | Call | Answers | | --- | --- | | `subscribe.supported()` | Is there any Web Push in this browser? | | `subscribe.permission()` | `"granted"`, `"denied"`, `"default"` or `"unsupported"` | | `subscribe.current()` | The subscription this browser already has, or null | The last one is the difference between a toggle that's right and one that lies: permission stays `"granted"` after someone unsubscribes, so a browser that is granted-but-not-subscribed looks identical to a subscribed one until you ask. ```typescript const on = Boolean(await subscribe.current()) ``` `unsubscribe()` removes the subscription and hands back the copy you stored, so you know which row to delete. ### The toggle, without the choreography Every settings page that offers a push switch re-implements the same state machine: `current()` on mount, busy and error state, subscribe-then-register, rollback when the server never learned the address. `subscription` is that machine written once, with one wrapper per framework so the state is reactive in each one's own idiom. Svelte's is reactive properties — `on`, `busy`, `supported` and `reason` read plainly, no `$` prefix and nothing to unwrap, and they only start watching when something actually renders them: ```svelte ``` React gets it as `usePush` from `postboi/react` — camelCase because the hooks linter and the React Compiler recognize hooks by the `/^use[A-Z]/ ` name pattern, and a hook they can't see is a hook they can't protect. Vue has no such enforcement, so `postboi/vue` keeps house style with `use_push`: ```tsx const push = usePush({ register: "/push/subscriptions" }) ``` `toggle()` is the one a switch wants — subscribe if this browser isn't, unsubscribe if it is — and handing it to a click handler by name is safe. `enable()` and `disable()` are there when the UI is two buttons rather than one. Call whichever **from a click**, for the same reason `subscribe()` has to be. `register` is a URL the subscription is POSTed to (or a function, for anything beyond that); `unregister`, when given, unfiles it on disable. A register call that fails rolls the browser subscription back, so there's never an address the server doesn't know. | Framework | Import | Shape | | --- | --- | --- | | Svelte | `subscription` from `postboi/svelte` | Reactive — `push.on` | | React | `usePush` from `postboi/react` | Hook — `push.on` | | Vue | `use_push` from `postboi/vue` | Composable — `on.value` | | Expo / React Native | `usePush` from `postboi/push/expo` | Hook — `push.on`, over the phone's [registration](#expo-and-react-native) | | Anything else | `subscription` from `postboi/push` | Store contract — `push.subscribe(fn)`, plain `push.on` reads | The last row is the machine the other three wrap, framework-neutral and plain DOM. Reach for it from vanilla JS, or from a framework we don't ship a wrapper for. **Runnable example:** [`examples/sveltekit-provider-postboi`](https://github.com/postboi-mail/postboi/tree/main/examples/sveltekit-provider-postboi) does Web Push end to end — the toggle above, the endpoint it registers with, the send, and the service worker that shows the notification. Nuxt, Next.js, Astro and Remix carry the same page in their own idiom. ### When a subscribe fails `subscribe.reason(error)` says which wall was hit, the way `push.expired(error)` does on the server — null for anything that didn't come from the subscribe call, so it's safe on a bare `catch`: ```typescript import { subscribe } from "postboi/push" try { await subscribe({ key }) } catch (error) { switch (subscribe.reason(error)) { case "permission_denied": show_settings_hint(); break case "unsupported": hide_the_button(); break default: throw error } } ``` | Reason | What happened | | --- | --- | | `permission_denied` | The user said no. The browser will not ask again. | | `permission_dismissed` | The prompt was closed without an answer (Android reports a backed-out prompt as denied-but-can-ask-again, and that counts). You can ask again later. | | `unsupported` | No Web Push in this browser. `subscribe.supported()` tells you first. | | `missing_key` | No `{ key }` and nothing baked — run `bunx postboi sync` with `VAPID_PUBLIC_KEY` set. | | `no_service_worker` | No worker at `/sw.js` or `/service-worker.js` (or at the `sw` you passed). | | `failed` | The push service refused the subscription. | The reasons are a typed union, so a mistyped `case` is a compile error rather than a branch that never runs. `PushSubscribeError` is exported too if you prefer `instanceof`. ## Your service worker Push notifications are delivered to a service worker, and the handlers inside it are the same in every app. `bunx postboi init --push` offers to write them — it finds the worker your framework already has, or creates one where that framework expects it: ``` ✓ created public/sw.js (handlers written out — this file is served as-is and can't import) On the page: subscription({ register: "/push/subscriptions" }) ``` It never appends to a worker that already handles `push` itself — two handlers means two notifications for one send — and it tells you instead. ### The two shapes Which shape you get depends on whether your worker file is **built** or **served verbatim**, and that's a per-framework fact rather than a preference: | Framework | Worker file | Shape | | --- | --- | --- | | SvelteKit | `src/service-worker.ts` | Built — imports `postboi/push/sw` | | Next.js, Nuxt, Astro, Remix | `public/sw.js` | Served as-is — handlers written out | | Vite / webpack worker entry | your entry | Built — imports `postboi/push/sw` | A built worker is four lines: ```typescript // src/service-worker.ts import { receive } from "postboi/push/sw" receive({ register: "/push/subscriptions" }) ``` A served-as-is file can't `import` — an import statement there is a syntax error at worker startup, with nothing pointing at the cause — so the CLI writes the same handlers out instead, with your VAPID public key baked in. postboi's test suite drives both through one fake worker and compares what they do, so the generated copy can't drift from `receive()`. Either way you get `push` (show the notification), `notificationclick` (open the thing, or focus the tab already showing it) and `pushsubscriptionchange` — the one that only exists inside a worker, and the one everybody skips. Nothing else: no fetch handler, no caching, no claiming clients. A worker that intercepts requests is a different feature, and yours may already be one. **`subscribe()` finds your worker on its own.** A worker already registered for the page is reused — SvelteKit registers the one it builds — and otherwise `/sw.js` and `/service-worker.js` are tried in turn, which covers every framework here with nothing passed. Only a worker served somewhere unconventional needs `sw` with its path. ### Adjusting the notification `notification` returns the fields to override, merged over the defaults. The two things the payload can't carry are an app-name fallback for a send with no title, and `tag`/`renotify`/`actions`: ```typescript receive({ register: "/push/subscriptions", notification: (payload) => ({ title: payload.title ?? "Acme", tag: "orders" }), }) ``` In a generated worker the same thing is a literal edit — the file is yours from the moment it's written, and the `""` fallback title is commented where it sits. ### Taking over the click The default click is deliberately conservative: focus the tab already showing the notification's `url` **exactly**, or open a new one — anything looser would pull someone off the page they were on. When your app knows its own clicks better, `click` replaces that entirely, called with the notification's `data` (the payload's `data` plus `url`) and the action button pressed (`""` for the body of the notification): ```typescript receive({ register: "/push/subscriptions", // A single-window PWA: bring the one open tab here rather than minting another. click: async (data) => { const [tab] = await clients.matchAll({ type: "window", includeUncontrolled: true }) if (!tab) return void clients.openWindow(data.url ?? "/") await tab.navigate?.(data.url ?? "/") await tab.focus() }, }) ``` The notification is closed before `click` runs — that part every handler owes the user — and `clients` is a worker global, so the callback reaches for it directly. ### Rotations, and why they matter Subscriptions rotate. Browsers replace them on their own schedule, and when that happens the address you stored is dead while the browser holds a replacement nobody has told you about. `pushsubscriptionchange` fires **only** inside the worker, which is why a page-side helper can't cover it and why this is the piece worth wiring. Without a handler the gap does close on its own — the next send answers 410, and `push.expired()` deletes the row — but only *after* one notification has silently gone nowhere. `receive` re-subscribes and POSTs the replacement to `register`, carrying `old_endpoint` when the browser says which subscription it replaced: ```json { "endpoint": "https://push.example/new", "keys": { "p256dh": "…", "auth": "…" }, "old_endpoint": "https://push.example/old" } ``` Delete the row for `old_endpoint`, then store the rest — that's a swap rather than a leak. The field is absent on browsers that don't hand the old subscription over, and a register endpoint that ignores it still works. Re-subscribing needs the VAPID public key, and some setups only hold it at runtime — a per-deployment pair in a Workers secret, handed to the page by an endpoint rather than baked in by `bunx postboi sync`. For those, `key` takes a function instead of a string, called only when a rotation actually needs to mint (a replacement the browser hands over needs no key). It resolves at the moment the event fires on purpose: a rotation can wake the worker cold, so a fetch at worker startup would lose exactly the race that matters. ```typescript receive({ register: "/push/subscriptions", // The same answer the page subscribes off. key: async () => { const res = await fetch("/push/key") return res.ok ? (await res.json()).key : null }, }) ``` ### Writing it yourself Nothing stops you. The minimum is: ```javascript // public/sw.js self.addEventListener("push", (event) => { const { title, body, icon, url } = event.data.json() event.waitUntil( self.registration.showNotification(title ?? "", { body, icon, data: { url } }) ) }) self.addEventListener("notificationclick", (event) => { event.notification.close() if (event.notification.data?.url) event.waitUntil(clients.openWindow(event.notification.data.url)) }) ``` You **must** show a notification for every push. `userVisibleOnly` is mandatory in Chrome, and a browser that sees you receive pushes without showing anything will revoke the permission. That's also why `receive` shows an empty notification rather than throwing on a payload it can't parse — and why the version above, which throws on any payload that isn't JSON, is a subscriber you lose eventually. ## Subscriptions expire — plan for it This is routine, not an error case. Users clear site data, reinstall browsers, and don't open your app for months. The push service answers **410 Gone** and the right response is to **delete your stored copy** — not to retry, and not to alert: ```typescript import { push } from "postboi" try { await push({ to: subscription, message: "…" }) } catch (error) { if (push.expired(error)) await forget_subscription(subscription.endpoint) else throw error } ``` The check hangs off `push` itself, so the send and its routine failure are one import. Sending to many at once, the same check applies per result: ```typescript const results = await push(subscriptions.map((to) => ({ to, message: "…" }))) for (const [i, result] of results.entries()) { if (!result.ok && push.expired(result.error)) { await forget_subscription(subscriptions[i].endpoint) } } ``` (Holding a provider instance directly? The same check is `PushProvider.is_expired()`.) ## Payload size One encrypted record holds **3993 bytes** of plaintext, and that's the whole payload — title, body, icon URL and data together. Postboi checks before encrypting and tells you the real number, rather than letting the push service reject it with a bare 400. If you're near the limit, send an id and fetch the detail in the service worker. That's better practice anyway: the payload is stored on someone else's server until it's delivered. ## Urgency and TTL ```typescript await push({ to: subscription, message: "Your code is 4291", urgency: "high", // ask the push service not to delay for battery ttl: 60, // give up after a minute — a stale code is worse than none }) ``` `ttl` defaults to 28 days. For anything time-sensitive, set it low: a notification that arrives two days late is usually worse than one that never arrives. ## Android **FCM** (`postboi/fcm`) is the only way to reach an Android phone. Not the recommended way — the only one. Push on Android goes through Google Play Services, and nothing else has access to that transport. Which leaves the phones that don't have Play Services. **Huawei** has shipped without it since 2020, and on those devices FCM doesn't fail loudly, it simply never arrives. Push Kit (`postboi/hms`) is the route to them: ```bash # .env POSTBOI_PUSH_PROVIDER=hms HMS_APP_ID=… HMS_APP_SECRET=… ``` Both from AppGallery Connect. Same `push()` call, same `push.expired(error)` check. One quirk worth knowing about, though Postboi handles it for you: **Push Kit answers HTTP 200 even when the send failed** — the real outcome is a result code in the body. Anything that only checked the status code would report a silent non-delivery as a success. Postboi reads the code, so a failure throws like it does everywhere else. Serving both? Store which one a device registered with, and pick the provider per device — a token from one is meaningless to the other. ## iOS Two routes, and the difference is whether Google sits in the middle. **APNs** (`postboi/apns`) talks to Apple directly, with a `.p8` key from your developer account and nothing else in the chain. **FCM** forwards to APNs on your behalf, which is worth it if you're already sending to Android and would rather hold one credential than two. If iOS is all you ship, there's no reason to route it through Firebase. ```bash # .env POSTBOI_PUSH_PROVIDER=apns APNS_KEY_ID=ABC1234567 APNS_TEAM_ID=DEF1234567 APNS_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGT…\n-----END PRIVATE KEY-----" APNS_TOPIC=com.example.app ``` `APNS_TOPIC` is your app's bundle ID. The key is the `.p8` you download **once** — Apple won't show it again — from Certificates, Identifiers & Profiles → Keys. **Or don't type any of it.** `bunx postboi init --push` looks for the `AuthKey_*.p8` you just downloaded — in the current directory and in `~/Downloads` — and offers it. Pick one and it fills the key *and* `APNS_KEY_ID`, because Apple puts the key ID in the filename. It writes the PEM's newlines as `\n` inside double quotes too, since a `.env` value is a single line. Then it checks the credentials against APNs before writing anything, by sending to a device token that can't exist. Apple validates the key, team and topic before it looks at the device, so a rejected *token* means everything else was accepted — and if something is wrong, you're told which thing: ``` ✓ read the key, and took APNS_KEY_ID from its filename Checking the credentials with APNs… ! APNs rejected the topic — is com.example.app really the app's bundle ID? Save them anyway? (y/N) ``` There is no OAuth to offer here and there won't be: Apple has no API that creates an APNs key, the App Store Connect API's own credential is another `.p8` you download by hand, and its terms forbid using it to provide services to third parties. Finding the file and checking what you typed is the ceiling. Set `APNS_ENVIRONMENT=sandbox` while you're testing against a development build. A token from a debug build is only valid against the sandbox and a TestFlight or App Store token is only valid against production; cross them and every send fails as `BadDeviceToken`, which reads like a broken token rather than a wrong setting. It's the first thing to check. `push.expired(error)` covers APNs too. Apple reports a dead token two ways — `Unregistered` as a 410, and `BadDeviceToken` as a **400** — and both mean the same thing: delete your stored copy. One note on how this works, because it's the reason most libraries hand you Firebase instead. APNs refuses HTTP/1.1, and Node's built-in `fetch` only speaks HTTP/1.1 — so Postboi sends over `node:http2` on Node and Bun, and over the global `fetch` on Workers and Deno, where it already negotiates HTTP/2. There's nothing to configure, and no dependency either way. Web Push also works on iOS 16.4+, but **only for a home-screen web app**, and the user has to add it to their home screen first. Worth knowing before you build a flow around it. ## Expo and React Native Two halves, like the browser: the app registers, the server sends. Both are JavaScript, so both ship in this package — there is no native SDK to install, and none coming. The app already has everything registration needs in `expo-notifications`; what was missing was the same small helper the browser gets, so the two apps read the same. ### In the app ```typescript import { subscribe } from "postboi/push/expo" const registration = await subscribe() await fetch("https://example.com/api/push/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(registration), // { token, provider: "expo", platform } }) ``` `token` is an `ExponentPushToken[…]` — what `postboi/expo` sends to — and `provider` says so, because a token from one push provider is meaningless to the others. Store both. When to call it depends on whether the app has a switch. An app that always pushes calls `subscribe()` on every launch: it reuses the token the device has, the OS asks at most once, and tokens rotate so the server should hear the latest. An app with a switch calls it when the user says yes, and on later launches re-posts `subscribe.current()` instead — `subscribe()` at launch would quietly undo an opt-out. Same surface as the browser, with the same names, so a settings screen shared between the two apps reads alike: | Call | Answers | | --- | --- | | `subscribe.supported()` | Is push available here? Not on the web build, not in Expo Go on Android | | `subscribe.permission()` | `"granted"`, `"denied"`, `"undetermined"` or `"unsupported"` — async, the OS is asked | | `subscribe.current()` | The registration this device holds, or null. Never prompts, never hits the network | | `subscribe.reason(error)` | Which wall a subscribe hit, or null for an error that wasn't ours | `unsubscribe()` unregisters the device and hands back the registration so you know which row to delete. On Android the helper creates the `default` notification channel **before** asking — on Android 13+ there is no permission prompt without one, and the OS answers "denied" as though the user had. It's created at full importance, because it's the channel Expo delivers to, and the one Expo would otherwise create at default importance the first time a notification arrived. | Reason | What happened | | --- | --- | | `permission_denied` | The user said no. The OS will not ask again — they change it in Settings. | | `permission_dismissed` | The prompt was closed without an answer (Android reports a backed-out prompt as denied-but-can-ask-again, and that counts). You can ask again later. | | `unsupported` | The web build (use `postboi/push`), or Expo Go on Android, which lost remote push in SDK 53 — use a development build. | | `missing_project` | No EAS project id. `eas init` writes one to `app.json`, or pass `{ project_id }`. | | `failed` | Expo couldn't mint a token. | Simulators are fine: iOS since Xcode 14, Android emulators with Play services. That's new enough that older guides still tell you to check for a physical device; this helper doesn't. #### The toggle React Native is React, so the toggle is `usePush` — same shape as `postboi/react`'s, over the phone's registration: ```tsx import { usePush } from "postboi/push/expo" import AsyncStorage from "@react-native-async-storage/async-storage" const push = usePush({ register: "https://example.com/push/registrations", unregister: "https://example.com/push/registrations", storage: AsyncStorage, }) ``` Two things differ from the browser. `register` needs an **absolute URL** — a phone has no origin to resolve a relative one against, and a relative one is refused up front rather than surfacing as a bare `register_failed`. And `storage` is where the registration is remembered: a browser's `PushManager` holds its subscription, but the OS holds only *permission* — granted by default on Android 12 and below, and left granted after someone turns push off in your app — so "is this device registered?" is something the helper has to remember for itself, and it's what `current()` answers from without a network round-trip. Anything with AsyncStorage's `getItem`/`setItem`/`removeItem` will do; without it the memory lasts one launch, and the switch comes up off after a restart. `subscription()` is the same machine for anything that isn't a component. The phone does have one thing the browser page lacks: it hears when the OS rotates the token underneath a running app. While something is rendering the toggle, a rotated token is re-filed at `register` on its own — the page-side twin of what the service worker does on `pushsubscriptionchange`. #### Raw device tokens instead If the server already sends through `postboi/fcm` and `postboi/apns`, skip Expo's service: ```typescript const registration = await subscribe({ native: true }) // { token: "…", provider: "apns" | "fcm", platform: "ios" | "android" } ``` `provider` follows the platform, and it's what to pick the sender by — the reason to store it. This is the route with two credentials on the server instead of none; Expo's is the one below. ### On the server ```bash # .env POSTBOI_PUSH_PROVIDER=expo ``` And that's the setup. Expo's push service holds your FCM and APNs credentials — set up once with `eas credentials`, where the app's build already needs them — and forwards to both, so the server carries nothing. The one credential that exists is optional: `EXPO_ACCESS_TOKEN`, only once **Enhanced Security for Push Notifications** is switched on for the project. This is the one push provider that has to be named: there is no credential to infer it from, and the token's name is the one `expo-server-sdk` users already set for their own sends. ```typescript import { push } from "postboi" await push({ to: registration.token, title: "Order shipped", message: "On its way" }) ``` `push.expired(error)` covers Expo too — its `DeviceNotRegistered` is the same instruction as a 410: delete your stored copy. **A 200 is a ticket, not a delivery.** Expo has taken the message and will pass it on; whether Apple or Google took it shows up in a **receipt** Expo keeps for a day, and that's where `DeviceNotRegistered` usually arrives. Fetch them at least once — a job an hour later is plenty: ```typescript import Expo from "postboi/expo" const notify = new Expo() const { id } = await notify.send({ to: token, message: "…" }) // Later. const receipts = await notify.receipts([id]) const receipt = receipts[id] // absent until Expo hears back if (receipt && !receipt.ok && Expo.is_expired(receipt.error)) await forget_token(token) ``` Each receipt is `{ ok: true }` or carries the same `PostboiError` the send would have thrown, so the expiry check is the one you already have. Payloads are capped at **4096 bytes** of title, body and data together — checked before sending, like the other providers. Expo also rate-limits at 600 notifications a second per project and answers 429 past it; set `retries` and the built-in backoff honours its `Retry-After`. --- --- title: Slack name: Slack description: Post to Slack from your app with slack() — one webhook URL, zero config, the same shape as mail() and sms(). category: Channels --- ```typescript import { slack } from "postboi" await slack({ title: "Deploy", message: "Finished in 42s" }) ``` One env var and your app can talk to the team. The webhook URL carries the destination channel inside it, so there's usually nothing to pass but the message. ## Setup The fast way: `bunx postboi init --chat`, pick Slack, choose **Connect in the browser**. Slack's own consent screen asks which channel to post to, and the webhook lands in your env file without you ever seeing it. Signed in to Postboi, it also [syncs to your team](/provider#team-credentials), so nobody else sets it up at all. Or create an [incoming webhook](https://api.slack.com/messaging/webhooks) yourself and paste it: ```bash # .env SLACK_WEBHOOK_URL=https://hooks.slack.com/services/… ``` **Treat the URL as a secret** — anyone holding it can post to your channel. It's exactly the kind of credential team sync exists for. ## Titles `title` renders as bold mrkdwn above the message: ```typescript await slack({ title: "Build failed", message: "3 tests failing on main" }) ``` ## Posting to more than one channel `to` overrides the webhook per message, so one app can serve several channels: ```typescript await slack([ { to: process.env.SLACK_ALERTS, message: "🔴 Checkout is down" }, { to: process.env.SLACK_DEPLOYS, message: "Deployed abc123" }, ]) ``` Each gets its own result — one failure never loses the rest. ## Development posts for real Unlike [SMS](/sms), Slack is not intercepted in development: posting to your own channel while building is usually the point, costs nothing, and can be deleted. With no `SLACK_WEBHOOK_URL` configured, messages are captured by the [dev inbox](/dev-inbox) (or logged) instead of erroring. ## Worth knowing - Slack replies to a failed post with a **plain-text reason** (`no_service`, `invalid_payload`) rather than JSON — that's what lands in `error.code`. - In a multi-channel [`send()`](/send), the chat leg posts to whichever platform `chat.provider` names in your [config](/config). Your own code just calls `slack()`. **Runnable example:** [`examples/scripts/chat.ts`](https://github.com/postboi-mail/postboi/tree/main/examples/scripts/chat.ts) posts to each platform in turn. The framework apps reach it through their `POST /notify` route — see the [SvelteKit app](https://github.com/postboi-mail/postboi/tree/main/examples/sveltekit-provider-postboi). --- --- title: Discord name: Discord description: Post to Discord from your app with discord() — one webhook URL, zero config, the same shape as mail() and sms(). category: Channels --- ```typescript import { discord } from "postboi" await discord({ message: "New release is live 🎉" }) ``` Same shape as [Slack](/slack): the webhook URL carries the destination channel, so one env var is the whole setup. ## Setup The fast way: `bunx postboi init --chat`, pick Discord, choose **Connect in the browser**. Discord's own consent screen asks which server and channel to post to, and the webhook lands in your env file without you ever seeing it. Signed in to Postboi, it also [syncs to your team](/provider#team-credentials), so nobody else sets it up at all. Or create a webhook yourself under **Server Settings → Integrations → Webhooks** (or a channel's own settings), and paste it: ```bash # .env DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/… ``` **Treat the URL as a secret** — anyone holding it can post to your channel. It's exactly the kind of credential team sync exists for. ## Titles `title` renders as bold markdown above the message: ```typescript await discord({ title: "Build failed", message: "3 tests failing on main" }) ``` ## Posting to more than one channel `to` overrides the webhook per message: ```typescript await discord([ { to: process.env.DISCORD_ALERTS, message: "🔴 Checkout is down" }, { to: process.env.DISCORD_RELEASES, message: "v1.4.2 is out" }, ]) ``` Each gets its own result — one failure never loses the rest. ## Development posts for real Unlike [SMS](/sms), Discord is not intercepted in development: posting to your own server while building is usually the point. With no `DISCORD_WEBHOOK_URL` configured, messages are captured by the [dev inbox](/dev-inbox) (or logged) instead of erroring. ## Worth knowing - Discord caps a message at **2000 characters**. Postboi truncates on code points (an emoji never splits into garbage) rather than letting the whole post be rejected — which matters when you're piping in a stack trace. - In a multi-channel [`send()`](/send), the chat leg posts to whichever platform `chat.provider` names in your [config](/config). Your own code just calls `discord()`. --- --- title: Microsoft Teams name: Teams description: Post to Microsoft Teams from your app with teams() — a Power Automate Workflows webhook, an Adaptive Card, zero config. category: Channels --- ```typescript import { teams } from "postboi" await teams({ title: "Deploy", message: "Finished in 42s" }) ``` Postboi posts an [Adaptive Card](https://adaptivecards.io) to a **Power Automate Workflows** webhook — the current, supported way into a Teams channel. ## Setup In Teams: pick a channel → **Workflows** → *Post to a channel when a webhook request is received*. The URL it gives you lives on `logic.azure.com`: ```bash # .env TEAMS_WEBHOOK_URL=https://prod-….logic.azure.com:443/workflows/… ``` **Treat the URL as a secret** — anyone holding it can post to your channel. It's exactly the kind of credential [team sync](/provider#team-credentials) exists for. ## Legacy connector URLs are rejected If you have an old Office 365 connector URL (`outlook.office.com/webhook/…` or `….webhook.office.com/…`): Microsoft disabled those in May 2026, and posts to them vanish without an error. Postboi recognises them and **throws `code: "legacy_webhook"`** instead — a loud failure pointing at the Workflows setup above, rather than a message that silently never arrives. ## Titles `title` renders as a bold heading block in the card: ```typescript await teams({ title: "Build failed", message: "3 tests failing on main" }) ``` ## Development posts for real Unlike [SMS](/sms), Teams is not intercepted in development. With no `TEAMS_WEBHOOK_URL` configured, messages are captured by the [dev inbox](/dev-inbox) (or logged) instead of erroring. ## Worth knowing - Workflows answers a rejected post with `{ error: { code, message } }` — that's what lands on the normalised `PostboiError`. - In a multi-channel [`send()`](/send), the chat leg posts to whichever platform `chat.provider` names in your [config](/config). Your own code just calls `teams()`. --- --- title: Telegram name: Telegram description: Message Telegram from your app with telegram() — a bot token and a chat id, zero config. category: Channels --- ```typescript import { telegram } from "postboi" await telegram({ to: "987654321", message: "Deploy finished" }) ``` ## Setup Create a bot with [@BotFather](https://core.telegram.org/bots#botfather) and give Postboi its token: ```bash # .env TELEGRAM_BOT_TOKEN=123456:ABC-… ``` Commit a default chat id as `chat.default.to` in [postboi.config.ts](/config) and the `to` can be omitted: ```typescript export default config({ chat: { provider: "telegram", default: { to: "987654321" } }, }) ``` ## A chat id is a registered identity, not an address A bot **cannot message someone who hasn't started a chat with it first**, and you address people by `chat_id` — a number you capture from an inbound update, not something you can know in advance. That's the same shape as a push subscription token, so it needs somewhere to be stored. [Slack](/slack), [Discord](/discord) and [Teams](/teams) have no equivalent problem: there, the URL *is* the destination. ## Titles `title` renders bold, with the body safely below it — Postboi sends HTML parse mode with everything escaped, so a message containing `<`, `&` or an underscore never breaks the formatting or gets mangled by Markdown rules: ```typescript await telegram({ title: "Deploy ", message: "migrated table user_accounts & friends" }) ``` ## Development posts for real Unlike [SMS](/sms), Telegram is not intercepted in development. With no `TELEGRAM_BOT_TOKEN` configured, messages are captured by the [dev inbox](/dev-inbox) (or logged) instead of erroring. ## Worth knowing - Telegram reports failures with `ok: false` and an **HTTP 200**, so don't check status yourself — the normalised error already accounts for it. - In a multi-channel [`send()`](/send), the chat leg posts to whichever platform `chat.provider` names in your [config](/config). Your own code just calls `telegram()`. --- --- title: Bluesky name: Bluesky description: Post to Bluesky from your app with bluesky() — a handle and an app password, zero config. category: Channels --- ```typescript import { bluesky } from "postboi" await bluesky({ message: "Postboi 0.25 is out — https://postboi.app" }) ``` ## Setup Create an app password in [Settings → App Passwords](https://bsky.app/settings/app-passwords) — never your account password — and give Postboi the pair: ```bash # .env BLUESKY_HANDLE=you.bsky.social BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx ``` Self-hosting your own PDS? Set `BLUESKY_SERVICE` to it. Everyone else can leave it alone. ## This one is public Every other chat platform posts somewhere private: a channel, a server, a conversation. Bluesky posts to **your own feed**, where anyone can read it. There is no destination to choose, which is why `to` does nothing here — the post lands on the account the credentials belong to. Worth remembering if you put Bluesky behind a multi-channel [`send()`](/send): a fallback chain that ends in a public post is not the same kind of fallback as one that ends in an email. ## Titles, links and length `title` becomes the first line, with the body below it — a post record has no formatting of its own. Links are made clickable for you. Bluesky doesn't linkify anything itself: a URL is dead text unless the post carries a *facet* pointing at it, measured in UTF-8 bytes, so Postboi builds those from the text you send. Mentions and hashtags are not detected. A post is **300 graphemes**, not characters — one emoji family counts once. Postboi counts before sending, so an over-long post fails with a `too_long` error instead of a 400. ## Sessions, not API keys The AT Protocol trades your app password for a session token lasting a couple of hours, and minting one is rate-limited to 300 a day — so a session per post would become the ceiling. Postboi keeps the session and re-mints it only when the server says it expired. Nothing to configure. ## Development posts for real Like the rest of [chat](/slack), Bluesky is not intercepted in development. With no `BLUESKY_APP_PASSWORD` configured, posts are captured by the [dev inbox](/dev-inbox) (or logged) instead of erroring — with one set, they go out publicly, so keep a second account for that. --- --- 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 `_honey` honeypot and Turnstile's token (`cf-turnstile-response`, or `_captcha` on SvelteKit remote forms). 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. ## Naming the form On the Postboi provider, a send can say which form it came from: ```typescript 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 types `form` to your account's forms (and their `form_…` ids), the same way it types `from`, so a form you rename in the dashboard becomes a type error here on the next sync — while 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. Naming a form marks the send as a form submission, so [managed captcha](/spam) gates it like any FormData send. 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 `` 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 `
`, 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 `
` 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: ```typescript import { escape_html, escape_lines, mail } from "postboi" await mail({ subject: "New enquiry", body: `

From ${escape_html(name)}

${escape_lines(message)}

`, }) ``` `escape_html` for single-line values and attributes, `escape_lines` when the value may contain newlines. ## 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: Dev inbox name: Dev inbox description: A local inbox that catches everything your app sends in development — email, texts, WhatsApp, chat and push — so you can read the real thing instead of a console dump. category: Guides --- Messages you send while building have to go somewhere. Sending them for real means a stray customer address is one typo away (and on SMS, a real bill); printing them to the terminal means you never see the thing you're actually building. So Postboi ships a local inbox. Start your dev server and everything lands at `/__postboi` instead of going out — mail with rendered HTML, headers and attachments, and [texts](/sms), [WhatsApp messages](/whatsapp), [chat posts](/slack) and [pushes](/push) alongside them. **Your code doesn't change.** No `preview()`, no `if (dev)`, no separate provider. The same calls that send in production are captured in development. ## Vite projects — nothing to run If you have the [`postboi/vite`](/cloudflare-workers#the-config-file) plugin in your `vite.config.ts` (`postboi init` adds it), the inbox is already there: ```bash bun run dev ``` ``` ➜ Local: http://localhost:5173/ ➜ Postboi: dev inbox at http://localhost:5173/__postboi ``` It rides on the dev server you already started — no extra port, no second terminal. That covers SvelteKit, Astro, Nuxt, Remix and plain Vite. Because it's mounted by Vite's `configureServer` hook, which only ever runs in dev, the inbox cannot end up in a production build. ## Everything else — `postboi dev` Express, Hono, Next.js and bare `wrangler dev` have no Vite to hang it off, so run the inbox yourself: ```bash bunx postboi dev ``` ``` Postboi dev inbox: http://localhost:1080/__postboi Mail from this project is captured here instead of being sent. ``` It advertises its port in `node_modules/.postboi/`, so an app started in the same project directory finds it with nothing configured. Running somewhere else — another directory, a container, a different runtime — set `POSTBOI_INBOX` to the port: ```bash POSTBOI_INBOX=1080 bun run dev ``` ## Just looking at it `--demo` fills the inbox with sample captures from every channel — mail (a styled HTML message, a FormData table with an attachment, a text-only body), a text thread, a WhatsApp template, one conversation per chat platform, and a couple of notifications, plus a scheduled send and a cancelled one — so there's something to look at without wiring an app to it: ```bash bunx postboi dev --demo ``` It re-seeds on every start, which also makes it the way to work on the inbox itself: run it under a file watcher and an edit restarts the server with the sample captures still there. ```bash bun --watch src/cli/index.ts dev --demo ``` ## What gets captured **Everything**, including a provider with real, working credentials. That's the point: a laptop should not be able to mail a real person because a test fixture had a real address in it. With the inbox running, `mail()` never reaches a provider. The other channels land here too, each by its own rule: - **[SMS](/sms) and [WhatsApp](/whatsapp)** are intercepted in development whether or not an inbox is running — a stray text costs money and can't be recalled. With an inbox up, the capture lands there; without one, it's printed to the console. - **Chat ([Slack](/slack), [Discord](/discord), [Teams](/teams), [Telegram](/telegram)) and [push](/push)** send for real in development once configured (posting to your own Slack is usually the point). Unconfigured, their dev fallback captures to the inbox instead of erroring. Two guards keep that from ever biting a deploy: - It only happens when `NODE_ENV` is exactly `development`. An unrecognised environment is treated as production, so nothing is ever silently swallowed on a server. - If the inbox isn't reachable — you killed the dev server, the port moved — the message is **printed to the console**, never sent. A send you thought was captured can't quietly become real mail. > **On an HTTPS dev server**, the inbox is served over HTTPS too — it's mounted on that same > server — and Postboi works this out for itself. Its certificate doesn't need to be one > anything trusts. If you're pointing at an inbox by hand, `POSTBOI_INBOX` takes a whole URL > as well as a bare port: > > ```bash > POSTBOI_INBOX=https://localhost:5173 bun run dev > ``` ## Turning it off To send for real from your machine, pick whichever fits: ```ts // postboi.config.ts import { config } from 'postboi' export default config({ dev: { inbox: false }, }) ``` ```bash POSTBOI_INBOX=off bun run dev ``` ```ts // vite.config.ts — don't serve it at all postboi({ inbox: false }) ``` ## Reading the mail The inbox lists messages newest-first and shows each one four ways: | Tab | What it shows | | --------------- | ------------------------------------------------------------------- | | **Message** | The HTML, rendered in a sandboxed frame — scripts blocked, as a real client would | | **Plain Text** | The `text` part, including the one [auto-derived](/config) from your HTML | | **Source** | The raw HTML, for when a provider mangles something | | **Report** | The [email-testing](/email-testing) analysis over what you sent — client compatibility, clipping, dead links | | **Attachments** | Every file, downloadable, with its type and size | Above them: `from`, `to`, `cc`, `bcc` and `reply_to` exactly as the provider would have received them — after defaults are applied and addresses parsed. If `reply_to` isn't what you expected, this is where you find out. The Report tab can also photograph the capture in **real clients** — Outlook on Windows, Gmail, Apple Mail — when a hosted [Postboi](https://postboi.app) account is connected: set `POSTBOI_TOKEN` (your API token) and a **Photograph in real clients** button appears. One click runs the capture through the hosted [testing pipeline](/email-testing) and the screenshots develop right there in the tab. Every client on a run is one rendered preview from the account's monthly allowance, so the button never fires on its own. Messages live in memory for the life of the dev server. Restarting it empties the inbox. **Prev** and **Next** at the bottom of the reader step through the mailbox without going back to the list. ### Folders The list is an outbox, not an inbox — these are messages on their way out, caught before they went. The tabs carry their counts: | Folder | What's in it | | ------------- | --------------------------------------------------------------------- | | **Outbox** | Everything still going out — Sent and Scheduled together | | **Sent** | Went immediately, or its scheduled time has passed | | **Scheduled** | Sent with [`scheduled_at`](/scheduling), still ahead of its time | | **Deleted** | Called off with [`cancel()`](/scheduling#cancelling-a-scheduled-send) | ```ts const { id } = await mail({ to: 'ada@example.com', subject: 'Reminder', body: '

See you tomorrow.

', scheduled_at: { days: 1 } }) await cancel(id) // moves it to Deleted ``` Nothing is really queued — the inbox captured these instead of sending them — so a scheduled message crossing its time only moves folders while the page is open. > [`mail.messages.reschedule`](/scheduling#rescheduling) is a provider API call rather than > a send, so it doesn't pass through the inbox. A captured message keeps the time it was > captured with. ### Every channel opens as its own app Mail opens in the reader. Everything else opens as the application it belongs to, each in its own window on the desktop: - **WhatsApp** gets a WhatsApp window — brand green, date chips, template cards, and two grey ticks that will never turn blue. - **Slack, Discord, Teams and Telegram** share one chat window that dresses as whichever platform the message was bound for. A chat whose platform isn't known falls back to a Messenger window of a suspiciously familiar vintage — the Nudge still works. - **Push** pulls down a notification shade, every notification on one panel, delivered to 0 devices. - **SMS** is the exception to all of it: a text lands on a handset, so a handset is what opens — a Pokia, an indestructible blue brick with no window frame at all, dragged around by its body like Winamp. Click a text on the screen to read it, scroll it with the wheel or the two curved keys, and power the thing off from the button on its crown. The keypad types, and the letters printed on the keys still spell something. The desktop has one other icon on it, and it isn't a mail client. Its weapon sprites come from the [Freedoom](https://github.com/freedoom/freedoom) project, under the 3-clause BSD licence — the notice ships with the package as `FREEDOOM-LICENSE.txt`. Conversations thread by channel and destination — send three texts to the same number and the phone reads them as one inbox. Channel details ride along in each skin: SMS shows its segment count and encoding (what your provider would have billed), WhatsApp shows the template, language and variables, push shows the click-through URL and data payload. Every window is a child of the app: minimise Postboi Local and they all duck down with it to reveal the desktop, restore it and the same set comes back. ### Options The inbox is dressed as a mail client of a certain vintage, sound and all. Both the sound and the sign-on screen can start off, for everyone on the project: ```ts // vite.config.ts postboi({ inbox: { sounds: false, intro: false } }) ``` ```bash bunx postboi dev --no-sound --no-intro ``` These set what the page *starts* with; the toolbar toggle still works, and a viewer's own choice is remembered and wins. `inbox: false` disables the whole thing. > The desktop behind the app plays a short clip, which streams rather than shipping in the > package — the only thing here that reaches the network. If it can't, nothing breaks and > nothing is logged. **No mail ever leaves your machine**; the capture path is unrelated. ## Tests still use the mock The inbox is for reading mail with your eyes. In tests, keep using [`postboi/mock`](/providers#mock-provider), which captures to `sent` with no server involved: ```ts 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) ``` > The inbox stands in front of **sending** only. `mail.lists`, `mail.contacts` and the other > [Postboi provider](/provider) namespaces still talk to the real API in development — > managing an audience isn't something you'd want faked. --- --- title: Email testing name: Email testing description: Lint an email before anyone receives it — client compatibility from real support data, Gmail clipping, accessibility, deliverability signals — with one synchronous function and zero dependencies. category: Guides --- An email that renders beautifully in your browser can still collapse in Outlook, get clipped by Gmail, or read as blank to a screen reader. Finding that out from a customer is the expensive way. `postboi/inspect` is the cheap way: static analysis for email HTML. One synchronous call, no network, no dependencies — the same analysis runs in a test, a CI job, a Worker or the CLI. ```ts import { analyze } from 'postboi/inspect' const report = analyze({ html: '
', text: undefined, subject: 'Hello' }) report.status // "warning" report.findings[0].message // display:flex is used but not supported in Outlook (Windows) … ``` ## In your test suite The natural pairing is the [mock provider](/providers#mock-provider): send with your real code, analyze what would have gone out. ```ts import Mock from 'postboi/mock' import { analyze } from 'postboi/inspect' const mail = new Mock({ default: { from: 'no-reply@example.com' } }) await mail.send({ to: 'ada@example.com', subject: 'Welcome', body: welcome_template }) const report = analyze({ html: mail.last?.html, text: mail.last?.text, subject: mail.last?.subject }) expect(report.status).not.toBe('error') expect(report.findings.filter((f) => f.severity === 'warning')).toEqual([]) ``` Pass what you have. Every input is optional, and a check that needs a missing input stays silent instead of guessing — a bare HTML string is never nagged about headers it couldn't possibly carry. ## What it checks **Client compatibility.** The document is matched against a support matrix derived from the [Can I email](https://www.caniemail.com) project's data (CC BY-SA 4.0) — fifty-odd CSS and HTML features that actually vary, across Gmail (web, iOS, Android), Outlook (Windows and Outlook.com), Apple Mail, iOS Mail and Yahoo. Using flexbox, `max-width`, background images, `@media`, `:hover`, `