Local auth testing without the flaky inbox dance
Postwire catches email OTPs, magic links, password resets, and SMS 2FA codes for local development, Playwright tests, CI pipelines, and AI agents. One tiny Rust binary — long-polling waits, Twilio webhooks, and structured signal extraction.
- SMTP mail catcher + SMS catcher
- OTP and magic-link extraction
- 14 native MCP tools for agents
$ docker run -d --name postwire \ -p 1025:1025 -p 8025:8025 yoosuf/postwire:latest $ curl "http://localhost:8025/api/[email protected]&timeout_ms=10000" { "id": "msg_01J6XYZ", "subject": "Confirm your account", "codes": ["849201"] } $ curl -X POST http://localhost:8025/api/sms/webhook \ -d "From=%2B15550199&To=%2B15550100&Body=Your code is 839201" ✓ sms_01A2B3 captured in 3ms
Why auth tests are so fragile
Your test suite signs up a user. The app sends an email verification code, then a text-message 2FA code. The whole thing should take seconds, but it often turns into waits, retries, inbox scraping, and one very suspicious timeout.
-
Cloud sandboxes add coupling
Mailtrap, SendGrid, and phone-verification SaaS reduce the blast radius, but your local suite still depends on the network, API keys, account state, and a pricing tier.
-
Classic catchers are email-only
MailHog and Mailpit are great SMTP catchers, but they stop at email. Modern auth testing needs SMS, OTP extraction, magic links, and clean wait APIs too.
-
Agents hit a wall at OTPs
An autonomous agent can't casually read a phone or browse webmail like a patient human. It needs a native, structured channel for email and SMS.
Everything you need, nothing you don't
Four capabilities that make Postwire feel boring in the best developer-product sense: predictable, local, scriptable, and easy to delete from your mental stack.
Long-polling wait API
/api/wait and /api/sms/wait hold the request open until the matching message arrives. Use since timestamps so fast delivery never slips through the gap.
Signal extraction
Pull OTP codes, email verification links, magic login links, and reset URLs out of email bodies and SMS text. Structured JSON — no regex, no MIME debugging.
SMS + Twilio webhooks
Catch texts via JSON or a native Twilio webhook parser. Full SMS 2FA flows locally — no phone, no SMS gateway in your test path.
Email analysis
Run an 11-factor HTML email compatibility check plus a SpamAssassin-style score. Assert template quality before a broken transactional email ships.
How it works
One binary, two channels, one long-polling engine. Point your app at the listeners — your tests and agents just wait and read.
Your app
-
SMTP · :1025email triggers -
/api/sms/webhookTwilio form or JSON -
POST /api/smsdirect REST push
Postwire
- Email store MIME & HTML · auto signal extraction
- SMS store JSON & Twilio · auto signal extraction
Tests & agents
-
14 MCP toolsnative stdio server -
/api/waitand /api/sms/wait -
Web UI · :8025dual-tab dashboard
- SMTP 0421 · received
- extract → OTP 812904
- analysis · spam 0.02
- delivered → agent 7712
- sms 3c9d · received
- mailbox idle · 0 waiting
Running locally in under 30 seconds
Same binary, several install paths. Start with Docker, Homebrew, Cargo, or the one-liner and point your app at localhost:1025 .
docker run -d --name postwire \ -p 1025:1025 -p 8025:8025 \ yoosuf/postwire:latest
Pulls the image from Docker Hub and starts in one shot — SMTP on 1025 , dashboard on 8025 .
brew tap yoosuf/tap
brew install postwire Runs as a service with brew services start postwire — no container runtime needed.
curl -fsSL https://raw.githubusercontent.com/\ yoosuf/postwire/main/install.sh | sh
Installs the server and MCP binary to ~/.local/bin — no root, no dependencies.
cargo install --git \ https://github.com/yoosuf/postwire \ postwire-server postwire-mcp
Builds postwire-server and postwire-mcp from source; requires a Rust toolchain.
Built for AI agents
A native Model Context Protocol (MCP) server exposes the whole local inbox as 14 tools — 7 for email, 7 for SMS. An agent can complete signup, email verification, password reset, and SMS 2FA without touching a browser inbox.
{ "mcpServers": { "postwire": { "command": "postwire-mcp", "env": { "POSTWIRE_URL": "http://localhost:8025" } } } }
Fourteen tools across email and SMS, grouped by what an agent actually needs to do. An agent signs up, waits for the confirmation email, pulls the code or link out of it, reads the second factor, and keeps going — no inbox to click through.
-
wait
waitwait_for_emailandwait_for_smsblock until a matching message lands on either channel and hand it straight back. They resolve the moment the message arrives rather than on a poll interval, and give up on timeout instead of leaving the agent hanging. -
extract
extractextract_signalsandextract_sms_signalspull verification codes and links out of the raw body, so nobody has to write a parsing regex. They recognise 4–8 digit numeric and alphanumeric OTPs, magic login links, password reset URLs and confirmation triggers. -
read
readlist_emails,get_email,list_smsandget_smsare for taking inventory before you wait. The list calls return headers and metadata without dragging the whole HTML body along; the get calls fetch one message when you actually need it. -
clear
clearsend_test_emailandsend_test_smsput a message into the local inbox, so you can drive a two-factor fixture from both ends.delete_email,delete_sms,clear_inboxandclear_sms_inboxreset either channel on its own between runs.
One prompt, a full multi-factor flow
In your test suite
Deterministic, race-free, and fast. If delivery fails, the test fails on a clean timeout — it never hangs and never waits on someone else's API.
const since = new Date().toISOString(); // capture BEFORE triggering await fetch("http://localhost:3000/api/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "[email protected]", password: "..." }), }); const email = await (await fetch( `http://localhost:8025/api/[email protected]&since=${since}&timeout_ms=10000` )).json(); const { codes } = await (await fetch( `http://localhost:8025/api/messages/${email.id}/extract` )).json(); await fetch("http://localhost:3000/api/verify-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "[email protected]", code: codes[0] }), }); console.log("verified with code", codes[0]);
Postwire vs. MailHog, Mailpit, and Mailtrap
Honest comparison. MailHog and Mailpit are still useful. Mailtrap is polished. Postwire is built for the local auth-testing jobs they were never quite designed to own.
| Feature | Postwire | MailHog | Mailpit | Mailtrap |
|---|---|---|---|---|
| Email catching | ||||
| SMS catching + Twilio webhooks | ||||
Long-polling wait API ( /api/wait , /api/sms/wait ) | ||||
| Signal extraction (OTP / magic links) | ||||
| Litmus-style email analysis | ||||
| 14 MCP tools for AI agents | ||||
| Single static binary (<15 MB) | ||||
| Free & open source (MIT) |
Developer guide
Plain HTTP with JSON responses on localhost:8025 . No SDK, no auth, no API keys — just curl, or whatever your test suite already speaks.
8 endpoints
No endpoint matches that. Try “sms”, “wait” or “extract”.
/api/wait long-poll Wait for an email matching the filters, then return it.
/api/messages/:id fetch Fetch one stored email by ID, including the raw HTML body.
/api/messages/:id/extract signals Pull verification codes, magic links and reset URLs into structured JSON.
/api/messages/:id/analysis CI assertions Compatibility check plus a spam score, stable enough to assert against in CI.
/api/sms ingest Push a structured SMS from a test driver or notification service.
/api/sms/webhook Twilio Point your Twilio client here; form payloads are parsed automatically.
/api/sms/wait long-poll Long-poll for an SMS to a number, with the same filters as email.
/api/sms/:id/extract signals Extract the OTP and any embedded URLs from an SMS body.
Conventions, request samples and parameters
Conventions
-
Long-polling waits
/api/waitand/api/sms/waithold the request open and resolve the instant a matching message lands. On timeout they return a clean408— no hangs, no polling loops. -
Race-free
sinceBoth wait endpoints accept an ISO-8601
sincetimestamp. Capture it before triggering the action, and a message that lands before your fetch connects still gets returned (received_at >= since). -
Error codes
400for invalid parameters,404for unknown message IDs,408on wait timeout.
GET /api/wait long-poll
curl "http://localhost:8025/api/[email protected]&subject=Verify&since=2026-09-04T10:00:00Z&timeout_ms=15000"
| Query param | Type | Required | Description |
|---|---|---|---|
to | string | no | Filter by recipient address. |
subject | string | no | Filter by subject substring. |
since | ISO-8601 | no | Only messages with received_at >= since . |
timeout_ms | int | no | Max wait before returning 408 . |
Response — the full message payload
{ "id": "msg_01J6XYZ", "from": "[email protected]", "to": "[email protected]", "subject": "Confirm your account", "text": "Your verification code is 849201", "html": "<html><body>...</body></html>", "received_at": "2026-09-04T10:00:01.123Z" }
GET /api/messages/:id/extract signals
curl "http://localhost:8025/api/messages/msg_01J6XYZ/extract" { "codes": ["849201"], "links": ["http://localhost:3000/auth/verify?token=d8f1e09a8b2c4d5e"], "action_urls": ["http://localhost:3000/auth/verify?token=d8f1e09a8b2c4d5e"] }
The other six follow the same shape. Mail: /api/messages/:id fetches one message, /api/messages/:id/analysis returns a spam score you can assert on, and POST /api/messages/bulk-delete with PATCH /api/messages/bulk-read reset the inbox between runs. SMS: POST /api/sms, POST /api/sms/webhook, GET /api/sms/wait and GET /api/sms/:id/extract, plus the matching bulk endpoints. Every path, parameter and response is in the API reference.
-
<15 MB
Single binary
-
<5 ms
Startup time
-
2
Channels: email + SMS
-
14
MCP tools for AI agents
Frequently asked questions
Does Postwire need Docker, Mailtrap, or a cloud account?
Neither. It's a single static binary with embedded SQLite and an embedded web UI. Docker is one convenient install option among several — Homebrew, one-liners, and Cargo all work too.
Is Postwire a MailHog or Mailpit alternative?
Yes, if you want a local SMTP mail catcher with a sharper testing API. Postwire catches email like those tools, but also adds SMS catching, Twilio webhook parsing, OTP and magic-link extraction, race-free long-polling waits, email analysis, and MCP tools for agents.
How does SMS catching actually work?
Two ways: POST structured JSON to /api/sms , or point your app's Twilio client at /api/sms/webhook — Postwire parses standard form payloads (From/To/Body) automatically. Then long-poll /api/sms/wait and extract OTPs via /api/sms/:id/extract .
Can AI agents really use this?
Yes. The postwire-mcp server exposes 7 email and 7 SMS tools over Model Context Protocol. Configure it once in Claude Desktop, Cursor, Windsurf, or Claude Code, and agents can wait, extract, and assert — all natively.
Is it safe to run in CI?
Yes. Pass --memory for an in-memory database, long-poll with a timeout (clean 408 on miss), and use the bulk read/delete endpoints to reset between tests. No temp files left on disk.
Will it accidentally send real emails or relay spam?
No. It's a dead-end local SMTP listener with no auth, no relaying, and no external calls. That's exactly what makes it safe for test environments.
Make auth testing boring again.
Free and MIT-licensed. One command gets you an inbox dashboard and an API on localhost. Point your app or your agent at it, and the inbox stops being the part that flakes.
Let's build something that scales.
Planning an AI product, automating a workflow, or taking a platform to production? I'd love to hear what you're building.