Version audited: 0.4.0 (2026-09-05). Fixes shipped as 0.4.1 (2026-09-06). Published at https://mailmcp.ai/audit.
1. What this is, and what it is not
This is an internal, AI-assisted audit commissioned by the author of mailmcp. Sixteen independent review agents (Claude Fable 5.1 for cryptography, OAuth, the mail layer and the threat model; Claude Opus 5 and Claude Sonnet 5 for the remaining areas) each examined one part of the system read-only, with file and line evidence, and several of them reproduced their findings by executing the real code against a local mail server. A second, independent review by a different model family (OpenAI Codex with GPT-6 Astra) followed the same brief; its findings are in section 12.
It is not a third-party penetration test and nobody outside the project signed it. It is published so that you can read what was found, what was fixed, what was deliberately left as is, and what no configuration can remove. Every High finding was fixed before publication; the Medium findings were fixed or are listed as accepted trade-offs with the reason.
2. The system in one page
mailmcp connects an AI assistant (ChatGPT, Claude, Cursor, VS Code, Gemini CLI) to your mailboxes over IMAP and SMTP through the Model Context Protocol. There are three ways to run it.
- Shared server (mailmcp.ai or any server run by someone else). The server holds one master key. You create a token in your browser: the browser encrypts your mailbox settings with a random key K (AES-256-GCM), the server seals K under its master key and hands it back, and the token is
mmt1.<sealed K>.<encrypted settings>. Whoever holds only the token cannot decrypt it. Whoever holds only the master key has no data. The server combines the two while it serves your request, decrypts your settings in memory, opens IMAP/SMTP to your provider, and keeps nothing on disk. Decrypted settings stay in memory for at most 15 minutes after your last request. - Your own deployment (Vercel one-click, Docker, a VPS). The same code, your own master key. Your organization is the operator.
- Local (Claude Desktop extension, Claude Code). No server, no OAuth, no links; settings live on your machine.
Everything else derives from the master key: OAuth codes and tokens, dynamically registered client ids, one-hour attachment download links, one-hour upload links. The server is stateless: no database, no session store, no revocation list. Capabilities (read, draft, send, modify, trash) are stored inside the encrypted settings per mailbox and enforced on every call; tools that no mailbox permits are not offered to the assistant at all. Sending is off by default and, when on, limited to an allowlist of addresses or domains.
3. Who sees what
Parties and the three ways of running mailmcp. A = shared server run by the vendor, B = your own deployment, C = local.
| Party | A: vendor shared server | B: your own deployment | C: local |
|---|---|---|---|
| You | Everything about your mailboxes. You hold the token (a live credential) and the edit password. | Same. | Plaintext settings on your machine. |
| Other users of the same server | Nothing of yours. Runtimes are keyed by a hash of the token; no code path shares them. They compete only for capacity (throttles, cache slots). | Same; colleagues. Registration can be closed with an invite code. | Not applicable. |
| AI vendor (OpenAI, Anthropic, …) | Every tool result: subjects, addresses, message text, names of attachments, text attachments, download links. Holds your token or an encrypted OAuth token, neither of which it can decrypt. Never sees passwords. | Identical. Your own server changes nothing about what the model sees. | Same, and binary attachments as base64 when asked. |
| Server operator | The vendor. Holds the master key; during your requests has your decrypted settings, passwords and mail in memory. Could change the code at any time. | Your organization, under your change control. | You. |
| Hosting provider (Vercel) | Environment variables (master key), TLS termination, function memory, request logs with IPs and URLs (including one-hour link references), region. | Same, under your account, region and audit log; none if you run Docker on your own hardware. | None. |
| Someone who steals your token or OAuth token | Reads and, if enabled, sends mail at the token's capabilities until you delete the app password at your provider or the operator rotates the master key. Access tokens live 30 days, refresh tokens 90 days; nothing revokes them individually. | Same, and your operator can rotate the master key as a kill switch. | Not applicable (a leaked local config is plaintext passwords). |
| Someone who gets a download or upload link | Downloads that one attachment, or uploads files into your mailmcp-uploads folder, for one hour. | Same. | Not applicable. |
| A malicious sender | Gets text in front of the model. Hidden text is stripped, headers and file names are neutralized, bodies are marked as third-party data; the model may still follow a visible instruction, within the allowlist and rate limit, with no permanent deletion. | Identical. | Identical. |
| The author | Is the operator of A. | Ships the prebuilt bundle; you decide when to update; the licence check is offline and there is no telemetry. | Same as B. |
What is where:
| Asset | A | B | C |
|---|---|---|---|
| Mailbox app passwords | Server memory, at most 15 min after the last use; never at rest | Same, on your server | At rest on your machine |
| Key K of your settings | Sent once over TLS at token creation, then only sealed inside the token | Same | At rest as MAILMCP_KEY |
| Master key | Vendor's Vercel environment | Your environment | Same value as K |
| Your token | Your AI client's config or keychain; inside encrypted OAuth tokens and links | Same | Not applicable |
| Mail content and addresses | AI vendor, server memory, hosting provider memory and logs | AI vendor, your server | AI vendor, your machine |
| Attachments (binary) | Browser to server directly; the AI vendor sees a link unless you ask for the contents | Same | Base64 through the AI vendor |
| Edit password | Only a PBKDF2 hash (600 000 iterations, browser-side), sealed in the token | Same | Not applicable |
| Licence key | Signed and readable; carries the buyer's name and an e-mail fingerprint (not the address) | Your environment | Local |
4. Findings
Sixteen reviews produced 6 High, about 30 Medium and about 40 Low or informational findings, with overlaps. All High and most Medium findings were fixed in 0.4.1 before this document was published. The remaining Medium findings are accepted trade-offs and are listed with the reason.
4.1 Fixed in 0.4.1
| Area | Severity | Finding | Fix |
|---|---|---|---|
| Tools | High | send_draft accepted any folder and any message, sent it verbatim and then permanently removed it, with only the send capability. A prompt-injected assistant could re-send and destroy any message. Reproduced against a local mail server. | Only messages flagged as drafts in the Drafts folder qualify; a different folder is refused; read and send capabilities required. |
| Tools | High | policy.attachments = "metadata" only hid one tool; download links were still minted and mailbox attachments could still be re-sent. | The setting now disables download links, attachment re-sending and forwarding of attachments. |
| Tools | High | forward_message read message bodies and attachments from mailboxes marked as not readable (read: false, send: true). | Read capability required. list_uploads gained a capability check too. |
| Prompt injection | High | Attachment file names were inserted into the assistant's text outside the untrusted wrapper and could forge its delimiters; subjects and sender names could do the same on one line. Reproduced. | One sanitizer for every sender-controlled field: control and invisible characters removed, wrapper delimiters neutralized, length bounded. Applied to names, subjects, senders, recipients, forwarded headers, upload listings. |
| Prompt injection | High | Hidden-text stripping missed <style> class rules, entity-encoded styles, CSS comments, self-closing hidden elements, fonts of 2 px, near-white colours, zero-size boxes. Reproduced. | Style-block rules are applied by class and id; styles are entity-decoded and comment-stripped before matching; thresholds widened; self-closing non-void elements treated as open; presentational zero sizes honoured. |
| Prompt injection | High | The plain-text part of a message was preferred over HTML, so a sender could show the human one message and the assistant another. | The HTML part is read when present (the same one the user sees); plain text is the fallback. |
| Cryptography | Medium | A sealed key was not bound to its encrypted settings: an old seal (no expiry, old edit password) could be paired with a newer settings blob of the same key. Reproduced. | The seal carries a hash of the settings blob and is refused with any other blob; regenerating a token uses a fresh key by default. |
| Cryptography | Medium | A cached runtime outlived its token's expiry while the client stayed active. | Expiry is checked on every cache hit. |
| Cryptography | Low | The master key was not validated in token mode; PBKDF2 iterations had no ceiling; token text accepted non-canonical base64. | Master key must decode to 32 bytes; iterations 100 000 to 2 000 000; strict base64url. |
| Medium | Bcc recipients were disclosed to every recipient: the raw message carried the Bcc header onto the wire. Reproduced. | The Bcc header is stripped from the wire copy. | |
| Medium | Oversized bodies and attachments were silently truncated by the IMAP library and delivered corrupted. | Downloads ask for one byte over the limit and refuse anything larger. | |
| Low | A recipient string with two angle-bracket addresses displayed one address and delivered to another. | Exactly one angle address allowed. | |
| Low | Forwarded headers were not sanitized; forwarded text was unbounded. | Sanitized and bounded. | |
| Medium | Attached messages and containers (message/rfc822) were split into inner parts. | Recorded as one attachment. | |
| HTTP | High | Anyone could mint a token pointing IMAP or SMTP at a private address, using a public server to probe internal networks. | User tokens may not name loopback, link-local, private or CGNAT hosts on public servers (opt-in for intranet mail servers). |
| HTTP | Medium | Request bodies were buffered before any size check; uploads accepted chunked bodies of any size. | Bodies over 1 MB refused on all non-upload routes; uploads must declare Content-Length; at most 10 files per request; container content types stored as opaque files; on Vercel the upload cap is stated as 4 MB. |
| HTTP | Medium | Throttles keyed on X-Real-IP and friends even when no proxy was trusted, so a client could rotate headers. | Proxy headers are trusted only on Vercel or with MAILMCP_TRUST_PROXY=1; otherwise the socket address is used. |
| HTTP | Medium | The sign-in and unseal throttles shared one budget; unseal attempts with a correct password were never counted although each costs a PBKDF2 verification. | Separate unseal budget; every attempt counts. |
| HTTP | Low | X-Forwarded-Proto was reflected unvalidated; /start (where the master key is generated) and /api/* lacked Cache-Control: no-store; checkout links were not scheme-checked. | Fixed. |
| OAuth | Low | An authorization code issued on one host could be exchanged on another host sharing the key. | Codes are bound to the host that issued them. |
| OAuth | Low | Dynamic registration accepted redirect URIs with fragments or credentials. | Refused. |
| Licensing | Medium | The signed licence key carried the buyer's e-mail address in readable form. | The key carries the name and a 12-character fingerprint of the e-mail, not the address. |
| Licensing | Medium | The Claude Desktop extension still honoured a test-only public-key override that the other bundles had compiled out. | Compiled out of the extension as well. |
| Licensing | Low | The Lemon Squeezy webhook had no replay guard. | Per-instance guard on event id. |
| Operations | Medium | The Docker image and Vercel function shipped source maps; the Node entry had no crash handlers; an SMTP transport was not closed on failure; release tags were force-pushed; the customer changelog was generated from internal commit messages. | Source maps off; handlers added; finally around SMTP; tags are immutable unless explicitly re-tagged; curated CHANGELOG.md. |
| Public pages | Medium | The home page in single-owner mode listed the owner's e-mail addresses and permissions to anonymous visitors; /api/claim echoed the buyer's name and e-mail; error strings from the licence check and the webhook were echoed. | Removed or made generic. (Fixed on 2026-09-05, before this audit.) |
| Public pages | Low | Several sentences overstated the guarantees: "nothing in memory after the request" (15 minutes is the truth), "not even the operator sees passwords" (the operator's process decrypts them), "files never travel through the chat" (text attachments and explicit requests do), "a crafted e-mail cannot order it around" (mitigation, not immunity), and "security audit" without saying it was internal. | Reworded on the home page, README and guide; this document is linked from the home page. |
4.2 Accepted trade-offs (not changed, with the reason)
| Finding | Why it stays |
|---|---|
Access tokens live 30 days and refresh tokens 90 days; nothing revokes a single token; /revoke accepts and ignores. | The design is stateless (no database), and ChatGPT does not refresh tokens proactively, so short access tokens produce daily "connection expired" prompts. The immediate revocation path is deleting the app password at your provider; the operator's kill switch is rotating the master key, which logs everyone out. |
| Refresh tokens are not rotated. | Same reason: rotation without a revocation store gives no security benefit. A future version may add a bounded chain age. |
| Throttles, the authorization-code replay guard and the send rate limit are per process. On Vercel each instance keeps its own counters. | Stateless by design. They are honest best-effort limits, not global guarantees; the allowlist is the hard control on sending. |
| Anyone can create a token on a shared server unless the operator sets an invite code. | That is what a shared server is for. Companies set MAILMCP_INVITE_CODE; the vendor's server is open so that people can try the product. |
| The operator's process decrypts your settings in memory while serving you. | Unavoidable: the server must open IMAP with your password. This is the main reason to run your own deployment (section 7). |
| The AI vendor sees every tool result. | That is the product. Passwords are the only thing kept from it. |
| Upload links are bearer credentials valid for one hour with unlimited uses; someone holding one can fill your upload folder or plant a file. | Stateless links cannot count uses. The assistant is told that uploads may come from anyone with the link and to confirm with the user which file to attach; files are deleted once attached. |
| Bearer-header clients (Claude Code, Cursor, Gemini CLI) keep the raw token in a config file. | Their design. Prefer OAuth in ChatGPT and claude.ai; keep the token in a password manager. |
Drafts can be addressed to anyone (no allowlist on create_draft). | Drafts are reviewed and sent by you from your mail client. Sending them through the assistant (send_draft) is allowlisted. |
| Personal-tier "one person" and the licence check itself are enforced by contract, not by code; anyone controlling the server can bypass the licence. | The Sendy model. Offline verification and no telemetry were chosen over enforcement. |
Text attachments enter the chat; inline: true embeds binaries; in local mode everything is embedded. | Requested behaviour, bounded at 2 MB, stated on the pages. |
| Google Fonts is loaded from Google's servers on every page. | Convenience; Google receives visitor IPs. Self-hosting the font is on the roadmap. |
4.3 Open recommendations (roadmap)
- Per-response CSP nonces instead of
script-src 'unsafe-inline'(defence in depth; no injection point was found). - Publish SHA-256 sums with every release; sign them.
- A bounded refresh-token chain age; an optional deny list for access tokens on long-running Node deployments.
- Move CI into
.github/workflows(blocked by a token scope) and runpnpm auditthere; one transitive build-time dependency (tmp, via the extension packer) has two advisories that never reach the runtime bundle. - An explicit Vercel function duration and a streaming
/filesresponse. - Tests for a mailbox with read off and send on, for the rate limiter end to end, and for cache eviction.
- A privacy notice and imprint on the vendor's site.
5. Properties verified as true
Each of these was checked in the code and, where marked, executed.
- Mailbox passwords are encrypted in the browser with AES-256-GCM before anything leaves it; the server receives only the 32-byte key at token creation, never the settings. (executed)
- A token alone does not yield passwords: unsealing needs the token and the edit password, which no assistant ever sees; the edit password is hashed with PBKDF2-SHA256 at 600 000 iterations in the browser and the server refuses fewer than 100 000. (executed)
- The server has no database and writes no user data to disk; configuration comes from environment variables or a startup file; OAuth artefacts are self-contained encrypted tokens. Nothing in the logs contains tokens, passwords or message text.
- Every key derived from the master key has its own purpose (seal, code, access, refresh, file, sign); ciphertexts cannot be replayed across layers; IVs are random per encryption; there is no padding oracle. (executed)
- PKCE S256 is mandatory; authorization codes live 3 minutes and are bound to client, redirect URI, PKCE challenge, host and user token; redirect URIs match exactly; error responses never redirect; the login form has a SameSite=Strict CSRF cookie compared in constant time. (executed)
- Client metadata documents are fetched with SSRF hardening: HTTPS only, port 443, no IP literals, private and loopback ranges refused on every DNS answer, the connection pinned to the vetted address, no redirects, 64 KB cap. (executed)
- Recipient strings that could expand into several SMTP recipients are refused; the SMTP envelope is built only from validated bare addresses; an empty allowlist refuses to send; domain rules do not match look-alike domains. (executed)
- Certificate validation is never disabled; every provider preset uses TLS or STARTTLS; IMAP commands cannot be injected through folder names, search strings or part ids.
- Permanent deletion is not implemented: trash moves to the Trash folder; only staged uploads and sent drafts are expunged, in folders mailmcp controls.
- Runtimes are isolated per token; a token can never reach another token's mailboxes, uploads or cache entries. (executed, end to end)
- Local file attachments (Claude Desktop) are confined to
policy.attachment_dirswith real-path resolution and a separator-aware prefix check. (executed) - The licence is verified offline with Ed25519 against an embedded public key; the software never contacts the author; there is no telemetry.
- The prebuilt distribution and the Claude Desktop extension contain no secrets, source maps or local paths; no secret has ever been committed to the repository. (executed)
- Security headers on every response: HSTS, nosniff, X-Frame-Options DENY, CSP with
frame-ancestors 'none',base-uri 'none',form-actionlimited to the server and the registered client, Referrer-Policy strict-origin-when-cross-origin,no-storeon pages that show or take secrets. (verified live)
6. Residual risks no configuration removes
- The AI vendor sees every tool result and keeps it under its own retention policy.
- The operator's process holds your decrypted settings in memory while serving you and for up to 15 minutes after. "Stores nothing" is a property of this code, not a cryptographic guarantee; a modified server could store anything.
- Stateless credentials cannot be revoked individually. Delete the app password at your provider to end access at once.
- A token issued with broad permissions stays valid after you issue a narrower one.
- Visible instructions inside e-mails still reach the model. Sanitization removes hidden text and prevents the framing from being forged; it cannot stop a model from complying with text it can read. The allowlist, the hourly limit and the absence of permanent deletion bound the damage.
- Throttles and replay guards are per instance on serverless hosting.
- One-hour links are bearer credentials that appear in hosting logs and browser history.
- Plain-text IMAP/SMTP (
tls: none) can be selected deliberately; presets never do.
7. Why your own deployment is the best option for a company
- You own the kill switch. Every credential on the server derives from the master key. Rotating it invalidates every token, OAuth token and link at once. On the shared server that lever belongs to the vendor.
- You remove the unverifiable honest-operator assumption. During every request the operator's process holds your users' passwords and mail in memory. Nothing in the protocol lets a user verify what code a remote operator runs. On your deployment the operator is you, under your change control.
- The blast radius is yours alone. The vendor's project also holds the licence signing key and the tokens of everyone who tried the free server. An incident there is everyone's incident.
- You control registration and offboarding.
MAILMCP_INVITE_CODEcloses token creation; your Google Workspace or Microsoft 365 admin can revoke app passwords per user, which is the real revocation path in this stateless design. - You choose the hosting party and region. Vercel sees the master key and plaintext requests after TLS termination. You pick the region, enable deployment protection and audit logs, or run the Docker image on your own hardware where no third party sees memory. That is what a DPO will ask for: a named processor under your DPA.
- No dependency on the vendor's uptime or business continuity. The licence verifies offline; the Deploy button clones the distribution into your GitHub; the running code is yours even if the vendor disappears. Tokens on a shared server die with it.
- You review updates. Updates reach you only when you pull a tag. The source is available to customers on request under the EULA.
- You tune the policy. Owner configuration sets token lifetimes, rate limits, attachment limits and can pin owner mailboxes.
What you give up: the Unlimited licence (€129, one-time) plus hosting (Vercel Hobby is non-commercial, so Pro or a small VPS), and someone who watches the distribution repository and redeploys; security fixes do not arrive by themselves.
When the shared server is reasonable: trying the product, and individuals whose concern is "I do not want OpenAI or Anthropic holding my Google account" and who trust a small Czech vendor about as much as their hosting. Use read and draft only, set an edit password, and prefer OAuth over pasting the token into config files.
When local is best: one person, one machine, Claude Desktop or Claude Code. No server, no operator, no links. The trade: passwords sit on the machine, and binary attachments go through the AI vendor as base64. Not available for ChatGPT or claude.ai on the web.
8. Notes for data protection officers
- Data categories touched: mailbox credentials (encrypted in the browser, decrypted in server memory per request), addresses and names of correspondents, message text, attachment names and, on request, contents, uploaded outgoing files (staged in the user's own mailbox), IP addresses (in-memory throttles and hosting logs), OAuth tokens (stateless, encrypted), licence buyer name and e-mail fingerprint.
- Roles: on the vendor's shared server the vendor is a processor and Vercel a sub-processor; on your own deployment your organization processes for itself and the author has no role; the AI vendor and the mail provider are separate controllers in every option.
- Sub-processors and recipients: the AI vendor (every tool result), Vercel (execution, logs, memory), the mail provider (IMAP/SMTP sign-ins from the server's egress address), Google Fonts (visitor IP on page loads), Lemon Squeezy (purchases), GitHub (downloads).
- Data subject rights: there is no account and no stored profile. Erasure is deleting the app password and discarding the token. Content already sent to the AI vendor is governed by that vendor's terms.
- What to document: records of processing naming the AI vendor, the host and the mail provider; Article 28 agreements with Vercel and with the AI vendor (zero-retention or enterprise terms change the analysis); a DPIA, since correspondence contains third parties' data and may contain special categories; a legal basis for correspondents' data; employment-law groundwork for staff mailboxes; user instructions about prompt injection and about what the AI vendor sees.
- Statements you can rely on are in section 5; the licence text contains no data-protection terms and does not replace a processing agreement.
9. Limits and defaults
| Setting | Default | Notes |
|---|---|---|
| Message body per read | 8 000 characters | policy.max_body_chars, 500 to 200 000 |
| Search results | 50 | policy.max_results, up to 200 |
| Attachment read into the chat | 2 MB | policy.max_attachment_bytes |
| Attachment download link | 25 MB | policy.max_download_bytes; link valid one hour |
| Outgoing attachments per message | 20 MB | policy.max_upload_bytes; on Vercel uploads through a link are limited to 4 MB per request by the platform |
| Sends per hour per mailbox | 10 | policy.send_rate_per_hour, per process |
| Recipients per field | 50 | to, cc, bcc |
| Access token / refresh token | 30 days / 90 days | server.access_token_ttl_seconds, server.refresh_token_ttl_seconds |
| Authorization code | 3 minutes | replay guard per process |
| Sign-in attempts | 5 per address and 30 overall per 15 minutes | per process |
| Token creation | 60 per address per hour | per process |
| Decrypted settings in memory | 15 minutes idle, 100 runtimes per process | RuntimeCache |
| IMAP connection idle | 60 seconds | one connection per mailbox per process |
| Mailboxes per licence | Personal 5 per token or configuration; Unlimited no cap |
10. What the test suite proves
88 automated tests: unit tests without network for cryptography, sanitization, policies, licensing, the Lemon Squeezy flow and the full OAuth flow; end-to-end tests against a real IMAP/SMTP server (GreenMail in Docker) for stdio and HTTP transports, shared multi-user mode, attachments in and out, uploads, forwarding, drafts and the ChatGPT connector contract. They prove the request-shaped attacks are handled: recipient smuggling, CSRF, PKCE and replay, SSRF against metadata and loopback addresses, tenant isolation, licence caps enforced server-side against client-built tokens, hidden-text stripping, delimiter forging through file names, path traversal for local attachments, tampered links. They do not prove behaviour under sustained load or across several serverless instances.
11. How the pages were corrected
The reviews compared every claim on the public pages with the code. Accurate: encryption in the browser, no database, offline licence verification, allowlist-only sending, no permanent deletion, one payment, 60-day refund, tier limits. Corrected: the memory window (15 minutes, not "during the request"), what the operator can see, which attachments enter the chat, what hidden-text stripping guarantees, and the nature of this audit. Claims about the official Gmail and Outlook connectors of ChatGPT and Claude describe those vendors' documentation as of September 2026 and cannot be verified from this code.
12. Independent second-model review
See the section appended below.