Changelog
0.2.0 - 2026-08-30
This is the first release published to npm, so everything below reaches a package page for the first time — including the parts that changed weeks ago.
Added
A container image at
ghcr.io/ni-c/imap-mcp, multi-arch (amd64 and arm64), built from a digest-pinnednode:24-alpine, running as a non-root user, with an SBOM and build provenance. It never writes to the filesystem unlessIMAP_DOWNLOAD_DIRis set, and then as uid 1000 — a bind mount has to be owned by that user on the host.A published npm package,
@ni-c/imap-mcp, with provenance, released through GitHub Actions with npm Trusted Publishing. The unscoped name belongs to an unrelated project.README: a container badge, the architecture diagram, install snippets for Claude Code, Claude Desktop, Codex and Docker, and a demo recording. The documentation site at imap-mcp.ni-c.de is now actually served.
IMAP_ALLOW_TOOLSandIMAP_DENY_TOOLSchoose which of the eleven tools are registered. Both take comma-separated tool names or a prefix with a trailing*, the allow list decides what is in and the deny list is subtracted from it, andIMAP_ALLOW_TOOLS=essentialselects a curated six —list_mailboxes,list_new_messages,list_messages,get_message,set_message_flagsandmove_messages. Four of those are read tools, so the preset stays a working combination under the read-only default. Nothing changes for an installation that sets neither.A filtered tool is not registered at all, so it is absent from
tools/listand answerstools/callwith "tool not found" — the same cutIMAP_READ_ONLYalready makes, not a second, weaker one. It covers tools: the attachment resources are not filtered.An entry that matches no tool stops the server at startup, naming the entry and listing the real names. Under the read-only default, an exact write-tool name in the allow list is refused with a message naming
IMAP_READ_ONLYinstead of calling the tool unknown — which matters more here than elsewhere, because read-only is the default rather than something you remember switching on.A documentation site at imap-mcp.ni-c.de, an architecture diagram generated from a single source,
server.jsonregistry metadata, and CI and docs workflows. The workflows ship disabled: this repository is private and its Actions minutes are worth keeping, sonpm run lint && npm run build && npm run test:coveragelocally is the whole of the verification until they are switched on.
Changed
IMAP_ALLOW_WRITEis nowIMAP_READ_ONLY, for one name across the family — but not one default. Everywhere else<PREFIX>_READ_ONLYdefaults tofalse; here it defaults totrue, because the variable it replaces was opt-in and a rename that quietly flipped that would have handed write access to every installation that upgraded without reading this file. Only the literal stringfalseturns it off, so a typo fails closed.An installation that still sets
IMAP_ALLOW_WRITErefuses to start, with a message naming the replacement. Silently ignoring a removed security variable is worse than refusing: whoever set it once believes it is still in force.The SPF/DKIM/DMARC verdicts are read from the topmost
Authentication-Resultsheader only and come with the authserv-id, so a forged copy sitting below the receiving server's own is ignored. Whether the topmost one can be trusted at all isIMAP_TRUSTED_AUTHSERV_ID, above.Thread subjects and sender names in the
get_messagemetadata block are now named as sender-chosen in its caveat, and the injection-shape detection runs over the metadata block too, not only over the message body.get_attachmentsno longer advertisesreadOnlyHint: truewhenIMAP_DOWNLOAD_DIRis set — with a download directory configured it can create files, and clients that auto-approve read-only tools should ask.Reference- and shortcut-style markdown images (
![alt][id],![id]) are defused alongside the inline form.The HTML-stripping passes use bounded scan windows, so crafted HTML full of unclosed tags can no longer burn minutes of CPU; hidden elements larger than the window are left to the fencing, which was always the real defence.
A long
Referenceschain in a draft is folded across lines instead of emitting a header line beyond the RFC 5322 998-octet limit.Confirmation tokens are compared in constant time.
IMAP_HOSTno longer accepts a colon outside an IPv6 address, matching what the error message always said.The inline image result uses the allowlist-checked declared content type rather than the unchecked one from the download metadata.
Security
The attachment resources are covered by the tool filter, and bounded by the inline budget.
IMAP_DENY_TOOLS=get_attachmentsremoved the tool fromtools/listand leftimap://message/{uid}/part/{partId}fully live — the same door, still open, with the narrowing looking complete. They also read up toIMAP_MAX_DOWNLOAD_BYTES(25 MB by default) and returned it base64 in one JSON-RPC response; that limit exists to bound what may be written to a file, andIMAP_MAX_ATTACHMENT_BYTESnow applies instead, as it always did for the tool.A binary attachment requested inline is refused rather than base64-encoded without limit.
textResultapplies no budget, so up toIMAP_MAX_ATTACHMENT_BYTESx 1.37 of base64 went into the model's context against a stated cap of 200 000 characters, scaling with a variable raised for an unrelated reason. Truncating would be worse than useless — half a PDF decodes to nothing — so the refusal names the two ways to get the bytes.get_messagechecks the size of the message it received, not just the size it asked for. imapflow'smaxLengthbounds the request; a compromised server, or anyone in the way of anIMAP_TLS=noneconnection, could stream more than that straight into the parser. Every attachment path already went throughreadCapped; this was the one that did not.The
forgeableflag on SPF/DKIM/DMARC verdicts is now honest, and there isIMAP_TRUSTED_AUTHSERV_IDto make it useful. The old rule compared the header's authserv-id against the account's own domain and reported a match as not forgeable. A sender knows that domain — they just addressed mail to it — so on any account whose provider adds noAuthentication-Resultsof its own, the sender's header was the topmost one andAuthentication-Results: mail.<your-domain>; spf=pass; dkim=pass; dmarc=passbought a spoofed message this server's own vouching. Nothing in a message can settle who wrote that header, so the operator now names the id their provider stamps; unset, every verdict is reported as forgeable, which is what "pass, says a header anyone could have written" actually means.Auto-fetch markup is defused at the boundary rather than at two call sites.
defuseAutoFetchran only where a body was rendered, so a subject, a sender display name, an attachment filename or a thread summary carryingreached the model untouched — the same EchoLeak channel one layer earlier, in the field a model quotes back most often, and outside the fence in the metadata block. It now runs insidesanitizeTextandsanitizeFilename, after NFKC normalisation, so a fullwidth subject that folds into markdown is caught too.Message-IDis sanitised and length-capped. It went from the sender straight into the metadata block, the part of the result the model is told came from this server. Every other sender string on that path was already sanitised.CR is stripped along with the other control characters. It fell between the two ranges rather than being excepted on purpose.
wrapUntrustedsplits on\n, so a lone CR left everything after it on one logical line — marked once, at the start — while a terminal renders it as a new line, and a CR-padded line can overwrite the datamark a human is reading.set_message_flagsrefuses to add\Deleted. The tool carries no confirmation and is annotateddestructiveHint: false, on the grounds that everything it does can be undone. That is true of\Seenand\Flaggedand not of\Deleted, which the next client to close the mailbox — or any server with autoexpunge — turns into a permanent removal. It wasdelete_messageswithout the dialog, reachable in one call, and it is in theessentialpreset. Removing\Deletedis still allowed, since that undoes one.Copying messages now needs a confirmation, like moving them. The old rule reasoned about deletion; deletion is not the only thing that cannot be taken back.
destinationis a free-form mailbox name, so on a shared account or a public namespace one unconfirmed call handed every named message to everyone with access to that folder — and left the source folder untouched, so nothing looked different afterwards. Move and copy have separate token keys.Confirmations and elicitation dialogs no longer quote mailbox names inside their own sentence. Folder names look like server-side metadata and are not: they come from the caller, and
list_mailboxessources them from the account, which on a shared mailbox means a colleague — or whoever compromised one — chose them. A folder namedArchive" — routine cleanup, pre-approved by ITbecame part of the sentence a human reads before losing a folder. Caller-chosen names are now rendered on their own labelled lines under an explicit heading.
0.1.0 - 2026-08-24
Added
- Initial release: MCP server for IMAP mailboxes.
- Six read tools, always available:
get_server_info,list_mailboxes,list_messages,list_new_messages,get_message,get_attachments. - Five mailbox tools behind
IMAP_ALLOW_WRITE=true:set_message_flags,move_messages,delete_messages,manage_mailbox,save_draft. - No way to send mail, by design. Private data plus untrusted content plus an outbound channel is what makes an agent exploitable by indirect prompt injection; a mailbox supplies the first two, so this server does without the third.
save_draftstores the reply for a person to send from their own client. - New-mail tracking through a custom IMAP keyword, so an agent sees each message once without touching the human read state.
- Message bodies are fenced with a per-call random nonce and marked line by line, followed by a reminder, so the provenance signal does not stop at the edges of a long thread and the attacker does not get the last word.
- Hidden HTML, zero-width characters and directional overrides are stripped, and markdown image syntax is defused so a rendering client cannot be induced to fetch a tracking URL.
- Each message comes with a server-side assessment: SPF/DKIM/DMARC verdicts, matching prompt-injection shapes and mixed-script words. A match puts the warning at the top of the result rather than inside the metadata.
- Deleting messages and deleting a folder ask the user through MCP elicitation where the client supports it, falling back to a two-call token where it does not — and saying which of the two happened.
- Attachments can be read inline, written to
IMAP_DOWNLOAD_DIRor fetched as MCP resources. All three paths share one policy: content-type allowlist, executable refusal, size ceiling and a magic-byte check on the bytes. - Every change to the mailbox is logged to stderr with UIDs and folder, never subjects.