{
  "markdown": "# mailbox\n\n[![CI](https://github.com/psyb0t/docker-mailbox/actions/workflows/pipeline.yml/badge.svg?branch=main)](https://github.com/psyb0t/docker-mailbox/actions/workflows/pipeline.yml)\n[![version](https://raw.githubusercontent.com/psyb0t/docker-mailbox/badges/version.svg)](https://github.com/psyb0t/docker-mailbox/releases)\n[![license](https://raw.githubusercontent.com/psyb0t/docker-mailbox/badges/license.svg)](LICENSE)\n[![Docker Pulls](https://img.shields.io/docker/pulls/psyb0t/mailbox?style=flat-square)](https://hub.docker.com/r/psyb0t/mailbox)\n\nYour 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.\"\n\nEvery 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`.\n\nStdlib `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.\n\n## Table of Contents\n\n- [What's Inside](#whats-inside)\n- [Quick Start](#quick-start)\n- [Configuration](#configuration)\n- [HTTP API](#http-api)\n  - [Authentication](#authentication)\n- [MCP server](#mcp-server)\n- [Agent integrations](#agent-integrations)\n- [Architecture](#architecture)\n- [Development](#development)\n- [License](#license)\n\n## What's Inside\n\n| Surface         | The goods                                                                                                                                       |\n| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |\n| **HTTP API**    | `GET /inbox` fans out across every account at once (filter by mailbox, sender, subject, date, flags…). Per-mailbox reads + deletes. SMTP send.   |\n| **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. |\n| **Bearer auth** | One token list in YAML guards both the API and `/mcp`. Empty list = wide open (your problem). Multiple tokens = zero-downtime rotation.          |\n| **Protocols**   | IMAP (SSL / STARTTLS / plain), SMTP (SSL / STARTTLS / plain). Standards-boring on purpose.                                                       |\n| **Config**      | One YAML file. Add a mailbox, restart, done. Each one declares whichever subset of `{imap, smtp}` you actually care about.                       |\n| **State**       | None. Truly none. No DB, no queue, no cache, no \"oh just this little Redis.\" A connection opens, does the work, closes. Next.                    |\n\n## Quick Start\n\n1. Drop a `config.yaml` next to you (steal `config.example.yaml` if you're feeling lazy — that's what it's there for).\n2. Light it up:\n\n```bash\ndocker run --rm \\\n  -p 8000:8000 \\\n  -v \"$PWD/config.yaml:/etc/mailboxd/config.yaml:ro\" \\\n  psyb0t/mailbox:latest\n```\n\n3. Poke it:\n\n```bash\n# no auth? this works as-is. with auth.tokens set, add: -H \"Authorization: Bearer YOUR_TOKEN\"\nTOKEN=\"paste-a-token-from-config-here\"\n\ncurl -s http://localhost:8000/health | jq                                               # health is always open\ncurl -s -H \"Authorization: Bearer $TOKEN\" http://localhost:8000/mailboxes | jq\ncurl -s -H \"Authorization: Bearer $TOKEN\" 'http://localhost:8000/inbox?limit=5' | jq\n```\n\n### docker compose\n\n```yaml\nservices:\n  mailbox:\n    image: psyb0t/mailbox:latest\n    ports: [\"8000:8000\"]\n    volumes:\n      # config.yaml holds your IMAP/SMTP passwords AND your auth.tokens —\n      # gitignore it, lock down its filesystem perms, treat it like an SSH key.\n      - ./config.yaml:/etc/mailboxd/config.yaml:ro\n```\n\n## Configuration\n\nOne YAML file. Lives at `MAILBOXD_CONFIG`, or `--config`, or `/etc/mailboxd/config.yaml` if you can't be bothered.\n\n```yaml\nlog_level: INFO\n\n# Bearer-token gate. Guards the HTTP API AND /mcp. Empty / missing = no auth\n# (good luck out there). Multi-token list = rotate without downtime: add a\n# new one, swap clients over, retire the old one.\nauth:\n  tokens:\n    - \"long-random-token-1\"\n    - \"long-random-token-2\"\n\nmailboxes:\n  - name: personal              # URL-safe handle; shows up in /mailboxes/<name>/... and as the MCP tool prefix\n    description: \"Gmail\"\n\n    imap:\n      host: imap.gmail.com\n      port: 993                 # default 993\n      tls: ssl                  # ssl | starttls | none   (default ssl)\n      username: me@gmail.com\n      password: \"app-password\"  # Gmail/Yahoo/etc. need an app password, not your real one\n      default_folder: INBOX     # default folder when callers don't specify one\n\n    smtp:\n      host: smtp.gmail.com\n      port: 465                 # default 587\n      tls: ssl                  # default starttls\n      username: me@gmail.com\n      password: \"app-password\"\n      from_address: \"Me <me@gmail.com>\"\n\n  - name: work\n    imap:  { host: mail.work.com, port: 143, tls: starttls, username: me, password: \"...\", default_folder: INBOX }\n    smtp:  { host: mail.work.com, port: 587, tls: starttls, username: me, password: \"...\", from_address: me@work.com }\n```\n\nThe fine print:\n\n- **At least one mailbox.** Each one needs at least one of `imap` / `smtp`. Both is fine. Neither is a config error.\n- **`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.\n- **Defaults**: IMAP `993/ssl`, SMTP `587/starttls`. Override if your provider is weird.\n- **The config file holds plaintext passwords and your bearer tokens.** Treat it like a credential vault: gitignore it, `chmod 600`, mount read-only, don't paste it in Slack.\n\n## HTTP API\n\n### Authentication\n\nIf `auth.tokens` is set, **every request except `GET /health`** has to carry a bearer:\n\n```\nAuthorization: Bearer <one of auth.tokens>\n```\n\nNo 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.\n\nLeave `auth.tokens` empty (or skip the block) and everything's open. Fine for \"this is bound to 127.0.0.1 and there's a reverse proxy in front.\" Catastrophic otherwise. Your call.\n\n### Errors\n\nEverything's JSON. Errors look like `{\"detail\": \"...\"}`:\n\n| Status | When                                                                                       |\n| ------ | ------------------------------------------------------------------------------------------ |\n| `401`  | Missing or invalid bearer (when auth is on).                                                |\n| `404`  | Unknown mailbox name in the URL.                                                            |\n| `409`  | You're asking a mailbox for a protocol it doesn't have (IMAP endpoint on an SMTP-only one). |\n| `502`  | The IMAP / SMTP server upstream said no.                                                    |\n\n### `GET /health`\n\n```json\n{ \"ok\": true, \"version\": \"0.4.14\" }\n```\n\nAlways open, no bearer required. Point your liveness probe at this and forget about it.\n\n### `GET /mailboxes`\n\n```json\n{\n  \"mailboxes\": [\n    { \"name\": \"personal\", \"description\": \"Gmail\",\n      \"imap\": true, \"smtp\": true }\n  ]\n}\n```\n\n### Unified inbox (the main event)\n\n`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 `boss@corp.com`,\" \"what's unread right now,\" \"what came in this morning\" — all the same call, no fanout dance on the client side.\n\n| Query param                                      | What it does                                                                                                                              |\n| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |\n| `mailbox`                                        | CSV filter by mailbox name (`personal`) **or** email address (`me@gmail.com`). Omit to search all of them.                                 |\n| `from`, `to`, `subject`, `body`, `text`          | IMAP SEARCH predicates. `text` is full-text across headers + body.                                                                         |\n| `since`, `before`                                | IMAP dates, e.g. `1-Jan-2026`.                                                                                                             |\n| `unseen`, `seen`, `flagged`, `answered`          | Flag filters. Set the ones you want to true.                                                                                               |\n| `larger_than`, `smaller_than`                    | Bytes.                                                                                                                                     |\n| `folder`                                         | IMAP folder (default `INBOX`).                                                                                                             |\n| `limit`                                          | Hard-capped at 500. Default 50.                                                                                                            |\n\nResponse:\n```json\n{\n  \"messages\": [\n    {\n      \"uid\": \"1234\",\n      \"mailbox\": \"personal\",\n      \"mailbox_address\": \"me@gmail.com\",\n      \"from\": \"boss@corp.com\",\n      \"subject\": \"weekly sync\",\n      \"date\": \"...\"\n    }\n  ],\n  \"errors\": [\n    { \"mailbox\": \"work\", \"error\": \"login failed: ...\" }\n  ]\n}\n```\n\nPer-mailbox blowups land in `errors` instead of killing the call. One dead account doesn't blind you to the other nine.\n\n```bash\n# everything from one sender, all accounts at once\ncurl -s -H \"Authorization: Bearer $TOKEN\" \\\n  'http://localhost:8000/inbox?from=boss@corp.com&limit=20' | jq\n\n# unread mail in just two of them\ncurl -s -H \"Authorization: Bearer $TOKEN\" \\\n  'http://localhost:8000/inbox?mailbox=personal,work&unseen=true' | jq\n```\n\n### Per-mailbox IMAP\n\nWhen you want to zero in on one account:\n\n| Method   | Path                                                  | Does                                                                                                                        |\n| -------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |\n| `GET`    | `/mailboxes/{name}/folders`                           | List IMAP folders.                                                                                                          |\n| `GET`    | `/mailboxes/{name}/messages?folder=&limit=&search=`   | Newest-first headers. `search` is raw IMAP (`ALL`, `UNSEEN`, `FROM foo@bar`). `limit` ≤ 500.                                |\n| `GET`    | `/mailboxes/{name}/search?from=&subject=&...`         | Same structured query as `/inbox` minus `mailbox`, scoped to one account.                                                   |\n| `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). |\n| `DELETE` | `/mailboxes/{name}/messages/{uid}?folder=`            | `\\Deleted` + EXPUNGE. Gone. Really gone.                                                                                    |\n| `POST`   | `/mailboxes/{name}/messages/{uid}/seen?folder=`       | Body `{\"seen\": true|false}` — flip the `\\Seen` flag.                                                                        |\n\nUIDs everywhere, never sequence numbers — identifiers stay stable when the mailbox shifts around under you.\n\n### Reader mode\n\n`?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.\n\nHow it works:\n\n1. The HTML body is run through [html2text](https://github.com/Alir3z4/html2text) with `ignore_images=True`, `body_width=0` (no hard wrap), `unicode_snob=True`. Styles, scripts, comments, head, and `<img>` tags are dropped.\n2. Headings → `#`, bold/italic preserved, `<a href=\"x\">text</a>` → `[text](x)` inline, lists/tables converted to markdown equivalents.\n3. If there is no HTML body, `body_reader` falls back to the trimmed `body_text`.\n4. The original `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.\n\nWhy 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.\n\nReply-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.\n\n### SMTP\n\n| Method | Path                          | Does           |\n| ------ | ----------------------------- | -------------- |\n| `POST` | `/mailboxes/{name}/send`      | Send an email. |\n\nBody:\n```json\n{\n  \"to\":           [\"dest@example.com\"],\n  \"cc\":           [\"copy@example.com\"],\n  \"bcc\":          [\"hidden@example.com\"],\n  \"subject\":      \"hi\",\n  \"body_text\":    \"plain text body\",\n  \"body_html\":    \"<p>optional html body</p>\",\n  \"from_address\": \"override@example.com\",\n  \"reply_to\":     \"noreply@example.com\"\n}\n```\n\nAt 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.\n\n## MCP server\n\nSame 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:\n\n```\nmailboxes                   # discovery: list configured mailboxes + capabilities\ninbox                       # unified read across all IMAP mailboxes (mailbox= filter)\nlist_folders                # (mailbox)\nlist_messages               # (mailbox, folder, limit, search)\nsearch                      # (mailbox, from, subject, since, ...)\nget_message                 # (mailbox, uid, reader=true → +body_reader)\ndelete_message              # (mailbox, uid)\nmark_seen                   # (mailbox, uid, seen)\nsend                        # (mailbox, to, subject, body_text/html, ...)\n```\n\nAn agent finds what's available via `mailboxes`, then passes the chosen name (`\"personal\"`) or address (`\"me@gmail.com\"`) as the `mailbox` arg. For cross-account queries use `inbox` — `inbox(from=\"boss@corp.com\")` fans out across every IMAP-enabled mailbox at once.\n\nIMAP-only tools only appear if at least one mailbox has IMAP. Same for SMTP. No dead buttons.\n\n### Transport\n\nPlain 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.\n\n### Wiring it into Claude / Pi / any MCP host\n\nMost MCP hosts take a `.mcp.json` (or equivalent) like this:\n\n```json\n{\n  \"mcpServers\": {\n    \"mailbox\": {\n      \"transport\": \"streamable-http\",\n      \"url\": \"http://localhost:8000/mcp\",\n      \"headers\": {\n        \"Authorization\": \"Bearer YOUR_TOKEN_HERE\"\n      }\n    }\n  }\n}\n```\n\nDrop the `headers` block if you're running without `auth.tokens`. Keep it if you value sleep.\n\n## Agent integrations\n\nThe [skill](.agents/skills/docker-mailbox) works in any agent that reads `.agents/skills/`, and installs natively in the clients below.\n\n### Claude Code\n\n```bash\nclaude plugin marketplace add psyb0t/agents\nclaude plugin install mailbox@psyb0t\n```\n\nClaude Code prompts for the mailboxd URL and, if auth is enabled, the bearer token — the token is stored in your OS keychain.\n\n### Codex\n\n```bash\ncodex plugin marketplace add psyb0t/agents\ncodex plugin add mailbox@psyb0t\n```\n\nInstalled 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`.\n\n### OpenClaw\n\nThe skill is published to ClawHub on every release:\n\n```bash\nopenclaw skills install @psyb0t/docker-mailbox\n```\n\nFor MCP clients that speak local stdio, the [`@psyb0t/mailbox`](.agents/plugins/mailbox) plugin bridges to mailboxd's `/mcp` endpoint:\n\n```bash\nopenclaw plugins install clawhub:@psyb0t/mailbox\n```\n\nThen set `MAILBOX_URL` (and `MAILBOX_TOKEN` if the server was started with `auth.tokens` configured).\n\n## Architecture\n\n```\n┌────────────┐    ┌──────────────────────────┐    ┌──────────────┐\n│  HTTP CLI  │───▶│  FastAPI                 │    │  IMAP server │\n│  / curl    │    │  /inbox, /mailboxes, …   │───▶│  SMTP server │\n└────────────┘    │                          │    └──────────────┘\n┌────────────┐    │  bearer-auth gate        │\n│  MCP host  │───▶│  ─── shared ops ───      │\n│ (Claude…)  │    │                          │\n└────────────┘    │  /mcp  (streamable HTTP) │\n                  │  mailboxes/inbox/send/…  │\n                  └──────────────────────────┘\n                              │\n                              ▼\n                    ┌──────────────────────┐\n                    │  config.yaml         │\n                    │  auth.tokens         │\n                    │  one entry per       │\n                    │  mailbox             │\n                    └──────────────────────┘\n```\n\nStateless. 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.\n\n## Development\n\n```bash\nmake help          # list all targets\nmake dev-image     # build the sandboxed dev container\nmake shell         # drop into it\nmake run           # boot the server (REST + /mcp); mount CONFIG=path/to/config.yaml\nmake test          # full suite (unit + docker-in-docker integration)\nmake test-unit     # in-process only, fast feedback loop\nmake lint          # flake8 + mypy\nmake sec           # semgrep + bandit + pip-audit -> sec.sarif (reports, never fails)\nmake format        # isort + black\n```\n\n### Package management — supply-chain defense\n\nWe use `uv`'s `exclude-newer` to refuse any package version published after a fixed cutoff. The cutoff gets **bumped to today** automatically by every `pkg-*` make target, so a freshly-published malicious release can't sneak in on the next `pkg-add`.\n\n```bash\nmake pkg-add PKG=foo==1.2.3\nmake pkg-remove PKG=foo\nmake pkg-update PKG=foo\nmake pkg-lock\nmake pkg-upgrade\n```\n\nDon't hand-edit `[tool.uv].exclude-newer`. Let the targets do it.\n\n## License\n\nWTFPL — see [LICENSE](LICENSE). Do what the fuck you want.\n",
  "bytes": 20685,
  "sha": "e46802981d8d69d6b9d860bb6cc16ef7c1d96a42b1490e0567d15bfa0f10bee4",
  "repo_slug": "psyb0t/docker-mailbox",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_psyb0t_mailbox_c18dd60b/readme"
}