My agents keep needing to read email. Signup codes, verification links, password resets: every automated workflow that touches a third-party service eventually hits an inbox. There are hosted APIs for this, and they work fine until you think about what's actually in those emails. In a signup flow, the message with the verification code is the account credential. Every one of them passes through someone else's servers.
So I built openagent.email: a self-hosted mail server where every agent gets its own mailbox on a domain you own. It runs as two containers, a catch-all Postfix/Dovecot mailbox (docker-mailserver) and a small Node/Hono API, with an MCP server on top so any MCP client (Claude Code, Cursor, Windsurf...) can use it. The whole stack idles at about 190 MB of RAM on my production instance, and it has 268 automated tests.
This post is about the design decisions that make it work, because most of them are not the obvious ones.
One real mailbox, unlimited logical identities
The naive design is one mailbox per agent. Provisioning mailboxes in Dovecot per agent is slow, stateful, and pointless. Agents don't need IMAP folders; they need an address and a way to read what lands on it.
So there's exactly one real mailbox: a catch-all. Postfix aliases @yourdomain to a single account, so anything@yourdomain delivers to the same place. An "identity" is just a logical address plus a token. Creating one is a POST that takes milliseconds and touches no mail server config.
This makes identity creation free, which changes how you use the system. Every test run, every signup flow, every throwaway experiment gets a fresh address. Identities are cheap enough to be single-use.
But it creates the real problem: if all mail lands in one mailbox, how does an identity see only its own mail?
Exact-address matching is the entire security boundary
When the API reads mail for an identity, it filters the catch-all by recipient headers: the envelope To/Cc/Bcc plus the Delivered-To header that Postfix stamps on every message. The match is exact string equality on the full address. Never a substring.
That sounds paranoid until you look at what substring matching does. Say agent A owns k7d2@d and agent B owns fox-k7d2@d. A substring match lets A read B's mail. Worse: Postfix stamps Delivered-To: agent@d (the catch-all account) on every message, so a liberal match on that header means reading the entire mailbox. The address comparison is the only thing standing between "your mail" and "everyone's mail", so it can't be approximate.
A few smaller traps fell out of the same code path:
- Display names don't count. In
Name <mailbox>, only the address inside the brackets grants access, because a display name can itself be crafted to look like an address. The parser also strips RFC 5322 comments with a bounded loop, since unbounded parsing of hostile headers is its own DoS. - The header trust list is explicit. There's a whitelist of headers that may grant read access (
delivered-to,x-original-to,envelope-to,x-forwarded-to,to,cc,bcc), and the fetcher currently only requestsdelivered-toon top of the envelope. The whitelist exists so that widening the fetch later doesn't silently widen the trust boundary.Subjectand randomX-*headers never grant anything. - Sender-supplied dates are not trusted for ordering. Spam loves future-dated messages, which would pin a fake message to the top of "latest" forever and break
wait_for. Sorting uses the server'sINTERNALDATEfirst. - No
+taghandling. Identity localparts don't allow+at all, and matching is exact, sofox-k7d2+signup@dis simply invisible tofox-k7d2@d. I'd rather have an explicit blind spot than a clever aliasing rule that becomes an attack surface.
Scoped tokens, stored as hashes
Each identity gets one token: oa_ plus 24 random bytes, base64url, 192 bits of entropy. The server stores only the SHA-256 hex; the plaintext is returned exactly once, at creation or rotation.
Three details worth copying:
- Lookup is constant-time (
timingSafeEqual). Token comparison is a dumb place to leak timing, but it's also a one-liner to do right. - Scope is enforced at the router. An identity token can read, wait on, and send from its own address only. Every route calls the same
forbidUnlessAddress()guard. The MCP server adds no permissions of its own; it's a thin stdio wrapper over the REST API, and all authorization lives server-side. - Revocation is rotation. There's no token expiry and no per-token revoke endpoint. Rotating immediately kills the old one. One identity, one live token, nothing to track.
The token store itself is boring and paranoid: a JSON file in a 0700 directory, 0600 permissions, written via tmp-file plus atomic rename. If it's corrupt, the code fails closed. It throws instead of "helpfully" treating the store as empty, which would risk overwriting everyone's tokens.
OTP extraction: small rules, sharp edges
The feature agents actually love is that messages come back with one-time codes and verification links already parsed. The extractor is a pure function with no I/O, which is why it has 44 test cases and I trust it.
The rules are simple; the edges are not:
- Codes are 4-8 digits, but only if a keyword (
code,verification,otp,passcode, plus the Chinese and Japanese equivalents) appears within an 80-character window around them. - Things that look like years (
19xx/20xx) need a stronger keyword in the window. Otherwise every "© 2026" footer becomes an OTP. - HTML entities are decoded first, and out-of-range entities like
�are dropped rather than "recovered" into a fake 8-digit code. - Verification links prefer
<a>tags: anchor text like "Verify your email" can qualify an otherwise neutral URL. The intent regex (verif|confirm|activate|reset|signin|login) can hit either the URL or the anchor text. - When converting HTML to text, inline tags vanish without adding spaces, because senders love splitting codes across spans:
G-<span>77</span><span>4102</span>must read asG-774102.
wait_for: IMAP IDLE with a 3-second conscience
The call that makes automated signups practical is wait_for: the agent says "tell me when mail arrives for this address" and blocks. Implementation-wise it's a long-lived IMAP connection doing:
loop:
re-check the mailbox (latest 20 matching, then from/subject filters)
if no match: race(client.idle(), sleep(3000))
Enter fullscreen mode Exit fullscreen mode
IDLE gives instant wakeup when Dovecot sees new mail; the 3-second heartbeat covers the case where the IDLE event is silently lost. If the IDLE connection itself dies, the code degrades to plain 3-second polling on one-shot connections.
One capacity decision hides here. Every wait pins an IMAP connection for up to 10 minutes, and all identities share one Dovecot account. Dovecot's default per-IP connection limit is 10, so waits are capped at 3 per address, 8 globally, deliberately below 10 to leave headroom for the short-lived reads. Excess waits get a 429 with retryAfterSec: 5, which the MCP client surfaces as a clean retry hint instead of a mystery failure.
The human path: sanitize once, isolate twice
Agents get the raw HTML. They're parsers, and sanitizing for them would only destroy information (the OTP extractor already ran). The danger is the other path: the /ui dashboard, where a human opens the same message in a browser.
The intuition "sanitize the HTML twice" is wrong, and it's not what the code does. It does one thing three ways:
- Sanitize once, with a strict whitelist (sanitize-html, version pinned exactly): text-structure tags only, all attributes stripped except numeric
colspan/rowspanon table cells, zero allowed URL schemes,script/style/iframe/svg/templatedropped with their contents, and a hard 512 KB cap. - Render inside a
sandboxiframe with no permissions at all. - Serve the frame with a hostile CSP:
default-src 'none'; script-src 'none'; img-src 'none'. Even if the sanitizer misses something, there's no script execution and no network egress.
Also worth noting: the upgrade path for that sanitizer is "bump it in its own PR and re-run the poison-message corpus." Security dependencies that parse hostile input don't get ride-along upgrades.
The boring ops that make it self-hostable
A few things that don't demo well but matter when it's your box:
- A retention sweeper runs every 6 hours, deleting messages older than 30 days (configurable) via IMAP
\Deletedplus expunge. First run waits 60 seconds for Dovecot to finish starting; errors are logged, never fatal. - Send rate limits: 20 messages per identity per rolling hour, in memory. The nice touch is that failures that never reached the mail server refund the quota, but a server-acknowledged rejection doesn't. A flaky network can't be turned into unlimited retries. And SMTP error details stay in server logs, because SMTP errors can echo credentials.
- The API binds localhost by default. Remote access means SSH tunnel or your own TLS reverse proxy. It refuses to start on invalid env config instead of "warning and continuing".
- Releases are SLSA-provenanced. Tagging
v*runs the full test suite, checks that the tag,package.json, andserver.jsonversions all agree, then publishes to npm with--provenancevia OIDC trusted publishing (no long-lived npm token anywhere) and to the official MCP registry in the same workflow.
Numbers
- Two containers: docker-mailserver plus the API (which also serves the dashboard and the MCP-facing REST).
- About 190 MB RAM idle on my production instance, ~0% CPU. ClamAV and SpamAssassin are off by default; ClamAV alone wants ~1 GB.
- 268 tests (259 API + 9 MCP), with the heaviest coverage on OTP extraction and address matching, the two places a bug becomes a credential leak.
- Four commands to install: clone,
cp .env.example .env, set your domain,docker compose up.
Try it
The project is Apache-2.0: github.com/openagentemail/openagentemail. The MCP server is npx -y @openagentemail/mcp against any running instance. Point your domain's MX at a $5 VPS and every agent you run gets a real inbox on your own domain.
If you're building agents that sign up for things, I'd genuinely like to hear how the wait-for flow works for you. Issues and PRs welcome.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.