Version audited: 0.4.0 (2026-09-05). Fixes shipped as 0.4.1 and 0.4.2 (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 on the code after the first round of fixes; its findings, and what they changed, 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 from both reviews was fixed before publication (releases 0.4.1 and 0.4.2, all with regression tests); the Medium findings were fixed or are listed as accepted trade-offs with the reason. Fixes are claimed against commits in the repository, and section 12 records one case where the second reviewer caught a fix that had been described but not actually shipped.
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; the second-model review added 5 High and 15 Medium, again with overlaps. All High and most Medium findings were fixed in 0.4.1 and 0.4.2 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 and 0.4.2
| 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; forward_message is available to draft-only configurations as well. |
| Tools | High | Consuming a staged upload deleted the source message even from a mailbox the token could only read, and any message moved into the staging folder qualified. | Uploads are consumed only from mailboxes the token may write to, and only messages mailmcp staged itself (marked with its own header) are ever removed. |
| Tools | Medium | modify_message could move a message to Trash with the modify capability alone, and could move mail into the staging folder. | A move to Trash (by folder or Gmail label) requires the delete capability; the staging folder is reserved. |
| Tools | Medium | The hourly send limit lived inside the cached runtime, so eviction after idle time or cache pressure reset it within the hour. | Limiters are kept per token outside the runtime cache. |
| Tools | Medium | send_draft sent stored drafts regardless of the attachment policy and without a size bound. | Drafts with attachments are refused under a metadata-only policy; drafts are bounded like every other transfer and never sent incomplete. |
| 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; a name-based check alone could be bypassed with a public hostname that resolves to a private address. | User tokens may not name loopback, link-local, private or CGNAT hosts on public servers (opt-in for intranet mail servers); mail hosts are resolved before connecting, every answer is vetted and the connection is pinned to the vetted address with TLS still verifying the hostname. |
| HTTP | High | Request bodies were buffered before any size check; a chunked body without Content-Length bypassed the declared limit entirely. | Limits are enforced on the bytes actually read (streaming body limit): 1 MB on every route that parses a body, 50 MB on uploads, plus the per-token policy; 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 | High (vendor) | A licence key for an unrelated Lemon Squeezy product whose name contained "unlimited" or "personal" would have been accepted and signed. | Only the configured variant ids are accepted, product names are never trusted, and an optional store id pin refuses keys from other stores. |
| Licensing | Low | The Lemon Squeezy webhook had no replay guard; the first version of the guard marked an event as handled before processing succeeded. | Per-instance guard on event id, recorded only after success. |
| 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; the source Docker build broke when the audit page was added to the build. | Source maps off; handlers added; finally around SMTP; tags and release assets are immutable unless explicitly re-tagged; curated CHANGELOG.md; Docker build fixed and verified. |
| Setup page | Medium | Loading a token back into the form to add a mailbox silently reset policy fields the form does not show (a send limit of 0 became 10; attachment and token-lifetime settings reverted to defaults). | Fields not shown in the form survive the round trip unchanged; 0 stays 0. |
| Prompt injection | Medium | Hidden-class rules were lost when rendering link text; a hidden image still contributed its alt text; white text in rgb(100%,100%,100%) form passed; an out-of-range HTML entity threw; deep nesting cost quadratic time; the untrusted wrapper did not remove invisible Unicode from text attachments. | All fixed; nesting is bounded and dropped depth is counted, not scanned. |
| Local files | Low | Local attachments were checked and then opened separately, and special files were not refused. | The file is opened once, inspected through the same descriptor, must be a regular file, and is read up to the checked size. |
| Operations | Low | Sending quotas, connection state and other limits could be raised without bound by a user's own token; hosting logs saw the credential-bearing link paths in Referer headers. | Schema maxima independent of the token (mailboxes per token, bytes, rates); Referrer-Policy is no-referrer. |
| 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 and a stolen refresh token can be renewed until the underlying user token stops working. | Same reason: rotation without a revocation store gives no security benefit, and the design has no store. Deleting the app password at the provider ends every chain at once; on your own deployment rotating the master key does too. A bounded chain age is on the roadmap. |
| 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. Since 0.4.2 such tokens can only reach public mail hosts. |
Two concurrent send_draft calls for the same draft can both send it; SMTP cannot promise exactly-once delivery. | Known; the tool reports what was accepted. A per-draft lock is on the roadmap. |
| Generating your deployment configuration on someone else's setup page means trusting that page's code with the initial credentials. | Inherent. Companies should generate their configuration on their own deployment's /setup (or locally with the CLI), which is what the guide recommends. |
| 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. |
The vendor's instance loads Vercel Web Analytics (MAILMCP_ANALYTICS_SCRIPT, served by the deployment itself). | Page views, referrer, country, browser and device class as aggregate statistics; cookieless, visitors are a per-day hash, no identities. Only the deployment that sets the variable reports, and only to its own Vercel project. A self-hosted copy loads no analytics unless its operator opts in, and never reports to the vendor. |
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. - Whatever the assistant reads can leave through the assistant itself: its reply, or any other tool the user has enabled in ChatGPT or Claude. The permissions bound what mailmcp will do after a successful injection (no sending outside the allowlist, no permanent deletion); they cannot bound what the model does with content it has already read. That is the connector's edge, not a setting: connect only mailboxes whose content the assistant may see.
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 (€149, 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), Vercel Web Analytics on the vendor's instance only (aggregate page views, cookieless), Stripe (purchases; merchant of record since 0.6.0, previously Lemon Squeezy), 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 | Free 2 per token (tokens created before 0.7.0 keep 5), Personal 5 per token or configuration; Unlimited no cap | Free (no key) adds a "Sent with mailmcp.ai" signature to every composed message (0.5.0) |
0.7.0: the free tier caps new tokens at 2 mailboxes (seals carry iat; older seals keep 5). The seal may carry a Personal licence key (lic) verified per request with its expiry; no such keys are issued yet. Nothing new is stored.
10. What the test suite proves
88 automated tests: unit tests without network for cryptography, sanitization, policies, licensing, the purchase flow (Lemon Squeezy at the time of the audit, Stripe since 0.6.0) 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 (14 days since 0.6.1), 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
OpenAI Codex (GPT-6 Astra, high reasoning, read-only sandbox) received the same brief and worked from the repository after the first round of fixes. Its report ran to 26 findings. It confirmed the cryptographic construction, the OAuth protections, the recipient and envelope handling and the Bcc stripping, and it found no cross-tenant access path. Its top ten, and what happened to each:
| # | Finding | Severity | Outcome |
|---|---|---|---|
| 1 | Private-network check on mail hosts was string-based; a public hostname resolving to a private address bypassed it | High | Fixed in 0.4.2: hosts are resolved, every answer vetted, the connection pinned to the vetted address |
| 2 | Refresh tokens can be renewed indefinitely; /revoke does nothing | High | Accepted trade-off of the stateless design, documented in 4.2; app-password deletion or master-key rotation ends every chain |
| 3 | Consuming a staged upload deleted from a read-only source mailbox | High | Fixed in 0.4.2 |
| 4 | The 1 MB body limit relied on Content-Length; chunked bodies bypassed it | High | Fixed in 0.4.2 with a streaming limit |
| 5 | A foreign Lemon Squeezy product could become a signed licence through the name fallback | High (vendor) | Fixed in 0.4.2 |
| 6 | Silent truncation of oversized attachments and drafts | Medium | Fixed in 0.4.2. The first round had described this fix, but it had not reached the code: a scripted edit had failed and the change was lost. The second reviewer caught it. |
| 7 | modify_message could move to Trash without the delete capability | Medium | Fixed in 0.4.2 |
| 8 | Remaining hidden-text gaps and inconsistent untrusted handling of structured fields | Medium | Fixed in 0.4.2 (the HTML-part preference had likewise been described and not shipped; it is now in the code with tests) |
| 9 | Send quotas reset with runtime eviction | Medium | Fixed in 0.4.2 |
| 10 | The setup form weakened imported policy | Medium | Fixed in 0.4.2 |
Also fixed from its list: the source Docker build broken by the audit page, webhook events marked as handled before success, release assets overwritten under an existing tag, the local-file check-then-open race, the missing forward tool for draft-only configurations, invalid HTML entities throwing, and quadratic work on deeply nested HTML. Left as documented trade-offs: per-instance code replay guards, concurrent send_draft duplicates, generating a configuration on a third party's setup page.
Its independent opinion on deployment matched section 7: a company should run its own deployment, provided it maintains it, with restricted egress, conservative capabilities and narrowly scoped mailbox credentials, and it should not treat the split-key design as a substitute for those controls. The sentence "I would not rely on the present split-key marketing or published fixed claims as substitutes for those controls" is quoted here on purpose: the pages were reworded, and this document states fixes against released versions with tests.
Lesson recorded for the project: every fix in this report is tied to a test that reproduces the original problem, and a release is not called fixed until that test passes in the shipped version.