Changelog
0.5.2 - 2026-10-04
Fixed
save_draftkeeps the\Draftand\Seenflags after a read-only call. The append ran on whatever mailbox the last call had left selected, and imapflow keeps only the flags that mailbox lists inPERMANENTFLAGS. A read-only select (EXAMINE) lists none on Dovecot, so a reply draft, or any draft saved afterlist_messages,get_message,get_attachmentsor the mailbox resource, landed without its flags and without an error. The Drafts folder is now selected read-write for the append. Reported and fixed by @alvarocarrera (#35, #36).get_server_inforeports the flags the mailbox can store. It read them through a read-only selection, which lists none on Dovecot, so it said the new-mail keyword could not be stored whilelist_new_messagesstored it fine. It now also reportspermanent_flags_reported, and a server that sends noPERMANENTFLAGSat all no longer makes it, orlist_new_messages, fail.set_message_flagsrefuses a flag the mailbox does not store, and writes nothing. imapflow dropped such a flag without an error, and the result still listed it underadded.save_draftreports the flags the draft was stored with, and says so when the Drafts folder does not store\Draftor\Seen.
0.5.1 - 2026-09-29
Fixed
- The process no longer exits when the server drops an idle connection. imapflow reports a dead socket as an
errorevent, and with nobody listening Node ended the process about 20 seconds after the first call, taking every tool with it. The connection is now dropped and the next call reconnects. Reported and fixed by @Reconnact (#31, #32). - A connection the server closes cleanly, without an error, is dropped as well, so the first call after it no longer fails once on the dead connection before the retry reconnects.
- Shutting down no longer throws when the logout fails after the connection has already reported its close.
- An attachment name keeps a leading dot only when that dot names its extension. A name like
..!(or︰!, which Unicode normalisation turns into it) came out as.!, dotfile-shaped with nothing behind the dot to check. The.execase from 0.5.0 still keeps its dot, so the executable check still reads the extension.
Changed
- imapflow 2 (from 1.7), and the Node base image of the container moved to the current digest.
0.5.0 - 2026-09-07
Added
- The server introduces itself in full.
title,description,websiteUrlandiconsnow travel withnameandversion, so a client that shows a server to a person has something to show. All four were already inserver.jsonfor the registry and reached no client at all; a test compares the two so they cannot drift. - An OpenSSF Scorecard run, weekly and on every push to
main, reporting into the Security tab next to CodeQL and Trivy. The badge is the second in the row.
Changed
- Source maps are no longer published in the npm tarball. Node reads them only under
--enable-source-maps, which nothing here sets, and the maps pointed at asrc/this package does not ship — so a stack trace under that flag named a file nobody could open.dist/**/*.jsis unchanged; the package is about a fifth smaller.
Fixed
hasAttachmentsin every message summary was alwaysfalse: the fetch behindlist_messagesandlist_new_messagesnever asked for the body structure the projection reads it from. It does now.- The two-call fallback prompt for
move_messagesin copy mode told the caller to callcopy_messagesagain — a tool that does not exist. It namesmove_messages; the copy and move keys stay distinct. - A reply to a message whose id is not shaped like a Message-ID (a control character, an unbounded length) is saved without an
In-Reply-Toheader instead of failing with "must not contain line breaks". - A connection factory that throws synchronously no longer leaves a rejected promise in the client that answered every later call for the life of the process.
Security
- mcp-approval 0.8.2. A sealed dialog answer is single-use since 0.8.1: the same
requestStatepresented again within its lifetime used to be accepted again, and with a resource key that is the same every time — a whole stream, a fixed set of targets — every replay landed. npm users on^0.8.0already had the fix; the Docker image is built from the lockfile and carried 0.8.0 until this release. - A refused connection is not retried for ten seconds. Every tool call opened the connection lazily and a failed one was not kept, so every call after a refused login was another LOGIN against the provider — and "check IMAP_USER and IMAP_PASSWORD" is exactly the answer a model retries. Providers lock an account after a handful of those. The refusal is now answered from memory, marked as repeated and naming when the next real attempt is possible; the one reconnect a dropped connection is allowed does not clear it.
list_mailboxesis bounded on a server without LIST-STATUS. imapflow falls back to one STATUS per folder there, with no ceiling, and the command timeout around the call does not end the commands: a namespace of a few thousand folders turned one listing into minutes during which no other call answered. The fallback now lives here — counters for the first 100 selectable folders within a 20-second budget, the rest listed without counts and counted asstatus_omitted; at most 1000 folders per listing, with the server's total reported.- Sender strings beside the fence are bounded and cleaned. The
Authentication-Resultsauthserv-id was unbounded and an attachment's declared content type went out as written, both into theget_messagemetadata block — which has its own budget and nothing array-shaped to shrink — so a sixty-thousand-letter header made the message unreadable through this server, and a hundred-kilobyteContent-Typedid the same and was quoted into refusal texts the budget never measures. The id is capped at a hostname; a declared type that is not shaped like a media type is reported asapplication/octet-streamwith a note that does not quote it; aReferencesid carrying a control or invisible character is dropped; flags that are not atoms are cleaned and the list bounded; a declared attachment size that is not a safe integer (imapflow turns1e400intoInfinity, which JSON writes asnull) no longer fails the listing's output schema. - Configuration values are checked for shape and never echoed.
ELICITATIONprinted the raw value on refusal (got "…"), one line fromIMAP_PASSWORDin every compose file.IMAP_DOWNLOAD_DIRwent intoget_server_infoand every attachment listing as typed; it now has to name an existing directory, is stored as its real path, and the refusal says how long the value was.IMAP_ATTACHMENT_TYPESentries have to be media types (the refusal names the position),IMAP_MAILBOXandIMAP_DRAFTS_MAILBOXfollow the mailbox parameter's rule,IMAP_USERis one line,IMAP_HOSTandIMAP_TRUSTED_AUTHSERV_IDare bounded to a hostname, andIMAP_SEEN_KEYWORDto 64 characters. get_server_infocleans the two lists the mail server writes. Capabilities and permanent flags are answered in this server's own voice; a permanent flag on a shared folder is a keyword a colleague chose. Each is cleaned and bounded, and so is the hierarchy delimiter in every listing.- Error messages from the library and the runtime are bounded and escaped before they reach the model. A TLS failure quotes the certificate's subject names, which the other end chose; a refused resource URI is quoted as a 200-character cleaned copy.
- Character references decode the way a mail client decodes them. The decoder ran six replacements in sequence with bounded digit runs and a required semicolon, so
hdecoded twice to anhno client shows andhstayed as eleven literal characters where the recipient sees anh. One alternation, one pass, any digit run, semicolon optional; zero, surrogates and values past U+10FFFF become U+FFFD as a browser renders them. The same in the OOXML reader. - Every string that leaves is well-formed UTF-16. A cut at the result budget, at a paging window or at a cell cap could split a surrogate pair; JSON carries the half as an escape and a Python client then fails to encode it.
npm ci --ignore-scriptsin the release workflow, in the job that holds the OIDC token for npm Trusted Publishing and in the one before it — every dependency's install hook used to run while that token was available.gh release createverifies the tag. Pull requests rundependency-review-action, which checks the change wherenpm auditchecks the tree.- The image no longer ships yarn, corepack or the lockfile. npm was already removed from the runtime layer; the two package managers beside it and a
package-lock.jsonnothing reads were not. - pdf.js 6.1.200, as bundled by unpdf 1.8.1, is in the range of CVE-2026-16633 (arbitrary JavaScript on opening a malicious PDF, fixed in 6.2.108). The vulnerable path is the annotation layer binding a form field's actions to DOM events under
enableScripting, and this server renders no annotation layer, no form and no DOM: it callsgetDocument,getPage,getTextContent,viewanddestroy, and a test now holds the source to that set. No scanner sees the bundled copy, which SECURITY.md said would happen; a test pins the bundled version so the next unpdf bump revisits the note rather than outlives it.
0.4.0 - 2026-09-04
Added
get_attachmentstakesmode: "text": the text of a PDF, Word, Excel, PowerPoint or OpenDocument attachment, extracted on the server and returned fenced as untrusted content.This is the only way to read a document from a client that cannot reach this server's filesystem — which is every remote one.
IMAP_DOWNLOAD_DIRdoes not help there: the file lands wherever the server runs, and the result is a path the caller cannot open. Without it the same attachment fell through to base64 and was refused for not fitting the result budget, which is a correct refusal and a useless one.offsetandmax_charspage through anything longer than one result can hold. The answer carriesnext_offset,total_charsandreturned_chars, and the notes spell out the exact follow-up call. Injection signals are computed over the whole document rather than the window, so a payload on the first page still raises the banner on the call that reads the third.Spreadsheets come back as tab-separated values with a
== Sheet: name ==heading per tab; presentations get one per slide. There is no OCR: a scan has no text layer, and the refusal says so and names the two ways to get the bytes instead of returning an empty string that reads like an empty document.Parsing happens in a child process with a heap limit and a timeout — a process rather than a worker thread, because a thread's memory limit was measured to fail for one allocation pattern and to keep a gigabyte resident after termination. In front of that boundary sit the guards that actually bound memory: a PDF's compressed streams are measured before PDF.js inflates them and refused past 32 MB for one stream or 128 MB for all of them (Flate, LZW and RunLength, behind an ASCII wrapper or not); a container's entries are bounded per entry and in total, counting only the entries the allowlist admits; and spreadsheet output is budgeted per row with every cell capped at four kilobytes, so the child never builds more than the million characters it may return. A document with more pages than the reader walks, or more slides, reports how many it declared. Word documents count the runs a reader would not see — hidden, white, or set at two points or smaller — the way PDFs count text drawn off the page, and text a tracked change deleted is not returned as content.
SECURITY.mdhas the measurements.IMAP_MAX_EXTRACT_BYTES, default 10 MB, ceiling 64 MB. A third size limit because there is a third question:IMAP_MAX_ATTACHMENT_BYTESbounds what may enter the model's context,IMAP_MAX_DOWNLOAD_BYTESbounds what may be written to your disk, and this one bounds how much input a parser is handed. It is the one with a hard maximum, and a value above it refuses to start rather than being clamped: the other two buy a big answer, this one buys a buffer inside a parser working on bytes a stranger chose.Attachment listings carry
extractable, andget_server_inforeportsattachment_text_extraction. Both exist so the option can be seen before anything is fetched — which matters most whereIMAP_DOWNLOAD_DIRpoints somewhere the caller cannot reach.
Fixed
One injection pattern was quadratic, and it ran in the main process. The
fake-delimiterheuristic (---,===,###before a word likesystem) backtracked once per possible length at every position of a run: 40 000 hyphens took 1.5 s, and the million characters an extracted document may carry would have held the whole server — its transport is stdio — for about a quarter of an hour, after the parser child had exited and outside every timeout. A message body at its 50 000-character cap cost about two seconds perget_messagein released versions. The pattern is now anchored to the start of a run, which makes it linear without capping the length of rule it recognises, and a test times every pattern on a million characters of its own trigger.A
:in a folder name could make two moves share one confirmation. The confirmation key joined source and destination with:, and mailbox names may contain one.("Inbox:Old" → "Archive")and("Inbox" → "Old:Archive")produced the same key for the same messages, so a token or an accepted dialog for the first pair executed the second, which nobody was asked about. Same for a rename. Both keys now JSON-encode their names.An IMAP server's error text reached the model unlabelled, as if it were the next sentence of this server's own message. It is now introduced as the mail server's words, and any invisible character in it is spelled out.
The size limits other than
IMAP_MAX_ATTACHMENT_BYTESdid not apply. The declaration check ran with the inline ceiling regardless of what the call asked for, and it runs before the destination is chosen — somode: "file"could never save anything larger than the inline cap, although the code said it could. The policy now takes the mode. A listing, which has no mode, uses the widest ceiling any mode could reach for that part, soallowedanswers "is this reachable at all".Numeric character references are decoded.
’in HTML mail, and the numeric umlauts OOXML writes throughout, used to arrive as literal text.</w:p>and friends: the tag-name pattern did not match a colon, so a namespaced closing tag read as its prefix alone.
Changed
mode: "auto"extracts the text of a document when there is no download directory to save it to. With one configured it still saves, so no existing installation changes behaviour; thesavedresult now namesmode: "text"for the case where that directory is not reachable from where the caller runs.Two new runtime dependencies,
unpdfandfflate, both MIT and both with no dependencies of their own. Note thatunpdfvendors PDF.js into its own bundle, sopdfjs-distdoes not appear in this package's dependency tree and a PDF.js advisory raises no Dependabot,npm auditor Trivy alert here. Anunpdfbump is a security bump; seeCONTRIBUTING.md.
0.3.0 - 2026-09-03
Added
Every tool declares an
outputSchemaand answers withstructuredContentbeside the text block. A client no longer has to parse prose to use a result.Every tool that reports anything out of the mailbox carries
untrusted: trueandsource: "imap"as fields, not only as a preamble in the text. A sender display name, a folder name a colleague chose and an attachment filename are all attacker-controllable and reach the model through the listing tools long before anyone opens a message, so a client that reads the structured half must not get them unframed.get_server_infoand the five write tools do not carry it: those report this server's own configuration, or what it just did with the uids it was given.get_messageand a text attachment keep the nonce fence in the text block; the structured half states the same fields rather than making a client parse it. An image attachment keeps its bytes in the content block, where a client renders them, instead of repeating the base64.
Changed
The advertised schemas avoid spellings that are legal JSON Schema and still get a tool refused, or its constraint silently dropped, by some MCP clients: an open object now writes
"additionalProperties": truerather than the empty schema{}zod emits for it; a value that was left untyped is declared as what it really is; and a nullable field is written asanyOfbranches rather than"type": ["string", "null"], which several clients read as a single type and then drop. What the tools accept and return is unchanged; only the way the schema says so is.Four refusals are error results rather than plain ones. An attachment the policy rejects, one whose bytes are an executable whatever it declared, one too large to return inline, and the
manage_mailboxrename prompt. Each read like an answer while being the opposite, and a tool that declares an output schema may not answer withoutstructuredContentunless the result is an error.A result too large to shrink is an error rather than an envelope carrying the oversized document as a string. That envelope is valid JSON and no longer a valid answer: the SDK checks a result against the schema its tool declares.
The two-call
confirm_tokenprompt is an error result, for the same reason. The text is unchanged and still carries the token.A
confirm_tokenthat does not match its arguments is refused with the reason instead of being answered with a fresh prompt. The binding is unchanged — a confirmation issued for one set of UIDs still cannot delete another, and one for INBOX cannot delete from Archive — but the answer now says which of the two happened. The rename branch ofmanage_mailboxis unaffected: it uses the plain two-call token and still re-prompts.move_messagesnow asks a person, for bothmoveandcopy. It was on the token alone, on the grounds that a move destroys nothing, and its own comment already said what is wrong with that:destinationis a free-form mailbox name, so on a shared account or a public namespace one call hands every named message to everyone who can read that folder. Disclosure is the part that cannot be taken back, and a token only proves the model agreed with itself.The binding is unchanged: the exact UID set and both mailboxes, with different keys for
moveandcopy.ELICITATIONswitches the dialog off —falsesends a client that could have been asked down the two-call-token path instead. For a scheduled job or a test harness, where a dialog is the wrong shape rather than an unwanted one.It does not remove the guard: there is no setting in which a guarded call goes unannounced. Two deliberate rough edges come with it. The variable is not prefixed, so one
export ELICITATION=falsereaches every MCP server in the environment — which is why a server started with it off prints a line saying so, and why the fallback text names the server instead of blaming a client that was working fine. And a value that is neithertruenorfalsestops the server, likeIMAP_TLSand unlikeIMAP_READ_ONLY: this is the only variable here that defaults to on. It is read afterIMAP_PASSWORDis wiped from the environment, so that exit cannot leave the password behind.A
docs/guide/approval.mdpage.delete_messagesandmanage_mailboxname themselves in the fallback text rather than saying "call this tool again".Runs on MCP SDK 2.0. Existing clients see the same protocol revision they always did; the change is the package layout behind it.
The linter is oxlint instead of eslint plus typescript-eslint, which lifts the TypeScript ceiling: typescript-eslint pins
typescriptbelow 6.1, so this repository was held on TypeScript 6 by its linter rather than by its code.The tool filter, the confirmation store, the approval flow and the documentation-asset generator now come from
mcp-tool-allowlist,mcp-approvalandsvg-asset-setrather than from copies kept here — 1000 fewer lines. The approval flow was written in this repository and cut into a library once smtp-mcp had grown a near-identical copy of it; the behaviour is the same, with one owner. None of the packages has a runtime dependency of its own.stdio is served through
serveStdio, so the connection's era is negotiated on the opening exchange rather than assumed. A client that pins the2026-07-28era is served it; until now itsserver/discoverprobe was answered with "Method not found" and only2025-11-25was on offer. A client that speaks the older era sees no change — it is still pinned to one instance for the life of the connection, exactly as a hand-wiredStdioServerTransportserved it.
Fixed
- An entry in
IMAP_ALLOW_TOOLSthat is not tool-name-shaped is now redacted in the error rather than quoted back.IMAP_PASSWORDandIMAP_ALLOW_TOOLSare adjacent lines in every compose file, and a paste into the wrong one used to print the credential into the client's log.
Security
A single mail could stop the server for half a minute.
htmlToTextwas a chain of regexes with bounded scan windows, and a bounded window bounds one factor of a product: the other is how many scans an input can start. Atext/htmlbody of'<style 'repeated 73 000 times is 512 000 legal bytes that start 73 000 scans and finish none, and it measured 33 seconds — Node is single-threaded and the transport is stdio, so nothing else was served meanwhile. The IMAP command timeout could not help; it wraps commands, not parsing, and itssetTimeoutcannot fire on a blocked event loop. Two more shapes did the same, and the tag stripper itself was scanning without any bound at all:'<a 'with no>in the document took 57 seconds.The pass is now one forward walk with cursors that never rewind, plus one global budget for closing-tag searches — a cap on the product rather than on either factor. The same inputs are under 20 ms. Stripping is still best effort and the fence is still what carries the weight.
The test that was supposed to catch this measured 238 ms, because its payload was longer than the 512 000-character input cap and the hostile half was sliced off before a regex saw it.
Folder names went to the model unsanitised. Every other mailbox string goes through
sanitizeTextorsanitizeFilename;list_mailboxesreturnedpath,nameandspecialUseraw, so a right-to-left override survived, and so did— the beacondefuseAutoFetchexists to take apart, arriving through the one door without it. Each entry now carries a sanitiseddisplay_namebeside the verbatimpath, and aname_warningspelling out the difference when there is one.pathstays verbatim on purpose: it is the argument the other tools take.The confirmation dialog shows folder names with their invisible characters removed and escaped beside them —
ArchiveandArchive<U+200B>are the same pixels, so the gate was asking about one folder and acting on another. The audit log on stderr escapes them too, which is what its own comment always said it did.mailboxParamnow refuses the whole C0/C1 range rather than\r\n\0alone.get_message(include_thread: true)returned up to 2.9× the stated result budget. It went out through a path that never touchedbudgetedJson, and fifty thread summaries with capped 2 000-character subjects and 4 000-character address lists are 10 kB each — all sender-chosen. The metadata block is now budgeted to a quarter of the result with the thread list as the first thing dropped, and the assembled result is checked as a whole, which also catches the growth from defusing images and from the per-line datamarks.Two entries of the executable blocklist could never match.
extensionOfread extensions with/\.([A-Za-z0-9]{1,10})$/, soappref-ms(hyphen) andapplication(eleven characters) both read as no extension — and no extension makes the check skip rather than fail. WithIMAP_DOWNLOAD_DIRset,Rechnung-2026.appref-msdeclaredapplication/xmlreached the disk under its own name with nothing noted against it; a ClickOnce manifest is valid XML, so the magic-byte check had nothing to object to either. A test now walks the whole blocklist and requires every entry to be refused.
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.