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", app=mcp_manager.handle_request))
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_SSL(cfg.host, cfg.port)
else:
conn = imaplib.IMAP4(cfg.host, cfg.port)
if cfg.tls == "starttls":
conn.starttls()
conn.login(cfg.username, cfg.password)
try:
yield conn
finally:
try:
conn.logout()
except Exception:
passEvery single IMAP function — list_folders, list_messages, search_messages, fetch_message, delete_message, mark_seen — wraps its work in a fresh with _connect(cfg) as conn: block. No pooling, no keep-alive, no background sync thread reading your mail in the background whether you asked or not. 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 database model anywhere in this codebase. No SQLite file, no Redis, no message table. Kill the container mid-request and there’s nothing to recover, because there was never anything written down to recover. Restart it and you’re exactly where you were, because the state was never anywhere but the actual mail server the whole time. That’s not a compliance checkbox I bolted on for the blog post — it’s the entire architecture. Zero bytes stored isn’t a marketing line, it’s what you get when you refuse to write a persistence layer.
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:
- "long-random-token-1"
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 both a URL path segment and an MCP tool-prefix), 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. The mailbox you’re targeting on any given call is selected by that same name, or by the account’s actual email address — both the REST path parameter and the MCP mailbox argument resolve either one, so you don’t have to remember whether you called it “personal” or “gmail” six months ago.
The HTTP API
Ten routes, all JSON in, JSON out, errors shaped like {"detail": "..."}:
GET /health— always open, no bearer required, point your liveness probe at it and move on.GET /mailboxes— list configured mailboxes and which protocols each one has.GET /inbox— the one you actually want. Fans out across every IMAP-enabled mailbox in parallel via aThreadPoolExecutor, 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 anerrorsarray 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 IMAPsearchparam if you want to hand it something likeUNSEENdirectly.GET /mailboxes/{name}/search— the same structured filters as/inbox(from/to/subject/body/text/since/before/flags/size), scoped to one account.GET /mailboxes/{name}/messages/{uid}— one message, fully decoded, with an optionalreader=true.DELETE /mailboxes/{name}/messages/{uid}—Deletedflag plusEXPUNGE. Gone. No trash folder, no undo.POST /mailboxes/{name}/messages/{uid}/seen— flip theSeenflag on or off.POST /mailboxes/{name}/send— SMTP send,multipart/alternativeif you give it both a text and an HTML body.
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 (a classic spam-filter tripwire), and sets the User-Agent to a Thunderbird string, because unknown or missing User-Agent headers are themselves a mild negative signal to provider spam heuristics. Petty, but it works, and it’s the kind of detail you only get right if you’ve actually had mail bounce because of it.
One Bearer, No Second Auth System to Learn
Auth is a list of tokens in the YAML file. Empty list, no auth — everything 200s, your problem. Non-empty list, 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.
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 nine 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.list_folderslist_messagessearchget_messagedelete_messagemark_seensend
Nine tools. Not nine per mailbox — nine 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 IMAP-only tools simply don’t get registered if nothing in your config has IMAP configured (same for SMTP). 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 nine tools, 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.
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-05-17T00: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 today’s UTC midnight, and it’s wired into every make pkg-* target (pkg-add, pkg-update, pkg-upgrade, pkg-lock) so the gate re-anchors itself to “now” automatically every single time a dependency actually changes. 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 full read/send/delete control over as many mailboxes as you own, over one port, behind one bearer token, with zero bytes of your actual mail ever touching its disk. Kill it, restart it, move it to a different host — there’s nothing to migrate because there was never anything stored.
Grab it from github.com/psyb0t/docker-mailbox or pull psyb0t/mailbox straight off Docker Hub. WTFPL license — do what the fuck you want with it. Point it at your own mail and never think about it again.