▣ open source · python · mcp
email-mcp
Multi-account email MCP server for agent fleets — scoped per-agent keys, owner-approved sends, and a policy layer that assumes the agent is compromised.
- Period
- 2026
- Role
- Author & maintainer
- Status
- open source
- 8 email tools
- 180s owner approval window
- #mcp
- #security
- #python
- #agents
The problem
Email is the most dangerous capability you can hand an agent. A mailbox is identity: password resets, receipts, contracts, and the ability to send as you. I wanted my agents — a local Claude Code session and, eventually, a whole fleet — to read and send mail across multiple accounts (iCloud and Gmail via app-specific passwords). But I wasn’t willing to design for the happy path. The design assumption is that an agent will eventually be prompt-injected or otherwise compromised, so the security boundary has to live in the tool, not in the prompt.
That’s the project: a Python MCP server where the email plumbing is table stakes and the permission system is the actual product.
The architecture
blocklist → allowlist → owner-approval ladder that fails closed, enforced below the tool boundary even if the agent is fully adversarial.Two surfaces share one IMAP/SMTP core. The stdio MCP server runs per-client for a single user, configured from an accounts file with passwords in the macOS Keychain — read at runtime, never written to disk, logged, or returned by any tool. The HTTP service is the fleet surface: a long-lived FastAPI app serving MCP over streamable-HTTP, multi-tenant, with each agent authenticating through its own scoped key. Humans onboard through a Matrix bot and manage everything from a web dashboard.
Eight tools cover the lifecycle: account/folder listing, reading, search, attachments, send, flag, move. Two details shape the read path. First, reads never mark mail as read — the read path is side-effect-free, so agents can poll aggressively without disturbing the human’s inbox state. Second, reads have two modes: recency reads serve from an in-memory cache (with optional background prefetch keeping the inbox warm, so a repeat read is near-instant), while real searches run server-side over the whole mailbox via IMAP. The result tells the agent which mode it got, so “not in the recent window” is never confused with “doesn’t exist.”
The security model is the story
Scoped per-agent keys. Each key is bound to exactly one mailbox and carries explicit scopes — read, write, send, mint, admin. Keys are stored hashed, shown once at creation, and metered: every read, search, send, and blocked send is counted per key.
Delegation without escalation. A mint-scoped key can create subagent keys — but only with scopes it already holds. An orchestrator can hand a subagent read-only access to one mailbox; the subagent can’t mint its way back up. Attenuation only.
The owner stays in control. The dashboard shows every key, its usage, and its scopes; any key can be paused or revoked at any moment. Onboarding itself is conservative: the Matrix bot issues single-use, 24-hour sign-in links, and “this is a private DM” is re-verified at every send rather than trusted from an invite-time cache — a room that quietly gains a third member stops receiving links and previews.
Send policy as the last line of defense. Sends pass through three tiers. A blocklist match is always BLOCKED — it wins even over a later human approval. An allowlist match sends immediately. Anything else becomes pending approval: I get a Matrix DM with a preview and 180 seconds to react — a 👍 performs the real send, any other reaction rejects it, silence expires it. If no bot is configured, the approval tier degrades to a plain deny. The system fails closed. One subtle but load-bearing detail: every recipient is canonicalized to bare addresses with the same parser SMTP uses, so an address smuggled behind a display name or a comma ("ok@x.com, attacker@y.com") is checked address-by-address.
Idempotent sends. A dedup ledger blocks a second send to the same recipients within 10 minutes by default; callers can pass an idempotency_key for exact control. A retrying agent can’t double-send an email.
The agent cannot delete mail. Trash folders are read-only under the default policy, and moving-to-trash is the only delete the server has — so the default capability set simply doesn’t include destruction. Policy violations return structured results (recipient_not_allowed, folder_protected), never exceptions, and policy is re-read on every call so edits apply without a restart.
Secrets. Stdio keeps passwords in the OS keychain; the HTTP service encrypts mailbox passwords with AES-GCM under a key HKDF-derived from a master key plus a per-row salt.
Decisions that mattered
Treat the model as an untrusted client. Every guarantee above holds even if the agent is fully adversarial, because it’s enforced below the tool boundary. Returning structured denials instead of exceptions matters more than it looks: agents handle a typed BLOCKED result gracefully and route around it, where a stack trace derails the run. And building two surfaces on one core meant the fleet deployment inherited every policy the single-user version had already hardened.
Lessons
The allowlist/blocklist/approve ladder turned out to be the right shape for agent permissions generally: deterministic rules for the common case, a human reaction for the gray zone, and fail-closed when the human channel is down. Honest scoping helps too — the README says plainly that only iCloud is exercised end-to-end. Tested claims beat broad ones.