mailbox-mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mailbox-mcpsearch my inbox for invoices from last month"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mailbox-mcp
Give your AI tools access to your email. Search, read, send, and manage messages across multiple accounts without leaving your terminal.
mailbox-mcp is an MCP server that connects your email to Claude Code, Cursor, Windsurf, or any AI tool that supports the Model Context Protocol. Instead of switching between your terminal and Gmail, you ask the AI to find that invoice, summarize a thread, or draft a reply — and it does.
What makes this different from the 60+ other email MCP servers:
Multiple accounts, one server. Work email, personal email, client accounts — all accessible through a single server. No need to run separate instances.
Not just Gmail. Supports Gmail (full API), any IMAP/SMTP provider (ProtonMail, corporate mail, self-hosted), and JMAP (Fastmail, Stalwart, Topicbox). Add providers without changing a line of tool code.
Built for untrusted input. Hidden-text stripping, random-nonce fences, confirmations before mail leaves the account, per-account read-only and drafts-only modes, encrypted credentials (AES-256-GCM), TLS enforcement, SSRF protection. Details in Security.
Tools for the workflows that matter. Search, read, send, reply, forward, drafts, labels, filters, templates, signatures, vacation replies, attachments, unsubscribe, and more.
Zero native dependencies. Pure Node.js. Install and run anywhere.
Security
Giving a model access to a mailbox means giving it access to text written by strangers. Any sender controls the subject, body, headers and attachment filenames of what the model reads, so the two risks are the model following instructions planted in an email, and the model sending mail somewhere it shouldn't (the usual goal of such instructions: forward the thread, reply with the contents of another message, mail a secret to an outside address). mailbox-mcp puts several layers between those two things.
Fenced untrusted content. Everything that came from an email is wrapped in [UNTRUSTED_<KIND>_<nonce>] ... [/UNTRUSTED_<KIND>_<nonce>] markers, and the server's MCP instructions tell the client that text inside them is data to report, never instructions to follow. The nonce is a random hex string chosen fresh for every tool response, so an email cannot close a fence early or open a fake one; anything in the content that even looks like a marker, in any case or with Unicode lookalike characters, has its bracket replaced before it reaches the model.
Hidden text is removed, and you're told. HTML-only messages are reduced to what a mail client would actually show. Elements hidden with display:none, visibility:hidden, opacity:0, zero or near-zero font sizes, text the same colour as its background (white on white), the hidden attribute, aria-hidden, off-screen positioning and the preheader max-height:0; overflow:hidden trick are dropped, along with comments, scripts, styles and templates. Zero-width and bidirectional control characters are stripped from every body, subject, header and filename. When anything was removed, the response ends with a visible warning saying how many characters went, so a message that says one thing to you and another to the model is flagged rather than silently cleaned.
Confirmations before mail leaves. Sending to an address the account has never sent to or received from needs confirm_new_recipient: true; the error lists the new addresses. Forwarding to a domain other than the account's own needs confirm_external_forward: true. The client is told to pass those flags only when you asked for that recipient yourself, never because an email did. Both checks are on by default.
Per-account modes. An account can carry a recipient allowlist (exact addresses and @domain patterns; everyone else is refused), a draftsOnly flag (send tools create a draft for you to review instead), and a readOnly flag (every write tool refuses; search and read keep working). These are set in accounts.json or when the account is created, and no tool can loosen them, so a hijacked session cannot switch them off. See Account safety settings.
Tool annotations. All 49 tools carry MCP readOnlyHint, destructiveHint, idempotentHint and openWorldHint annotations, so a client can auto-approve reads and always ask before anything that sends, deletes or changes settings.
Caps and a log. At most 10 sends per minute and, by default, 100 per rolling 24 hours per account; the daily count is kept on disk and survives restarts. Every send through the server is recorded in ~/.mailbox-mcp/sends.jsonl with its recipients, and every bulk label or trash operation in transactions.jsonl with an undo id.
The three options below enforce limits outside the model instead of asking it to behave. All are off by default.
Out-of-band approval ("approval": "external" per account). Every send path still runs the guards above, then writes the fully resolved message to ~/.mailbox-mcp/pending/<id>.json instead of sending it, and tells the model the id. No tool can list, edit or approve that queue. You do it in a terminal: mailbox-mcp pending, mailbox-mcp show <id>, mailbox-mcp approve <id>, mailbox-mcp reject <id>. approve prints the whole message, reads the confirmation from /dev/tty (not stdin, so it cannot be piped), re-runs the allowlist and daily cap, reloads attachments and refuses if one changed, then sends exactly what it printed. A shell without a controlling terminal, which is what an agent's shell is, cannot open /dev/tty, so it cannot approve its own sends. Entries expire after 7 days. See Approving queued sends.
Tool profiles (MAILBOX_MCP_PROFILE). read exposes only read-only tools; draft exposes everything except the tools that can make mail leave the account. Hidden tools are absent from the tool list and refuse if called anyway. See Choosing which tools load.
Lock after untrusted reads ("untrustedReadLock": "approval" | "refuse" per account). As soon as a tool shows the model a message it cannot vouch for, or exports or downloads message content to disk, the account is marked for the rest of the server process. approval then routes every send from it through the pending queue even if approval isn't set; refuse refuses sends until the server restarts. No argument the model can pass lifts it. The lock is strict by design: a message is vouched for only when its sender is on the account's trustedSenders list in accounts.json AND the receiving server's DMARC check passed for the From domain. Nothing a sender or the model can influence counts: not the From header, not the Sent folder, not the send log, not a mailbox search. With no trustedSenders configured, reading any third-party mail taints, which is the intended default; most people will want approval: "external" rather than a long trusted list. See How the lock decides.
None of this makes prompt injection impossible. A model can still be talked into a reply you didn't want, and a text/plain part can say something different from the HTML part a human sees. Keep a human approving sends. A reasonable setup: readOnly: true or MAILBOX_MCP_PROFILE=read on accounts you only need to search, approval: "external" or an allowlist on any account an agent sends from unattended, untrustedReadLock on anything that triages an inbox, and the default confirmations everywhere else.
Related MCP server: better-email-mcp
Quick Start
Install
Add to your Claude Code MCP config (~/.claude.json). The package runs straight from npm via npx:
{
"mcpServers": {
"mailbox": {
"command": "npx",
"args": ["-y", "mailbox-mcp"],
"env": {
"MAILBOX_MCP_PASSPHRASE": "a-long-random-passphrase"
}
}
}
}MAILBOX_MCP_PASSPHRASE is the passphrase used to encrypt IMAP/JMAP credentials at rest; it's required before adding an IMAP or JMAP account, and unused for Gmail-only setups. MAILBOX_MCP_CONFIG_DIR moves the config directory somewhere other than ~/.mailbox-mcp.
git clone https://github.com/jgalea/mailbox-mcp.git
cd mailbox-mcp
npm install && npm run buildThen point the config at the build with "command": "node", "args": ["/path/to/mailbox-mcp/dist/server.js"].
Add a Gmail Account
1. Create a Google Cloud project
Go to Google Cloud Console and create a new project
Enable the Gmail API: APIs & Services > Library > Gmail API > Enable
2. Set up OAuth consent screen
Set App name and User support email
Go to Audience, select External
Add the Google account you'll sign in with as a test user (this must be the exact
@gmail.comaddress you use to authenticate, not a workspace alias)
3. Create OAuth credentials
Go to Google Auth Platform > Clients > Create Client
Application type: Desktop app
Click Create
Go to APIs & Services > Credentials, find your client, and click the download icon to get the JSON
Save the file as
~/.mailbox-mcp/oauth-keys.json
4. Authenticate
In Claude Code, run: authenticate alias="personal" provider="gmail" email="you@gmail.com"
This opens a browser window to complete the OAuth flow. Your tokens are stored locally in ~/.mailbox-mcp/accounts/.
Add an IMAP Account
In Claude Code, run:
authenticate alias="work" provider="imap" email="you@company.com" host="imap.company.com" smtpHost="smtp.company.com" username="you@company.com" password="<app-password>"Credentials are encrypted at rest using AES-256-GCM.
Add a JMAP Account
In Claude Code, run:
authenticate alias="fastmail" provider="jmap" email="you@fastmail.com" host="fastmail.com" username="you@fastmail.com" password="<app-password>"JMAP auto-discovers the API endpoint via .well-known/jmap. Credentials are encrypted at rest using AES-256-GCM.
Supported JMAP servers: Fastmail, Stalwart, Topicbox, Cyrus IMAP, and any RFC 8620-compliant server.
JMAP advantages over IMAP:
Native thread support (real conversations, not synthetic)
Server-side search (faster, more accurate)
Batch operations in a single HTTP request
No persistent connection required
Tools
Universal (Gmail + IMAP + JMAP)
Tool | Description |
| List configured accounts |
| Add a new account |
| Re-run OAuth for an existing Gmail account (use when refresh token expires with |
| Remove an account |
| Search messages (optional |
| Run the same query across every configured account in parallel |
| Read a message |
| Read a conversation thread (Gmail + JMAP) |
| Send a new email (supports |
| Reply to a message (supports |
| Forward a message (supports |
| Create a draft (supports reply drafts via |
| List drafts for an account |
| Send an existing draft (same recipient guards as |
| Trash messages |
| Mark a message as read or unread |
| Star or unstar a message |
| Archive a message (remove from inbox) |
| List labels/folders |
| Create a label/folder |
| Delete a label/folder |
| Modify message labels |
| Bulk modify labels |
| Add/remove labels on every message matching a query (search-then-batch; |
| Trash every message matching a query ( |
| List recorded bulk operations from the transaction log |
| Reverse a recorded bulk operation against the exact ids it touched |
| Show unread message counts per label/folder |
| Download an attachment |
| Save a message as a |
| Save every message in a thread as |
| List messages received after a given timestamp |
| Inbox overview |
Gmail-Only
Tool | Description |
| Replace the contents of an existing draft (thread association preserved) |
| Permanently delete a draft |
| Create a filter |
| List filters |
| Delete a filter |
| Save a template |
| List templates |
| Delete a template |
| Send from template |
| Get signature |
| Update signature |
| Get vacation settings |
| Configure vacation reply (supports date ranges, domain-only) |
| Find unsubscribe link |
| Bulk unsubscribe |
| List send-as aliases |
Account safety settings
Each entry in ~/.mailbox-mcp/accounts.json can carry eight optional fields:
{
"accounts": {
"archive": { "provider": "gmail", "email": "old@example.com", "readOnly": true },
"agent": {
"provider": "imap", "email": "bot@example.com",
"host": "imap.example.com", "port": 993, "smtpHost": "smtp.example.com", "smtpPort": 587,
"allowedRecipients": ["ops@example.com", "@example.com"],
"dailySendLimit": 20
},
"personal": { "provider": "gmail", "email": "me@example.com", "draftsOnly": true },
"work": { "provider": "gmail", "email": "me@work.example", "approval": "external", "untrustedReadLock": "approval" },
"triage": { "provider": "gmail", "email": "inbox@example.com", "untrustedReadLock": "refuse", "trustedSenders": ["boss@example.com", "@partner.example"] }
}
}Field | Effect |
| Every tool that isn't read-only refuses for this account with a clear error. Search, read, list and export still work. |
|
|
| Exact addresses and |
| Sends allowed per rolling 24 hours (default 100; |
|
|
|
|
| Exact addresses and |
| The |
The same settings can be passed to authenticate as read_only, drafts_only, allowed_recipients, daily_send_limit, approval and untrusted_read_lock when the account is created, and list_accounts shows them. There is deliberately no tool to change them afterwards: edit the file and restart the server. Malformed entries make the server refuse to start rather than run unguarded.
Approving queued sends
The same binary that runs the server is the approval CLI. With no arguments it starts the MCP server; with a command it works the queue and exits.
mailbox-mcp pending # id, account, from, recipients, subject, created, attachment names
mailbox-mcp show <id> # the full message
mailbox-mcp approve <id> # prints the message, asks "Type yes to send", sends exactly that
mailbox-mcp reject <id> # drops itIf you installed with npx, run npx mailbox-mcp pending and so on. The config directory is the same one the server uses (MAILBOX_MCP_CONFIG_DIR, default ~/.mailbox-mcp), so set it the same way if you changed it.
approve reads the confirmation from /dev/tty, never stdin, and refuses with a clear error when it cannot open it. That is what stops an agent with shell access from approving its own sends: its shell has no controlling terminal. What it prints is what it sends: the entry is read once into memory through a descriptor that refuses symlinks, and the file's hash is checked again right before sending. A message containing terminal escapes, bidirectional overrides or zero-width characters, which could make the screen show something other than what goes out, is refused outright. At approval time it re-runs the allowlist and daily cap against the current accounts.json, reloads every attachment from the path recorded at queue time and refuses if the file is gone or its size changed, records the send in sends.jsonl like any other, and deletes the pending file. Entries older than 7 days are refused; reject them to clean up. The pending directory is created 0700 and files 0600.
For reply and forward, the queued body is your text; threading headers and the forwarded original are added by the provider at send time, as show says. For send_draft the queue holds the draft id, its recipients and a fingerprint of the draft as it was; approve refuses if the draft or its recipients changed since, so an update_draft after queueing cannot ride on an earlier review. Review the body in your mail client.
If the agent runs inside Claude Code, add the approve command to the deny list in .claude/settings.json so even a permission prompt never offers it:
{ "permissions": { "deny": ["Bash(mailbox-mcp approve*)", "Bash(npx mailbox-mcp approve*)"] } }How "new recipient" is decided: an address is known if this server has sent to it before from that account (the sends.jsonl log), if it is the account's own address, or if one provider search (from:addr OR to:addr, limited to one result; INBOX only on IMAP) finds a message. Anything else needs confirm_new_recipient: true. Reply targets taken from the message being replied to are trusted, since you already received mail from them.
authenticate refuses an alias that already exists, with or without safety settings, so a session cannot swap the address, provider or host under them; reauth refreshes a Gmail token without touching the config. remove_account refuses for any account that has a safety setting, since removing and re-adding it would drop them. Both point at accounts.json. Nothing the model can call may write into the config or log directory either: export_email, export_thread and download_attachment refuse a save_to inside them (even when that directory sits under /tmp, is spelled in a different case on a case-insensitive filesystem, or is reached through a symlink), write only to the canonical directory they validated, check it again once it exists, and never write through a symlink planted under the file name; the attachments of a send refuse to read from those directories by the same rules. So the approval queue, accounts.json, the send log and stored credentials stay out of reach. Every recipient of a send must be one plain address; a value carrying two addresses is refused rather than checked as its first one.
How the lock decides
untrustedReadLock treats a message as trusted only when all of the following hold. The From header reduces to exactly one plain address (several addresses, a display name with its own angle brackets or @, invisible characters or no address at all fail this). That address matches an entry in the account's trustedSenders (exact address or @domain, domain only, not subdomains). The topmost Authentication-Results header was written by the account's own server (authserv-id equals authservId, mx.google.com for Gmail), contains exactly one dmarc= result, that result is pass, and its header.from equals the From domain. Everything else taints: any sender not on the list, a listed sender whose mail did not pass DMARC, the account's own address (unless listed), mail from a server you have not named in authservId, any tool that cannot supply the header for a message, and any error while deciding. The Sent folder, the local send log and mailbox searches play no part, because labels and folders can be changed by a tool call and a search can be fed by a crafted message. Exports and downloads taint unconditionally because the content leaves the fence.
Choosing which tools load
49 tool schemas cost roughly 6,000 tokens in clients that load every definition into context. In everyday use a handful of tools do most of the work, so you can expose only the groups you need with MAILBOX_MCP_TOOLS (comma-separated). Unset means everything loads.
"env": {
"MAILBOX_MCP_TOOLS": "core,attachments"
}Group | Tools | What it covers |
| 19 | accounts, search, read, send, reply, forward, drafts, inbox summary (~2,900 tokens) |
| 9 | labels, star, archive, trash, modify |
| 4 | query-wide modify/trash with dry-run and undo |
| 3 | download attachments, export |
| 14 | filters, templates, signatures, vacation, unsubscribe, send-as |
Calls to tools in disabled groups fail with an error naming the group to enable.
Tool profiles
MAILBOX_MCP_PROFILE picks what kind of instance this is. It composes with MAILBOX_MCP_TOOLS: a tool loads only when both allow it. Hidden tools are missing from the tool list and refuse if called anyway, with an error naming the profile. An unknown value stops the server at startup.
Profile | Tools |
| everything, as before |
| everything except the ten below |
| only tools annotated |
draft hides, because each can make mail leave the account or change what future mail says to a third party: send_email, reply_email, forward_email, send_draft, send_template (they send), create_filter (Gmail filters can forward and auto-file), set_vacation (sends auto-replies), set_signature (text injected into every future message you send), unsubscribe and bulk_unsubscribe (they surface attacker-chosen unsubscribe targets to act on). Drafts, labels, archiving, trash, bulk operations with undo, downloads and exports all stay available.
"env": { "MAILBOX_MCP_PROFILE": "draft", "MAILBOX_MCP_TOOLS": "core,attachments" }Inbox triage with Jev (optional)
Set MAILBOX_MCP_TYPESAFE_API_KEY (or TYPESAFE_API_KEY) to a key from TypeSafe and inbox_summary labels each recent message: needs reply, FYI, newsletter, receipt, notification or suspicious, plus urgent when the sender needs something within about a day, with a confidence figure.
"env": { "MAILBOX_MCP_TYPESAFE_API_KEY": "your-key" }Jev is a decision model: it can only pick from the fixed options above, so text in an email can't steer it into doing anything else. Labels come from those fixed options, never from email text, and are printed outside the untrusted-content fence.
This sends each message's sender, subject and snippet (capped at 2,000 characters) to TypeSafe's API. Leave the key unset and nothing is sent; the tool behaves exactly as before. Up to 25 messages are triaged per call; any that fail are reported as unclassified rather than guessed.
Sending attachments
send_email, reply_email, forward_email, and create_draft accept an optional attachments parameter — an array of local file paths. The server reads each file, detects its MIME type from the extension, and embeds it in the outgoing message (or draft).
send_email account="personal" to=["friend@example.com"] subject="The report" body="See attached." attachments=["/path/to/report.pdf", "/path/to/chart.png"]Each file must be a regular file ≤ 25 MB; total per message is capped at 25 MB (Gmail's hard limit).
Paths are resolved through any symlinks, and filenames are stripped of CRLF before going into headers.
Gmail routes messages with attachments through the multipart upload endpoint (35 MB API limit) instead of the JSON endpoint, so the 25 MB message cap is the real ceiling.
JMAP uploads each file to the server's upload URL first, then references the resulting blobIds in the Email/set call.
Choosing the sender address
One account often speaks for several addresses. send_email, reply_email, forward_email, create_draft, and update_draft accept an optional from parameter; without it, the account's primary address is used.
reply_email account="personal" message_id="18f..." from="jean@example.com" body="Thanks, sorted."Accepted forms are alias@example.com and Name <alias@example.com>; matching is case-insensitive.
The address is checked against the account before anything is sent, and the call fails with the list of usable addresses if it doesn't match. This matters because Gmail silently falls back to the primary address when the From header names an address you haven't verified, so a message can be sent from the wrong identity and still look like it succeeded. Some recipients (Amazon's customer service, for one) reject mail that doesn't come from the address on file.
Gmail: the address must be a send-as alias with verification completed. Run
list_send_asto see them. Pending aliases are refused.JMAP: the address must match one of the account's identities; its
identityIdis attached to the submission.IMAP: no alias list exists to check against, so any
fromis passed to the SMTP relay, which accepts or rejects it at send time.
License
MIT
Built at AgentVania.
This server cannot be deployed
Maintenance
Related MCP Connectors
Connect any mailbox to Claude, ChatGPT & AI: read, send, reply, schedule & search emails.
Stateful email for AI agents — read inboxes, reply in-thread, draft with approval.
Your mailboxes in ChatGPT and Claude: Gmail, iCloud, Fastmail, any IMAP. Passwords stay yours.
- Lettio MCPOAutheu.lettio
Private, EU-hosted email for AI agents over JMAP: read, search, reply, organize, send.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables AI assistants to manage multiple email accounts with secure credentials, local full-text search, thread-aware replies, and automation.8 npmMIT
- AlicenseNot gradedqualityCmaintenanceIMAP/SMTP email for AI agents -- read, send, organize folders, and manage attachments across multiple accounts, with auto-discovery.MIT
- AlicenseAqualityBmaintenanceEnables AI assistants to read, search, send, and manage email on macOS through programmatic access to Apple Mail.29MIT
- AlicenseNot gradedqualityBmaintenanceEnables AI assistants to read, search, and manage email via IMAP, and send emails with attachments via SMTP, using natural language commands.468 npmMIT