Releases: ni-c/smtp-mcp
Releases · ni-c/smtp-mcp
Release list
v0.1.0
Added
- Initial release: an MCP server that sends mail over SMTP, as the counterpart to
imap-mcp, which deliberately cannot. - A multi-architecture container image at
ghcr.io/ni-c/smtp-mcp(amd64 and arm64),
published with an SBOM and build provenance. It runs as an unprivileged user and speaks
stdio only, so it needs-iand exposes no port. - Seven tools. Four are always available and cannot put a message on the wire —
get_server_info,validate_recipients,preview_mailandtest_connection. Three send:
send_mail,reply_mailandforward_mail. SMTP_ALLOW_SENDdefaults tofalse. Note the direction: the read/write variables
elsewhere in this family guard changes to a system the operator already owns, while this one
guards a channel out of it. Until it is set the sending tools are not registered at all.SMTP_ALLOWED_RECIPIENTSis required to enable sending. An unset allowlist is refused at
startup rather than treated as "anyone"; allowing everyone is spelled*.- Every send asks a human through MCP elicitation, falling back to a two-call token bound to a
fingerprint of the recipients and the content.
ELICITATION=falsetakes that fallback deliberately, for a deployment where a dialog is the
wrong shape rather than an unwanted one — it never removes the guard, the fallback text then
names this server rather than blaming the client, and a server started with it off says so on
one startup line. The variable carries no prefix on purpose, so one export reaches every MCP
server in the environment; this is the one where that costs the most. A value that is neither
truenorfalsestops the server, likeSMTP_TLSand unlikeSMTP_ALLOW_SEND, because it
is the only variable here that defaults to on. - The sender is fixed by
SMTP_FROM. There is nofromparameter. - Hourly send cap (
SMTP_MAX_SENDS_PER_HOUR) and a per-message recipient cap
(SMTP_MAX_RECIPIENTS). - Attachments are read only from
SMTP_ATTACHMENT_DIR, behind an extension allowlist, an
executable-extension refusal, a symlink refusal and a magic-byte check. - Outgoing HTML is sanitised: scripts, event handlers, remotely loaded images and unsafe URL
schemes are removed, and every removal is reported rather than applied silently. Markup that is
still dangerous after every pass has run — the case a regex cannot resolve and the recipient's
parser can — refuses the message instead of repairing it, which is also what keeps the
reported removals honest. "Remotely loaded" coverssrc,srcset,imagesrcset,posterand
background, eachsrcsetcandidate on its own. - At most once. A message the SMTP server accepted is remembered, by the same fingerprint the
approval is bound to, for as long as an approval for it could still be redeemed; an identical
send inside that window is answered with the earlier Message-ID instead of going out again. An
approval proves that somebody agreed to a message, not that they agreed to it a second time —
and a tool call is at-least-once by nature, so a client that times out and retries, a host that
reconnects or a model that repeats itself would otherwise send a second copy.send_mailis
the one operation in this family that is not idempotent. SECURITY.md records which protocol
revision exposes a replayable approval, which one this server actually negotiates, and what to
check the day that changes. The residual case — a connection lost after the end ofDATA,
where the outcome is genuinely unknown — keeps the rate-limit slot and writes an audit line
saying so, rather than being covered over. - The confirmation dialog shows the body, and the quoted original and HTML part when set,
each labelled with its length in characters. Every other layer binds the envelope; nothing
looked at what the message said. The HTML part appears twice — as markup, and as the
recipient reads it, derived from the sanitised source, because the first 200 characters of
markup are mostly tags and on a message with an empty body the HTML is the message. The
dialog also says when the plain-text body and the HTML part say different things, since most
recipients see only the latter;preview_mailreports the same astext_html_diverge. - The injection detector's findings are kept per field, and the dialog says whose words matched:
a quote that gives orders is a forwarded message trying to, a body that gives orders is the
model writing them.preview_mailreports the fields assuspicious_in. - A quoted original is passed on unchanged; matches against known prompt-injection shapes are
surfaced in the confirmation dialog instead. - A subject may not contain an RFC 2047 encoded-word (
=?utf-8?B?…?=). It travels as ASCII and
decodes to something else at the recipient, so the human would approve a subject other than the
one that arrives. get_server_inforeportselicitation_enabledand aconfirmationfield that follows the
configuration, so it never claims a human is asked whenELICITATION=falsesays otherwise.- Every accepted message is recorded on stderr, and optionally in
SMTP_AUDIT_LOG. Never the
body. Refusals are logged too — a refusal by the allowlist or the attachment checks, a
declined dialog and a rejected token each write a line markedoutcome=refused,declinedor
token_rejected, with the recipients and subject that were asked for. A log of what went out
cannot show that a session was being steered; the refusals can. Audit lines quote every string
value and every array element, always, so a subject likeInvoice_bcc=[quiet@evil.example]
cannot hand a parser abccfield that never existed. - Every tool declares an
outputSchemaand answers withstructuredContentbeside the text
block, so a client does not have to parse prose to use a result.preview_mailcarries the
untrusted marker asuntrusted: trueandsource: "smtp"fields — a quoted original was
written by whoever sent it, and anyone in the world can send mail. Its text block keeps the
nonce fence; the structured half states the same fields rather than making a client parse it.
No other tool carries the marker, because no other tool reports anything this server did not
write itself. SMTP_ALLOW_TOOLS/SMTP_DENY_TOOLSnarrow the tool list;essentialselects a curated
five.- An integration suite in
test/integration/that drives the built server over real stdio
against a throwaway Mailpit container, sends through it and reads the message back to check
what actually arrived. - stdio is served through
serveStdio, so the connection's protocol era is negotiated on the
opening exchange rather than assumed, and a client that pins either revision is served the one
it asked for. - The tool filter, the approval flow, the host classifier and the documentation-asset generator
come frommcp-tool-allowlist,mcp-approval,mcp-internal-hostsand
svg-asset-setrather than from copies kept here. The approval flow in particular was
written here and in imap-mcp, and became a library once the two had grown near-identical copies
of it. None of the packages has a runtime dependency of its own, so this makes the tree smaller
rather than larger. The server runs on MCP SDK 2.0 and is built with oxlint and
TypeScript 7.
Security
- Every recipient is checked twice, and the envelope comes from the checked lists. A local
part is an RFC 5322 dot-atom and a domain is ASCII, becauseceo,anna@work.examplehas one
allowlisted domain to a naive parser and becomes two RCPT commands at the SMTP server — the
first a bareceothat a submission relay qualifies with its own domain. The message is
composed once and sent as raw bytes with an envelope built from the address lists that passed
the allowlist, never from the headers, so delivery follows what was approved. - The HTML sanitiser models the tokenizer, not a space. Attribute patterns start after
whitespace,/or a quote rather than requiring whitespace; scheme and remote checks run on the
decoded value, with character references and CSS escapes resolved and backslashes read as
slashes; and the final refusal check is built from the same element list as the removal passes.
A check that runs on the raw string is checking something no recipient's parser will ever see.
The passes are linear rather than merely bounded, and the HTML input is capped, so
preview_mail— which is reachable with no send gate, no confirmation and no rate limit —
cannot hold the event loop. - Every recipient and every attachment gets a line of its own in the dialog, numbered
i/N. A detail value is cut at 200 characters and six ordinary addresses already exceed
that, so a single line per field showed the first few and dropped the rest without saying
so — and the one that goes missing is the one that matters, because an address appended to
Bcc is invisible in the delivered message too. - A declined dialog costs a slot of
SMTP_MAX_SENDS_PER_HOUR. The quota is not only a
cap on messages sent; it is the only bound on how many times a person can be asked, and a
free decline is an unlimited supply of dialogs — the same message reworded until somebody
agrees out of tiredness. A token that does not match gives the slot back, because nobody
decided anything and the alternative would let a caller burn the hour with invented tokens. preview_mailanswers inside the same result budget as every other tool. It is the one
tool that returns a whole message and the one reachable with no send gate, no confirmation
and no rate limit, and it had no budget at all: a 64 kB HTML part with a distinct unsafe
URL scheme per link produced 366 kB across the two channels. The list of removals is capped
and says how many ar...