Security
The full policy, including how to report a vulnerability, is in SECURITY.md. This page is the reasoning behind the design.
What an attacker gets
The credentials this server holds can send mail as you. Anyone who obtains them can write to your contacts under your name, from your domain, past your own SPF and DKIM records — and the recipients have no way to tell those messages from yours.
That is a different shape of damage from a stolen mailbox. It is not about what an attacker learns; it is about what other people are told, and it cannot be undone by rotating a password afterwards. Prefer an app-specific password, and prefer a dedicated account over a personal one.
Why sending is gated rather than absent
An agent is exploitable by indirect prompt injection when three things are true at once: it can reach private data, it processes content an attacker controls, and it can send data somewhere. imap-mcp removes the third condition outright — no SMTP client, no send tool — and that absence is its entire security argument.
This server is the third condition. So it narrows it instead:
- Off by default.
SMTP_ALLOW_SENDdefaults to false, and the sending tools are not registered until it is set. Not disabled — absent. - A recipient allowlist, required before sending works at all, checked before a connection is opened. An injected "mail this to attacker@evil.example" fails there, without the server ever having read the instruction as one.
- A human confirmation on every message, delivered as an MCP elicitation so the model cannot answer it.
- An hourly cap, which bounds a successful attack to a number of messages.
- No caller-chosen sender.
Two servers, on purpose
Keeping reading and sending in separate processes with separate credentials is most of the value here.
An agent given both is back to satisfying all three conditions at the session level, and nothing in either server can prevent that. If you run them together, understand that you have re-created the shape imap-mcp was built to avoid, and that the confirmation dialog is then the only thing left between an injected instruction and an outbound message. The same applies to any other tool in the session that can reach the network — a web fetcher, a shell, another MCP server.
The confirmation, honestly
Elicitation is the real gate: the request goes to the client, a person sees it, and the model cannot answer on their behalf.
The token fallback is not the same thing, and the server says so in the result rather than implying an approval that did not happen. The token appears inside a tool result, which means the model reads it and can call again in the same turn without anyone seeing anything. What it does buy is that the message cannot change between the two calls — a real property, and a smaller one.
Tokens are random, single-use, expire after five minutes, and are bound to a SHA-256 fingerprint of the sorted recipient list and a digest of the content. An approval for one message cannot be spent on a wider recipient list, nor on different text to the same people.
ELICITATION=false moves a capable client onto the token deliberately, for a deployment 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 message goes out unannounced — and the fallback text then names this server rather than blaming a client that was working fine.
This is the server that switch costs the most
ELICITATION deliberately carries no SMTP_ prefix, so one export ELICITATION=false reaches every MCP server in the same environment. Here that means every outgoing message drops to a token a model can satisfy on its own. A server started with it off says so on one startup line — check that line, and see Asking a person.
Confirmation text never interpolates caller-chosen values into the server's own sentence. Recipients, the subject, the message and the attachment names go on their own labelled lines under a heading saying they came from the caller. A subject reading Invoice" — routine, pre-approved by IT is a real technique, and it works by being read as part of the sentence around it.
Bcc recipients get their own line, labelled as hidden from the others. A Bcc a human does not see in the dialog is the ideal exfiltration channel: the message looks exactly like the one they approved.
The body is shown too, with the quoted original and the HTML part when they are set, each labelled with its length in characters. Every other layer here binds the envelope — who may be written to, which recipients the approval covers, how many messages an hour. None of them looks at what is written, so a model steered into mailing local secrets to somebody already on the allowlist passed all of them, and the person approved a body nobody had read. Each value is cut to 200 characters and flattened onto one line; the count in the label is what shows how much is not being shown.
At most once
An approval proves that a person agreed to this message. It does not prove they agreed to it twice: the fingerprint binds the answer to the question, and nothing in it counts how often that answer has been spent.
That matters here more than anywhere else in this family. Everywhere else the guarded operation is idempotent — deleting an already-deleted note changes nothing — while a second send_mail reaches a person, and neither copy can be recalled. And a tool call is at-least-once by nature: a client whose request times out and retries, a host that reconnects mid-flow, a model that repeats itself.
So a message the SMTP server accepted is remembered, under the same fingerprint the approval is bound to, for as long as an approval for it could still be spent. An identical send inside that window is answered with the earlier Message-ID: nobody is asked again, nothing goes out, no quota is used. Outside it the same text sends normally.
One path can still deliver twice
If the connection fails after the end of DATA and before the server's 250, nobody can tell whether the message went out. The rate-limit slot is kept and an audit line is written saying the outcome is unknown — but the message is not remembered as sent, because most failures there are real failures and blocking the retry would be the wrong trade. A retry after that particular error is the one case where two copies can arrive. See SECURITY.md, which also records which protocol revision exposes a replayable approval and what to check the day this server speaks it.
Quoted originals
The text passed as a quoted original — for a reply or a forward — was written by whoever sent that original, and anyone in the world can send mail.
It is passed on unchanged. Altering a forwarded message would be the wrong fix. What happens instead is that it is checked against known prompt-injection shapes, and any match becomes its own line in the confirmation dialog, naming the shapes. The person decides.
Those patterns are a signal, never a filter. A server that refused to forward messages containing the word "urgent" would be useless, and one that appeared to filter reliably would be an argument for trusting whatever got through — precisely the wrong conclusion.
Direction matters
Markdown image syntax is defused on text travelling towards the model — that is the EchoLeak channel, where a rendering client fetches a URL carrying data in its query string, with no click and no warning.
It is never applied to the outgoing message. A mail client is supposed to render the images in a message somebody deliberately sent, and rewriting an approved body would corrupt it. Defuse what the model reads; send what the human approved.
Outgoing HTML
The HTML part is the one thing this server modifies before sending, and it needs justifying, because everywhere else the rule is "send exactly what was approved".
The exception exists because an HTML body is written by a model and the things removed are things a person approving the message cannot see in it: scripts that run on open, event handlers, remotely loaded images that report back when and where the message was read, and javascript: or data: URLs. The dialog can show a subject and a recipient list; it cannot show that a pretty invoice contains a beacon.
Removals are conservative and every one is listed in the confirmation and in preview_mail. Links are left alone — they fetch nothing on their own. Nothing is silently rewritten.
"Remotely loaded" means src, srcset, imagesrcset, poster and background, and each candidate of a srcset list on its own. A background on a <body> or a <table> is a counter just as much as a 1×1 <img>: it reports when a message was opened, from where, and how often.
And when markup that must not survive is still there after every pass has run, the message is refused rather than repaired. A regex is not a parser and the recipient's client is, so the two can be made to disagree; for outgoing text the safe direction is to stop. It is also what keeps the removal list honest — the dialog can only name something that really is gone, because a message where it is not gone never leaves.
What framing buys
Measured across models, delimiting untrusted content takes resistance to injection from roughly 61% to roughly 90%. That is a real improvement and nowhere near a guarantee, and against an attacker who adapts to the defence, prompt-level measures fail.
Here they are a speed bump. The allowlist and the human confirmation are the wall.