Changelog
[0.1.0] - 2026-09-03
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 neithertruenorfalsestops 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" covers
src,srcset,imagesrcset,posterandbackground, 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,declinedortoken_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 from
mcp-tool-allowlist,mcp-approval,mcp-internal-hostsandsvg-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, because
ceo,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, sopreview_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 are not named, and it is reported as data rather than in the server's own voice — a scheme is whatever the caller wrote before a colon, and that text used to sit outside the untrusted fence.- The confirmation dialog cannot be forged from its own contents. Recipients, subject, Bcc and body each stand on their own labelled line rather than inside a sentence the server wrote, and the line-breaking codepoints that would fake such a line under
white-space: pre-wrap(U+2028, U+2029, U+000B, U+000C, U+0085) are rejected — refusing CR and LF is not enough. Caller-derived text stays out of the dialog'swhatandconsequence, which are not capped by the approval library, and appears only in details, which are. - An attachment is checked on the handle it is read from. The file is opened non-blocking, so a FIFO substituted between
lstatandopencannot hang the handler; type and size are checked again on the open handle; and the read is capped at the size that handle reported. - A message that cannot fit is refused before it is built. Ten attachments at the default ceiling are 67 MB once base64 has run over them; a lower bound is taken before composing, so the oversize case is refused rather than assembled and then rejected. A result too large to shrink is an error rather than an envelope carrying an oversized document.
- Defaults leave out the attachment types that carry their own execution.
text/htmlopens in a browser with none of a mail client's restrictions and none of the sanitising the HTML part of a message gets;application/zippasses the magic-byte check on its own bytes and can carry the executable that check never sees. Both remain available throughSMTP_ATTACHMENT_TYPES. - The rate limit reserves rather than checks. MCP clients call in parallel, and checking first and counting after leaves the whole SMTP round trip open as a window.
- The approval fingerprint binds what was actually approved: the arguments rather than the composed bytes, which carry a
Dateand a random Message-ID; the To/Cc/Bcc split rather than the multiset of addresses, so an approved visible recipient cannot be moved into Bcc afterwards; and the attachment bytes, so the file cannot be swapped between the two token calls. - Both credentials are removed from
process.envonce the configuration is read, not just the password: a user name is half a credential and, for most providers, the mailbox address. An upstream error that is an HTML page is dropped however it is prefixed — nodemailer appends the server's response to its own message, so a captive portal on the submission port arrives asInvalid login: <html>…rather than as markup at position zero.test_connectioncloses the session it opens instead of leaving an authenticated connection pooled for the lifetime of the process. - A Message-ID,
in_reply_toorreferencesentry is printable ASCII without whitespace or angle brackets. nodemailer writes these three headers byte for byte, so a non-ASCII character was an 8-bit header on the wire and U+2028 quietly split one identifier into two. - The advertised schemas avoid a spelling that is legal JSON Schema and still gets a tool refused, or its constraint silently dropped, by some MCP clients: a nullable field is written as
anyOfbranches rather than a two-entry type array. The two-callconfirm_tokenprompt is an error result, which is what "the message was not sent" means and what lets a tool that declares an output schema answer withoutstructuredContent.