mailbox: Your Inboxes as an HTTP API and MCP Server That Stores Zero Bytes

I have five email accounts. A personal Gmail I’ve had since I was a teenager, a work address, a “burner” I use for signups I don’t trust, a domain address for this blog, and one more I forgot existed until I found the password in a text file. Five inboxes, five sets of credentials, five tabs, five unread badges lying to me about how bad my life is. Every “unified inbox” app on the planet wants to fix this for me by taking custody of all five accounts, syncing them into their own cloud, running “AI-powered smart categorization” on my mail, and handing me a slick UI in exchange for the one thing I actually have that’s worth protecting: the contents of my inbox.

No. Fuck that. My email has bank statements, 2FA recovery codes, medical shit, contracts, and enough personally identifying information to ruin a decade of my life if it leaks. I am not shipping that to some VC-funded “inbox zero” startup so their intern’s SQL injection can leak it in eighteen months. So I built mailbox: one container, one YAML file, one HTTP API and one MCP server on the same port, zero bytes of my mail stored anywhere but the actual mail servers where it already lives.

How Every Unified Inbox Plays You

Every “unified inbox” product follows the same playbook, and it’s the same playbook every SaaS company runs on everything:

  • Step one: get you to hand over IMAP/OAuth creds for every account you own. That’s the whole business model: the aggregation is the product.
  • Step two: sync everything into their database. Now there’s a second copy of your entire mail history sitting on infrastructure you don’t control and can’t audit.
  • Step three: “enrich” it. AI summarization, smart categorization, “priority inbox” scoring: all of which means running your private correspondence through a model you didn’t choose, hosted somewhere you can’t see.
  • Step four: get breached. Not “if,” “when.” Every centralized mail aggregator is a single, gorgeous, high-value target. One leak and every account you connected is compromised at once, not staggered across five separate providers with five separate security teams, but all at once, in one dump, on one forum post.

And even setting the breach risk aside, there’s a simpler objection: why does a tool that reads my email need a database at all? IMAP already is the database. The mail server already stores the mail, indexes it, and serves search results. Bolting a second copy on top doesn’t make anything faster or safer, it just makes a second thing that can leak, drift out of sync, or get subpoenaed.

So I didn’t build a sync engine. I built a stateless proxy that opens a connection, does the one thing you asked for, and hangs up.

Connect, Do the Job, Die

mailbox (the daemon is called mailboxd) is a FastAPI app, stdlib imaplib and smtplib underneath, that turns however many IMAP/SMTP accounts you throw at it into one HTTP API and one MCP server, riding the exact same port. The MCP transport is mounted right into the same FastAPI app instance as a sub-route:

app.router.routes.append(Mount(_MCP_PATH, app=mcp_manager.handle_request))
app.add_middleware(_MCPSlashNormalizer)
app.add_middleware(_BearerASGI, cfg=cfg)

Same process, same port, same bearer-auth gate covering both. There’s no second daemon, no second port to firewall, no stdio bridge running as a child process that could crash independently of the HTTP layer. One uvicorn worker pool serves REST requests and MCP streamable-HTTP sessions off the same socket.

The part I actually care about is the storage model, which is: there isn’t one. Every IMAP call goes through a context manager that opens a fresh connection, logs in, does exactly the operation you asked for, and tears the connection down in a finally block whether it succeeded or not:

@contextmanager
def _connect(cfg: ImapConfig) -> Iterator[imaplib.IMAP4]:
    if cfg.tls == "ssl":
        conn: imaplib.IMAP4 = imaplib.IMAP4_SSL(
            cfg.host,
            cfg.port,
            ssl_context=ssl.create_default_context(),
            timeout=_SOCKET_TIMEOUT_SECONDS,
        )
    else:
        conn = imaplib.IMAP4(cfg.host, cfg.port, timeout=_SOCKET_TIMEOUT_SECONDS)
    try:
        if cfg.tls == "starttls":
            conn.starttls(ssl_context=ssl.create_default_context())
        try:
            conn.login(cfg.username, cfg.password)
        except imaplib.IMAP4.error as e:
            raise ImapError(f"login failed: {e}") from e
        yield conn
    finally:
        try:
            conn.logout()
        except (imaplib.IMAP4.error, OSError):
            ...  # logged, then the socket gets shut down

Every single IMAP function: list_folders, list_messages, search_messages, fetch_message, fetch_raw, mark_seen: wraps its work in a fresh with _connect(cfg) as conn: block. Since v0.6.0 that context verifies the certificate chain and the hostname on both ssl and starttls, with no setting anywhere to turn it off, so a private mail server means mounting your CA bundle and pointing SSL_CERT_FILE at it rather than shrugging and accepting whatever shows up. Sockets give up after 30 seconds instead of hanging forever on a server that stopped answering. No pooling, no keep-alive, no background sync thread reading your mail whether you asked or not. There is an opt-in watcher now, mailboxd watch, which holds an IMAP IDLE open and POSTs HMAC-SHA256-signed webhooks when new mail lands: headers and spam verdicts only, never bodies. It’s a separate process behind a Compose profile, so it runs because you switched it on. SMTP does the same thing with a plain context manager around smtplib.SMTP_SSL or smtplib.SMTP: open, authenticate, send, close, every time /send gets hit.

There is no message table anywhere in this codebase. No Redis, no sync engine, no second copy of your mail. v0.6.0 did finally admit one SQLite file, at /var/lib/mailboxd/state.db, and it holds exactly the six things that cannot live on an IMAP server: the audit log, the rolling hourly send counters, the polling checkpoints, draft claims, watcher leases, and the webhook delivery queue. Six tables, and not one of them has a column for a message body. Mount a volume on its directory or you lose your rate limits and your audit trail on restart, and that is the entire blast radius. Your mail itself is still never written down: kill the container mid-fetch and there’s nothing of yours to recover, because the only copy was always on the mail server. That’s not a compliance checkbox I bolted on for the blog post: it’s the entire architecture. Zero bytes of your mail stored isn’t a marketing line, it’s what you get when you refuse to write a sync engine.

One YAML File, As Many Accounts As You Want

All five of my inboxes live in one config file, and each mailbox declares whichever combination of imap and smtp it actually needs: a read-only account can skip SMTP entirely, a send-only relay can skip IMAP:

auth:
  tokens:
    - name: agent
      token: "long-random-token-1"
      scopes: [read, draft, organize, send]
      mailboxes: "*"
mailboxes:
  - name: personal
    description: "Gmail"
    imap:
      host: imap.gmail.com
      port: 993
      tls: ssl
      username: [email protected]
      password: "app-password"
      default_folder: INBOX
    smtp:
      host: smtp.gmail.com
      port: 465
      tls: ssl
      username: [email protected]
      password: "app-password"
      from_address: "Me <[email protected]>"
  - name: work
    imap:  { host: mail.work.com, port: 143, tls: starttls, username: me, password: "...", default_folder: INBOX }
    smtp:  { host: mail.work.com, port: 587, tls: starttls, username: me, password: "...", from_address: [email protected] }

Pydantic validates the whole thing on load: name has to match [a-zA-Z0-9_-]+ and be unique (it becomes a URL path segment, and it’s the handle an agent passes as the MCP mailbox argument), every mailbox needs at least one of imap/smtp or the config load fails outright, and IMAP defaults to 993/ssl while SMTP defaults to 587/starttls unless your provider is weird about it. Any string value in there can be written ${SOME_VAR} and gets resolved from the environment at startup, with a missing variable as a hard startup error rather than an empty password, so the credentials never have to sit in the YAML at all. The mailbox you’re targeting on any given call is selected by that same name, or, over MCP and in the /inbox filter, by the account’s actual email address. The MCP mailbox argument resolves either one, so an agent doesn’t have to remember whether you called it “personal” or “gmail” six months ago.

The HTTP API

Thirty-one routes, all JSON in, JSON out, errors shaped like {"error": {"code", "message"}, "detail": "..."}: the error object is the one to match on, the flat detail is still there so nothing that parsed the old shape broke. These ten are the ones you’ll actually type:

  • GET /health: always open, no bearer required, point your liveness probe at it and move on.
  • GET /mailboxes: list the mailboxes your token can see, their addresses, their sender identities and which protocols each one has.
  • GET /inbox: the one you actually want. Fans out across every IMAP-enabled mailbox in parallel via a ThreadPoolExecutor, runs the same structured search on each, merges the results newest-first, and tags every message with which account it came from. One dead account lands in an errors array instead of blowing up the whole call: nine mailboxes up and one down still gets you the other nine.
  • GET /mailboxes/{name}/folders: list IMAP folders for one account.
  • GET /mailboxes/{name}/messages: newest-first headers, raw IMAP search param if you want to hand it something like UNSEEN directly.
  • GET /mailboxes/{name}/search: the same structured filters as /inbox (from/to/recipient/subject/body/text/since/before, the Seen/Flagged/Answered flags, size bounds, IMAP keywords, has_attachments and label), scoped to one account, with opaque cursor paging and a since_cursor that returns only what landed since your last call.
  • GET /mailboxes/{name}/messages/{uid}: one message, fully decoded, with an optional reader=true.
  • DELETE /mailboxes/{name}/messages/{uid}: moves the message to Trash, with UID MOVE where the server has it and COPY plus \Deleted plus UID EXPUNGE where it doesn’t. It used to flag and expunge in place, which was a great way for an agent to permanently delete your mail on the first tool call it got wrong. Real erasure is permanent=true, and it only works from inside Trash and only for a token holding the delete scope, so you have to mean it twice.
  • POST /mailboxes/{name}/messages/{uid}/seen: flip the Seen flag on or off.
  • POST /mailboxes/{name}/send: SMTP send, multipart/alternative if you give it both a text and an HTML body.

The other twenty-one landed with v0.6.0 and are mostly about writing rather than reading. Raw source and individual attachments as downloads (.../messages/{uid}/raw, .../attachments/{part_id}), conversation lookup over Message-ID and References (.../thread, .../threads), and server-derived replies at .../messages/{uid}/reply, where the recipients, the Re: subject, In-Reply-To and References are all worked out from the original, so a client cannot get threading wrong by handing them over itself. Then the five draft routes, which are the ones that matter if you let an agent anywhere near this: it writes the message, leaves it in your Drafts folder, and you decide. Plus batch seen and delete, labels, moves, folder creation, identities, per-folder stats, and four admin routes: deep health, the audit log, and the webhook delivery queue with a replay on it.

UIDs everywhere, never sequence numbers, so identifiers stay stable while the mailbox shifts around underneath you between calls. And the send endpoint isn’t lazy about it: it stamps a proper Date header, derives the Message-ID domain from the sender’s own address so it doesn’t mismatch the From: header, which is a classic spam-filter tripwire and the kind of detail you only get right if you’ve actually had mail bounce because of it. It used to forge a Thunderbird User-Agent on top of that, on the theory that a missing one reads as mildly suspicious to provider heuristics; v0.6.0 took that back out. What it does now instead is file the sent copy: on a successful send the message, Bcc intact, gets appended to your Sent folder, and if that append fails you get warnings: ["sent_copy_failed"] rather than a failed send you already made. Gmail files outgoing mail itself, so a Gmail mailbox wants save_sent: false or you’ll see it twice. And the sender you pass to /send is no longer free text: identity (or the legacy from_address) has to resolve to an identity configured for that mailbox, or the call comes back 422 invalid_identity, which is how an agent stops being able to send as whoever it feels like.

One Bearer, No Second Auth System to Learn

Auth is a list of tokens in the YAML file. Empty list, nobody gets in: since v0.5.0 an empty or missing token list fails closed and everything except /health 401s, so you can’t forget auth and ship an open mailbox. Otherwise every request except GET /health needs an Authorization: Bearer <token> header matching one of them, checked with hmac.compare_digest so a timing attack can’t fish out valid tokens character by character. Multiple tokens means you can rotate without downtime: add a new one, move your clients over, delete the old one, nobody notices.

Since v0.6.0 a token is a mapping rather than a bare string: {name, token, scopes, mailboxes, dry_run}, with six scopes to pick from: read, draft, organize, send, delete, admin. So the agent you let read your mail does not automatically get to send as you, a token listing two mailboxes gets “not found” for the other three instead of a forbidden that tells it they exist, and dry_run: true hands back a preview of the message instead of putting it on the wire. Every write, and every refused write, lands in the audit log under that token’s name, which is the actual reason tokens got names. A bare string still works and still grants every scope on every mailbox, and logs a warning at startup telling you to stop. On top of the scopes there’s a rolling hourly send cap, counted per mailbox and per token (send_guard.per_hour, 60 by default, overridable per mailbox with smtp.per_hour), answering 429 with a Retry-After. An agent stuck in a loop burns its own quota instead of your sender reputation.

The interesting bit is how it’s wired in. It’s a raw ASGI middleware, not FastAPI’s BaseHTTPMiddleware, on purpose:

class _BearerASGI:
    """Pure-ASGI bearer-auth wrapper around the whole app.
    Pure ASGI (not BaseHTTPMiddleware) so it doesn't buffer streaming
    responses — required for the MCP streamable-HTTP transport mounted
    under /mcp to keep working.
    """

BaseHTTPMiddleware buffers the response body to let you inspect/modify it, which is exactly what breaks a streamed SSE connection like the MCP transport needs. So the auth gate is hand-rolled at the ASGI layer, checks the raw scope headers before anything downstream even starts responding, and lets the MCP session stream through untouched once it’s authenticated. Same gate, same token list, same 401-with-WWW-Authenticate behavior, whether you’re hitting /inbox with curl or opening an MCP session at /mcp. One thing to configure, one thing to rotate, one thing that can lock you out if you fat-finger it.

The MCP Server: One Flat Tool Set, No Matter How Many Accounts

This is the part that turns “an email API” into “something an agent can actually drive.” /mcp exposes twenty-eight tools over the official MCP Python SDK’s streamable-HTTP transport: no stdio nonsense, no separate process:

  • mailboxes: discovery: list configured accounts and their capabilities.
  • inbox: the unified, cross-account read, same fan-out as the HTTP /inbox.
  • Reading one account: list_folders, list_messages, search, get_message, get_raw_message, get_attachment, get_thread, list_threads, identities, mailbox_stats.
  • Organising: mark_seen, set_labels, move_messages, create_folder, delete_message.
  • Writing: send, reply, create_draft, list_drafts, update_draft, delete_draft, send_draft.
  • Admin: audit_log, health_check, webhook_deliveries, replay_webhook.

Twenty-eight tools. Not twenty-eight per mailbox: twenty-eight total, forever, no matter whether you’ve got one account configured or a hundred. Every per-mailbox tool takes a mailbox argument that accepts either the config name or the actual email address, and tools/list only returns the tools the calling token’s scopes allow, so a read-only agent never sees send at all. It even strips arguments it would refuse: no permanent flag on delete_message without the delete scope, and reply arrives with draft pinned to true for a token that can’t send. No dead buttons, no tool named send_personal and send_work and send_burner bloating the model’s context window every single call. An agent calls mailboxes once, sees what’s available, and passes the name it wants as a parameter from then on. This is the same design philosophy I used for the Telegram-as-MCP setup in telethon-plus: expose your own account as a tool surface an agent can drive, instead of building a chatbot that has to ask you to paste things in. Same idea, different protocol: there it’s your Telegram account, here it’s your inbox.

There’s also a stdio↔HTTP bridge plugin for OpenClaw-style agent runtimes that only know how to talk to local stdio MCP servers: it shells out through mcp-remote to your running mailboxd instance and forwards the bearer token, so the constant-sized tool catalog shows up in agents that can’t natively speak streamable-HTTP. Same tool set, same auth, just a thinner pipe in front.

Reader Mode, Because Marketing Emails Are HTML Crime Scenes

Add reader=true to a message fetch and you get a body_reader field alongside the raw body_text/body_html: the HTML body run through html2text with images ignored and word-wrap disabled, styles/scripts/tracking pixels stripped, headings turned into markdown, tables flattened. Most transactional and marketing mail ships multipart/alternative where the plain-text part is a useless “view in HTML” stub and the real content only exists in the HTML part: reader mode extracts the words without dragging you through the <table><tr><td style="..."> soup that email marketers apparently think is still 2003. It’s additive: the original bodies still come back untouched, so a UI can render the HTML while an agent reads the markdown. v0.6.0 added the other half of that: safe_html=true returns body_html_safe, the same HTML put through an nh3 allow-list with remote images stripped, so a UI can render it without pinging the sender’s tracking server, plus an html_analysis block that flags text hidden from a human reader and links that lie: punycode hosts, URL shorteners, bare IP literals, javascript: targets, and a visible label whose domain doesn’t match the href behind it. Which, for the mail that turns up pretending to be your bank, is the part you actually want an agent to see.

The Supply-Chain Detail Nobody Else Bothers With

This is the part I actually want to brag about, because almost nobody does it. pyproject.toml pins a uv exclude-newer date:

[tool.uv]
exclude-newer = "2026-10-06T00:00:00Z"

Every dependency install refuses to pull any package version published after that timestamp, full stop. Why that matters: the modern supply-chain attack isn’t some elaborate nation-state operation, it’s a maintainer’s npm/PyPI token getting phished and a malicious point release going out at 3am before anyone notices. If your build just does uv sync against latest, you can pull that poisoned release within hours of publish. With a fixed exclude-newer, anything published after your cutoff is invisible to the resolver: a fresh 3am malicious release literally cannot be installed until the cutoff moves.

The catch with fixed cutoffs is normally that they rot into a liability: fine forever, until it’s stale and now you can’t get security patches either. So the cutoff isn’t hand-maintained: scripts/bump_exclude_newer.sh rewrites it to UTC midnight seven days before today, and it’s wired into every make pkg-* target that changes a dependency (pkg-add, pkg-remove, pkg-update, pkg-upgrade; a plain pkg-lock re-resolves against the cutoff it already has) so the gate re-anchors itself to “a week ago” automatically every single time a dependency actually changes, which means anything younger than seven days never gets in, no matter when you run it. You get the delay-window protection without ever having to remember to bump a date by hand.

Tests That Actually Put Bytes On a Socket

The SMTP path isn’t tested against a mock that just checks a function got called: tests/test_smtp_integration.py spins up a real hand-rolled SMTP server on a real TCP socket, using nothing but stdlib socketserver. It speaks actual SMTP: responds to HELO/EHLO, MAIL FROM, RCPT TO, the DATA terminator dance, RSET, NOOP, and QUIT, running on its own background thread. The test then drives the FastAPI app’s real /send endpoint through a TestClient, which calls the real smtplib client, which opens a real socket to that fake server and sends real SMTP protocol bytes down it. The test then decodes what actually landed with email.message_from_bytes and asserts on the parsed headers and envelope, including confirming that BCC recipients land in the SMTP envelope’s RCPT TO list but never leak into the To or Cc headers of the message body, which is the entire point of BCC and a thing that’s shockingly easy to get wrong in a hand-rolled mail sender.

Your Mail Stays Yours

I’m not trying to convince you that self-hosting your own IMAP proxy is what “normal people” should do. It isn’t. What I’m telling you is that if you’re the kind of person who’s already running your own AI infrastructure, and if you’ve read aigate, you probably are: there’s no reason your mail should be the one thing you still hand to a third party. This container gives your agents, your scripts, and your 3am curl pipelines as much read/send/delete control over as many mailboxes as you own as you choose to hand each token, over one port, with zero bytes of your actual mail ever touching its disk. Kill it, restart it, move it to a different host: the only thing worth carrying across is one small SQLite file of audit rows and counters, and your mail was never in it.

Installing It Into Your Agent

An agent that can read your mail should at least know the API without being told twice. Everything under .agents/ is catalogued in one marketplace, so it is two commands:

claude plugin marketplace add psyb0t/agents
claude plugin install mailbox@psyb0t

Codex uses the same marketplace with a different verb: codex plugin add mailbox@psyb0t, because there is no codex plugin install. It also finds the skill on its own in a checkout of the repo, since it scans .agents/skills/ natively with nothing installed at all. It is also listed on the official MCP Registry now, so a client that resolves servers from there can find it without being handed a URL.

Grab it from github.com/psyb0t/docker-mailbox or pull psyb0t/mailbox straight off Docker Hub. The source is WTFPL, do what the fuck you want with it. The published image is a different animal: it bundles html2text, which is GPL-3.0, so the image as a whole is a GPL combined work (THIRD_PARTY.md in the repo has the details). Point it at your own mail and never think about it again.