How popterminal moves email from MX to your terminal in milliseconds
popterminal gives your laptop a real public email address with no DNS, no public IP, and no tunnel binary. Here’s the small pipeline that makes that work — Cloudflare Workers, Durable Objects, the WebSocket Hibernation API, and an offline-safe replay buffer.
popterminal is a deceptively small CLI that does something non-obvious: it gives your laptop a real public email address. Send something to dugong-3xy9@popterminal.com and within ~200ms it's sitting in a SQLite file on your hard drive, addressable by a popterminal wait command or an MCP tool call.
There's no public IP on your laptop. No port-25 deal-making with your ISP. No tunnel binary. Just npm install -g popterminal && popterminal start.
Here's how the pipeline is built.
The pipeline
sender → Cloudflare MX → Worker → Durable Object → WebSocket → laptop daemon → SQLite → CLI
Every hop here is doing something specific:
Cloudflare MX receives the mail. We're piggybacking on Email Routing's catch-all rule for the popterminal.com domain: any address at the domain forwards to our Worker's email() handler. No DNS gymnastics needed because Cloudflare already operates an MX cluster for any domain in their network.
The Worker parses the recipient address (<handle>+<tag>@...), looks up the handle in D1 to find which Durable Object owns it, and forwards the raw bytes to that DO via stub.fetch(). The Worker itself is stateless and disposable.
The Durable Object is where it gets interesting.
Durable Objects + WebSocket Hibernation
Each anonymous handle gets one Durable Object — its address-of-record on the platform. The DO holds two things: rate-limit counters and an ongoing WebSocket connection to the user's CLI.
The naive way to do this: the DO keeps the WebSocket open in memory, and Cloudflare charges us by the second for the duration. Bad economics — a daemon idle for hours costs the same as one actively receiving mail.
The right way: the WebSocket Hibernation API. We tell Cloudflare to "hibernate" the DO between messages, deserializing it on demand when mail arrives. Idle DOs cost essentially nothing. Active DOs spin up for a few milliseconds, push the message, hibernate again.
This is the key cost lever that makes the free tier viable. A user who never receives mail (or receives one per hour) is paying for the KV writes from their handle's rate-limit counter and not much else.
Offline-safe delivery
The user's CLI isn't always connected. They close their laptop, their WiFi flakes, they shut the daemon down.
If mail arrives during that window, we don't drop it. The DO writes the message into a per-handle buffer in D1, marks it pending, and holds it for up to 48 hours. When the CLI reconnects, the DO replays buffered messages over the WebSocket. Each message requires an explicit ack from the CLI before it's purged from the buffer — so even a CLI crash mid-message won't lose data.
We cap the buffer at 100 messages per handle to keep storage bounded. Past that, the oldest is dropped first. This is documented in our Terms.
SQLite-as-bus
Once a message reaches the CLI, it lands in a local SQLite database at ~/.popterminal/popterminal.db. WAL mode, write from the daemon process, read from CLI subprocess(es).
The CLI's wait and tail commands subscribe to file changes on the DB file using fs.watch plus a small polling fallback. When the daemon inserts a row, the watchers wake up, query for fresh rows since their cursor, and yield them to the caller.
This pattern — SQLite-as-bus — has a few nice properties:
- The daemon can crash and restart without losing messages
- Multiple CLI processes can independently subscribe to the same inbox (e.g.
popterminal tailin one terminal andpopterminal waitin another) - The MCP server can read from the exact same store without any inter-process coordination
The fastest read latency I've measured end-to-end (sender → CLI) is around 200ms. Slowest under "fair load" is more like 600ms. The bottleneck is Cloudflare's mail-handling pipeline, not anything in the popterminal code.
Why three surfaces in one install
popterminal@0.3.1 ships three bins: the CLI, an MCP stdio server, and a local web UI. They're not three separate packages — they all live in the same npm package and share the same core/storage.ts module.
This was deliberate. Every separate package is a separate version to manage, a separate npm install for the user, a separate set of upgrade paths to support. Three thin wrappers around one storage layer keeps the maintenance surface tiny.
The MCP server in particular was the highest-leverage addition. Once it shipped, Claude Desktop and Cursor users went from "I'd have to shell out to popterminal" to "popterminal is a first-class tool I can ask Claude to call." Closed the loop on the "for agents" positioning the project was always pointed at.
What's next
Roadmap items are deliberately driven by what the first users ask for, not what I assume they want. The likely next additions:
- Outbound email. Adding
popterminal sendso agents can reply to received mail. Two modes — local SMTP capture (for testing email-sending code) and relay through Resend / Mailgun / SendGrid. - Webhook capture. The same shape (real domain → Worker → DO → laptop), but for arbitrary HTTPS POSTs. OAuth callbacks and Stripe events become first-class popterminal primitives alongside email.
Try it:
$ npm install -g popterminal $ popterminal start
You'll have an address in seconds. Let me know what you wire it into.