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, WhatsApp messages, chat posts and pushes 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 plugin in your vite.config.ts (postboi init adds it), the inbox is already there:
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:
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:
Just looking at it
--demo fills the inbox with sample mail — a styled HTML message, a FormData table with
an attachment, and a text-only body — so there’s something to look at without wiring an app
to it:
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 mail still there.
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 and 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, Discord, Teams, Telegram) and 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_ENVis exactlydevelopment. 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_INBOXtakes a whole URL as well as a bare port:
Turning it off
To send for real from your machine, pick whichever fits:
Reading the mail
The inbox lists messages newest-first and shows each one four ways:
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.
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:
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.rescheduleis 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 certain indestructible Nokia, no window frame at all, dragged around by its body like Winamp. Read with the navi key, scroll with the rocker, power it off from the button on its crown.
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:
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, which captures to sent with no server
involved:
The inbox stands in front of sending only.
mail.lists,mail.contactsand the other Postboi provider namespaces still talk to the real API in development — managing an audience isn’t something you’d want faked.