Multi-mailbox IMAP/SMTP control plane. One YAML, N accounts, one bearer-auth gate. Unified inbox ...
6.1K
Your inboxes, on tap. Point this thing at as many email accounts as you want over IMAP + SMTP, and out the other end you get one HTTP API and one MCP server, both on the same port (MCP rides a streamable-HTTP channel at /mcp) so you can read mail, send mail, and nuke mail across every account from one place. No webmail. No database. No three-thousand-toggle desktop app. Just: "here's some email creds" → "now my agent / shell script / chaotic 3am curl pipeline can drive the inbox."
Every other "unified inbox" thing on the planet wants to own your mail — slurp it all into their cloud, charge you forever, lose it in a breach next quarter. This one stores zero bytes. Restart the container, nothing's lost, because there was never anything to lose. Connections come up per request, do their job, and die in a finally.
Stdlib imaplib + smtplib under the hood, FastAPI on top, official MCP Python SDK riding shotgun (streamable HTTP, no stdio nonsense), a supply-chain exclude-newer gate so a malicious pip release published at 3am can't sneak in, and a real-SMTP-server integration test that actually puts bytes on a socket.
| Surface | The goods |
|---|---|
| HTTP API | GET /inbox fans out across every account at once (filter by mailbox, sender, subject, date, flags…). Per-mailbox reads + deletes. SMTP send. |
| MCP server | Streamable-HTTP MCP at /mcp — same port, same bearer, same boss. A flat set of tools (mailboxes, inbox, list_messages, send, …) that take mailbox as a parameter. 100 accounts? Still one tool catalog. |
| Bearer auth | One token list guards the API and /mcp. No tokens = no access. Multiple tokens let clients switch keys before you retire the old one. |
| Protocols | IMAP (SSL / STARTTLS / plain), SMTP (SSL / STARTTLS / plain). Standards-boring on purpose. |
| Config | One YAML file. Add a mailbox, restart, done. Each one declares whichever subset of {imap, smtp} you actually care about. |
| State | None. Truly none. No DB, no queue, no cache, no "oh just this little Redis." A connection opens, does the work, closes. Next. |
config.example.yaml to config.yaml. Add your mail credentials and replace the example bearer token with a fresh one from openssl rand -hex 32.docker run --rm \
-p 127.0.0.1:8000:8000 \
-v "$PWD/config.yaml:/etc/mailboxd/config.yaml:ro" \
psyb0t/mailbox:latest
# Use the bearer token you put in config.yaml.
TOKEN="paste-a-token-from-config-here"
curl -s http://localhost:8000/health | jq # health is always open
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/mailboxes | jq
curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8000/inbox?limit=5' | jq
services:
mailbox:
image: psyb0t/mailbox:latest
ports: ["127.0.0.1:8000:8000"]
volumes:
# config.yaml holds your IMAP/SMTP passwords AND your auth.tokens —
# gitignore it, lock down its filesystem perms, treat it like an SSH key.
- ./config.yaml:/etc/mailboxd/config.yaml:ro
The image explicitly listens on 0.0.0.0:8000 inside the container. Keep that internal listener so Docker port forwarding and other containers can reach it. The standalone mailboxd http CLI defaults to 127.0.0.1; the image's normal startup command overrides that default.
Publish host-local access with -p 127.0.0.1:8000:8000, or -p 127.0.0.1:9000:8000 for another local port. -p 8000:8000 publishes on all host interfaces. Use it only for deliberate network access, with bearer auth and HTTPS where appropriate.
If replacing the image command with mailboxd http, include --host 0.0.0.0. Container loopback prevents published ports from reaching the server. The checked-in Compose file currently publishes on all host interfaces; change its port entry to 127.0.0.1:${PORT:-8000}:8000 for host-local access.
One YAML file. Lives at MAILBOXD_CONFIG, or --config, or /etc/mailboxd/config.yaml if you can't be bothered.
log_level: INFO
# Bearer-token gate. Guards the HTTP API AND /mcp. Empty / missing denies access.
# For rotation, add a new token, restart, swap clients over, then retire the old one.
auth:
tokens:
- "long-random-token-1"
- "long-random-token-2"
mailboxes:
- name: personal # URL-safe handle; shows up in /mailboxes/<name>/... and as the MCP tool prefix
description: "Gmail"
imap:
host: imap.gmail.com
port: 993 # default 993
tls: ssl # ssl | starttls | none (default ssl)
username: [email protected]
password: "app-password" # Gmail/Yahoo/etc. need an app password, not your real one
default_folder: INBOX # default folder when callers don't specify one
smtp:
host: smtp.gmail.com
port: 465 # default 587
tls: ssl # default starttls
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] }
The fine print:
imap / smtp. Both is fine. Neither is a config error.name matches [a-zA-Z0-9_-]+ and is unique — it's the URL path segment and the MCP tool prefix, so don't put spaces or emojis in it.993/ssl, SMTP 587/starttls. Override if your provider is weird.chmod 600, mount read-only, don't paste it in Slack.Every request except GET /health has to carry a configured bearer:
Authorization: Bearer <one of auth.tokens>
No header, wrong shape, wrong value → 401 with WWW-Authenticate: Bearer. Tokens get a constant-time compare so you don't leak them through timing. The same gate covers /mcp — there's no second auth system to learn.
Leave auth.tokens empty or skip the block and every protected request gets 401. Only /health stays open. Add a token and restart before using the API or MCP.
Every configured token controls every configured mailbox. This is one operator's mail service, not tenant isolation. For separate users who must not share mailbox access, run separate instances with separate configs and tokens. Use HTTPS when crossing an untrusted network.
Everything's JSON. Errors look like {"detail": "..."}:
| Status | When |
|---|---|
401 | Missing or invalid bearer, or no server tokens configured. |
404 | Unknown mailbox name in the URL. |
409 | You're asking a mailbox for a protocol it doesn't have (IMAP endpoint on an SMTP-only one). |
502 | The IMAP / SMTP server upstream said no. |
GET /health{ "ok": true, "version": "0.5.1" }
Always open, no bearer required. Point your liveness probe at this and forget about it.
GET /mailboxes{
"mailboxes": [
{ "name": "personal", "description": "Gmail",
"imap": true, "smtp": true }
]
}
GET /inbox is the read endpoint you actually want 90% of the time. It hits every IMAP-configured mailbox in parallel, runs the same structured search on each one, merges newest-first, and tags every result with which account it came from. "Show me everything from [email protected]," "what's unread right now," "what came in this morning" — all the same call, no fanout dance on the client side.
| Query param | What it does |
|---|---|
mailbox | CSV filter by mailbox name (personal) or email address ([email protected]). Omit to search all of them. |
from, to, subject, body, text | IMAP SEARCH predicates. text is full-text across headers + body. |
since, before | IMAP dates, e.g. 1-Jan-2026. |
unseen, seen, flagged, answered | Flag filters. Set the ones you want to true. |
larger_than, smaller_than | Bytes. |
folder | IMAP folder (default INBOX). |
limit | Hard-capped at 500. Default 50. |
Response:
{
"messages": [
{
"uid": "1234",
"mailbox": "personal",
"mailbox_address": "[email protected]",
"from": "[email protected]",
"subject": "weekly sync",
"date": "..."
}
],
"errors": [
{ "mailbox": "work", "error": "login failed: ..." }
]
}
Per-mailbox blowups land in errors instead of killing the call. One dead account doesn't blind you to the other nine.
# everything from one sender, all accounts at once
curl -s -H "Authorization: Bearer $TOKEN" \
'http://localhost:8000/[email protected]&limit=20' | jq
# unread mail in just two of them
curl -s -H "Authorization: Bearer $TOKEN" \
'http://localhost:8000/inbox?mailbox=personal,work&unseen=true' | jq
When you want to zero in on one account:
| Method | Path | Does |
|---|---|---|
GET | /mailboxes/{name}/folders | List IMAP folders. |
GET | /mailboxes/{name}/messages?folder=&limit=&search= | Newest-first headers. search is raw IMAP (ALL, UNSEEN, FROM foo@bar). limit ≤ 500. |
GET | /mailboxes/{name}/search?from=&subject=&... | Same structured query as /inbox minus mailbox, scoped to one account. |
GET | /mailboxes/{name}/messages/{uid}?folder=&reader= | One message, fully decoded (body_text + body_html + attachment metadata). Add reader=true to also get body_reader — HTML stripped to clean text/markdown (no tables, styles, tracking pixels). |
DELETE | /mailboxes/{name}/messages/{uid}?folder= | \Deleted + EXPUNGE. Gone. Really gone. |
POST | /mailboxes/{name}/messages/{uid}/seen?folder= | Body `{"seen": true |
UIDs everywhere, never sequence numbers — identifiers stay stable when the mailbox shifts around under you.
?reader=true on GET /mailboxes/{name}/messages/{uid} (or reader=true on the MCP get_message tool) adds a body_reader field — the HTML body flattened into clean markdown. Built for LLMs and humans who don't want to read raw <table><tr><td style="…">…</td></tr></table> chrome.
How it works:
ignore_images=True, body_width=0 (no hard wrap), unicode_snob=True. Styles, scripts, comments, head, and <img> tags are dropped.#, bold/italic preserved, <a href="x">text</a> → [text](x) inline, lists/tables converted to markdown equivalents.body_reader falls back to the trimmed body_text.body_text and body_html are still returned alongside — body_reader is additive. UI clients can render HTML; agents can read markdown; nobody loses anything.Why not just use body_text? Most marketing/transactional mail ships multipart/alternative where the text/plain part is missing, a "view in HTML" stub, or auto-generated noise. The real content lives in the HTML part. Reader mode extracts it. Why not readability/trafilatura? Those are tuned to find the article inside a webpage full of navigation and ads. Emails ARE the content — the noise is styling, which is exactly what html2text strips. No DOM-extraction needed.
Reply-quote chains aren't stripped (you get the full thread), table-layout emails come through as pipe-tables (faithful but noisy), and attachments stay as metadata only.
| Method | Path | Does |
|---|---|---|
POST | /mailboxes/{name}/send | Send an email. |
Body:
{
"to": ["[email protected]"],
"cc": ["[email protected]"],
"bcc": ["[email protected]"],
"subject": "hi",
"body_text": "plain text body",
"body_html": "<p>optional html body</p>",
"from_address": "[email protected]",
"reply_to": "[email protected]"
}
At least one of body_text / body_html is required. Both = multipart/alternative like a respectable mail client. We also add a Thunderbird-shaped User-Agent, a domain-aligned Message-ID, and a Date header — provider spam filters get hostile when those are missing or sloppy, so we play the game.
Same operations as the HTTP API, exposed as MCP tools over streamable HTTP at /mcp (same port, same bearer). One flat tool set — every per-mailbox op takes mailbox as a parameter (name OR address), so 100 inboxes still ship the same handful of tools:
mailboxes # discovery: list configured mailboxes + capabilities
inbox # unified read across all IMAP mailboxes (mailbox= filter)
list_folders # (mailbox)
list_messages # (mailbox, folder, limit, search)
search # (mailbox, from, subject, since, ...)
get_message # (mailbox, uid, reader=true → +body_reader)
delete_message # (mailbox, uid)
mark_seen # (mailbox, uid, seen)
send # (mailbox, to, subject, body_text/html, ...)
An agent finds what's available via mailboxes, then passes the chosen name ("personal") or address ("[email protected]") as the mailbox arg. For cross-account queries use inbox — inbox(from="[email protected]") fans out across every IMAP-enabled mailbox at once.
IMAP-only tools only appear if at least one mailbox has IMAP. Same for SMTP. No dead buttons.
Plain old streamable-HTTP MCP at /mcp. The endpoint speaks the full transport: GET opens the SSE stream back to the client, POST ships requests, DELETE terminates the session. Whatever your MCP host knows how to do, do that. There is no stdio transport — make run is all you need; the same process serves the REST API and /mcp. Point your client at http://host:8000/mcp and you're done.
Most MCP hosts take a .mcp.json (or equivalent) like this:
{
"mcpServers": {
"mailbox": {
"transport": "streamable-http",
"url": "http://localhost:8000/mcp",
"headers": {
"Authorization": "Bearer YOUR_TOKEN_HERE"
}
}
}
}
Keep the headers block. The token must match an entry in auth.tokens.
The skill works in any agent that reads .agents/skills/ (such as peen), and installs natively in the clients below.
claude plugin marketplace add psyb0t/agents
claude plugin install mailbox@psyb0t
Claude Code prompts for the mailboxd URL and bearer token. The token is stored in your OS keychain.
codex plugin marketplace add psyb0t/agents
codex plugin add mailbox@psyb0t
Installed via the marketplace, Codex invokes the skill as $mailbox:docker-mailbox. Codex also picks the skill up automatically, no install required, in any repo containing .agents/skills/ — there it invokes as plain $docker-mailbox.
The skill is published to ClawHub on every release:
openclaw skills install @psyb0t/docker-mailbox
For MCP clients that speak local stdio, the @psyb0t/mailbox plugin bridges to mailboxd's /mcp endpoint:
openclaw plugins install clawhub:@psyb0t/mailbox
Then set MAILBOX_URL and MAILBOX_TOKEN to your server URL and configured bearer token.
┌────────────┐ ┌──────────────────────────┐ ┌──────────────┐
│ HTTP CLI │───▶│ FastAPI │ │ IMAP server │
│ / curl │ │ /inbox, /mailboxes, … │───▶│ SMTP server │
└────────────┘ │ │ └──────────────┘
┌────────────┐ │ bearer-auth gate │
│ MCP host │───▶│ ─── shared ops ─── │
│ (Claude…) │ │ │
└────────────┘ │ /mcp (streamable HTTP) │
│ mailboxes/inbox/send/… │
└──────────────────────────┘
│
▼
┌──────────────────────┐
│ config.yaml │
│ auth.tokens │
│ one entry per │
│ mailbox │
└──────────────────────┘
Stateless. No DB. No queue. No cache. Connections open per request and die in a finally. Kill the container mid-flight — there's nothing to recover because nothing was ever persisted. Boot it back up. Same story. Boring on purpose.
The CLI defaults to loopback; Docker and make run explicitly listen on 0.0.0.0 inside the container. make run publishes on all host interfaces. Use the host-local Docker example when that is not intended. Bearer auth remains required in every mode.
make help # list all targets
make dev-image # build the sandboxed dev container
make shell # drop into it
make run # boot the server (REST + /mcp); mount CONFIG=path/to/config.yaml
make test # full suite (unit + docker-in-docker integration)
make test-unit # in-process only, fast feedback loop
make lint # flake8 + mypy
make sec # semgrep + bandit + pip-audit -> sec.sarif (reports, never fails)
make format # isort + black
We use uv's exclude-newer to refuse package versions newer than a fixed cutoff. Dependency changes set that cutoff to UTC midnight seven days ago. pkg-lock keeps the existing cutoff.
make pkg-add PKG=foo==1.2.3
make pkg-remove PKG=foo
make pkg-update PKG=foo
make pkg-lock
make pkg-upgrade
Don't hand-edit [tool.uv].exclude-newer. Let the targets do it.
WTFPL — see LICENSE. Do what the fuck you want.
Content type
Image
Digest
sha256:ccdd06461…
Size
62.3 MB
Last updated
about 1 hour ago
docker pull psyb0t/mailbox