{"slug": "giving-every-ai-agent-its-own-mailbox-the-architecture-behind-a-self-hosted-mcp", "title": "Giving every AI agent its own mailbox: the architecture behind a self-hosted MCP mail server", "summary": "A developer built openagent.email, a self-hosted mail server that gives each AI agent its own mailbox on a user-owned domain. The architecture uses a single catch-all mailbox with logical identities, filtering mail by exact recipient header matching to prevent cross-agent access. The stack runs as two containers, idles at ~190 MB RAM, and includes 268 automated tests.", "body_md": "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.\n\nSo I built [openagent.email](https://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](https://github.com/docker-mailserver/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.\n\nThis post is about the design decisions that make it work, because most of them are not the obvious ones.\n\nThe 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.\n\nSo there's exactly one real mailbox: a catch-all. Postfix aliases `@yourdomain`\n\nto a single account, so `anything@yourdomain`\n\ndelivers 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.\n\nThis 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.\n\nBut it creates the real problem: if all mail lands in one mailbox, **how does an identity see only its own mail?**\n\nWhen the API reads mail for an identity, it filters the catch-all by recipient headers: the envelope `To`\n\n/`Cc`\n\n/`Bcc`\n\nplus the `Delivered-To`\n\nheader that Postfix stamps on every message. The match is exact string equality on the full address. Never a substring.\n\nThat sounds paranoid until you look at what substring matching does. Say agent A owns `k7d2@d`\n\nand agent B owns `fox-k7d2@d`\n\n. A substring match lets A read B's mail. Worse: Postfix stamps `Delivered-To: agent@d`\n\n(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.\n\nA few smaller traps fell out of the same code path:\n\n`Name <mailbox>`\n\n, 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.`delivered-to`\n\n, `x-original-to`\n\n, `envelope-to`\n\n, `x-forwarded-to`\n\n, `to`\n\n, `cc`\n\n, `bcc`\n\n), and the fetcher currently only requests `delivered-to`\n\non top of the envelope. The whitelist exists so that widening the fetch later doesn't silently widen the trust boundary. `Subject`\n\nand random `X-*`\n\nheaders never grant anything.`wait_for`\n\n. Sorting uses the server's `INTERNALDATE`\n\nfirst.`+tag`\n\nhandling. Identity localparts don't allow `+`\n\nat all, and matching is exact, so `fox-k7d2+signup@d`\n\nis simply invisible to `fox-k7d2@d`\n\n. I'd rather have an explicit blind spot than a clever aliasing rule that becomes an attack surface.Each identity gets one token: `oa_`\n\nplus 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.\n\nThree details worth copying:\n\n`timingSafeEqual`\n\n). Token comparison is a dumb place to leak timing, but it's also a one-liner to do right.`forbidUnlessAddress()`\n\nguard. 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.The token store itself is boring and paranoid: a JSON file in a `0700`\n\ndirectory, `0600`\n\npermissions, 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.\n\nThe 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.\n\nThe rules are simple; the edges are not:\n\n`code`\n\n, `verification`\n\n, `otp`\n\n, `passcode`\n\n, plus the Chinese and Japanese equivalents) appears within an 80-character window around them.`19xx`\n\n/`20xx`\n\n) need a `�`\n\nare dropped rather than \"recovered\" into a fake 8-digit code.`<a>`\n\ntags: anchor text like \"Verify your email\" can qualify an otherwise neutral URL. The intent regex (`verif|confirm|activate|reset|signin|login`\n\n) can hit either the URL or the anchor text.`G-<span>77</span><span>4102</span>`\n\nmust read as `G-774102`\n\n.The call that makes automated signups practical is `wait_for`\n\n: the agent says \"tell me when mail arrives for this address\" and blocks. Implementation-wise it's a long-lived IMAP connection doing:\n\n```\nloop:\n  re-check the mailbox (latest 20 matching, then from/subject filters)\n  if no match: race(client.idle(), sleep(3000))\n```\n\nIDLE 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.\n\nOne 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`\n\n, which the MCP client surfaces as a clean retry hint instead of a mystery failure.\n\nAgents 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`\n\ndashboard, where a human opens the same message in a browser.\n\nThe intuition \"sanitize the HTML twice\" is wrong, and it's not what the code does. It does one thing three ways:\n\n`colspan`\n\n/`rowspan`\n\non table cells, zero allowed URL schemes, `script/style/iframe/svg/template`\n\ndropped with their contents, and a hard 512 KB cap.`sandbox`\n\niframe with no permissions at all.`default-src 'none'; script-src 'none'; img-src 'none'`\n\n. 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.\n\nA few things that don't demo well but matter when it's your box:\n\n`\\Deleted`\n\nplus expunge. First run waits 60 seconds for Dovecot to finish starting; errors are logged, never fatal.`v*`\n\nruns the full test suite, checks that the tag, `package.json`\n\n, and `server.json`\n\nversions all agree, then publishes to npm with `--provenance`\n\nvia OIDC trusted publishing (no long-lived npm token anywhere) and to the official MCP registry in the same workflow.`cp .env.example .env`\n\n, set your domain, `docker compose up`\n\n.The project is Apache-2.0: ** github.com/openagentemail/openagentemail**. The MCP server is\n\n`npx -y @openagentemail/mcp`\n\nagainst 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.", "url": "https://wpnews.pro/news/giving-every-ai-agent-its-own-mailbox-the-architecture-behind-a-self-hosted-mcp", "canonical_source": "https://dev.to/tizer_luo/giving-every-ai-agent-its-own-mailbox-the-architecture-behind-a-self-hosted-mcp-mail-server-4d12", "published_at": "2026-07-30 17:58:47+00:00", "updated_at": "2026-07-30 18:04:28.656399+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-infrastructure", "ai-safety"], "entities": ["openagent.email", "Postfix", "Dovecot", "docker-mailserver", "Node", "Hono", "Claude Code", "Cursor"], "alternates": {"html": "https://wpnews.pro/news/giving-every-ai-agent-its-own-mailbox-the-architecture-behind-a-self-hosted-mcp", "markdown": "https://wpnews.pro/news/giving-every-ai-agent-its-own-mailbox-the-architecture-behind-a-self-hosted-mcp.md", "text": "https://wpnews.pro/news/giving-every-ai-agent-its-own-mailbox-the-architecture-behind-a-self-hosted-mcp.txt", "jsonld": "https://wpnews.pro/news/giving-every-ai-agent-its-own-mailbox-the-architecture-behind-a-self-hosted-mcp.jsonld"}}