Changelog
[Unreleased]
Changed
- nodemailer 10. Its
SMTPTransport.Optionsnow declaresauthas an optional property without| undefined, so the transport options omit the key when there are no credentials instead of setting it toundefined— which is what the option already meant: no credentials, no AUTH command. No behaviour changes;tlswas built the same way already.
[0.2.0] - 2026-09-07
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. 0.1.2 already shipped 0.8.1 with that fix; 0.8.2 addsorderedResourceKey, which this server does not need (its key is a single message fingerprint) but the fleet pins one version.
Added
SMTP_REPLY_TOputs a Reply-To header on every message this server sends, in the same two formsSMTP_FROMaccepts:team@example.netorTeam <team@example.net>. It is for the deployment where the mailbox that sends is not the mailbox that should be answered — anoreplyaccount, or an automation writing on behalf of a team. Unset, and that remains the default, there is no Reply-To header at all and replies go to the sender; a Reply-To repeating the From address adds nothing and reads as forgery to some filters. A malformed value stops the server rather than being dropped, because a reply address that silently is not set is discovered when somebody asks why they were ignored. Like the sender it is operator configuration and has no tool parameter: a model that could set one per message could route the answer to a conversation you started away from you, and nothing about the delivered message would look wrong. It is a header and only a header — it does not join the SMTP envelope and is not checked againstSMTP_ALLOWED_RECIPIENTS, because it addresses nobody this server will contact. When it is set, the approval dialog carries it on its own line under the sender, andget_server_inforeports it asreply_to.
[0.1.2] - 2026-09-06
The third internal security review of this server, run through every code path with the MCP review checklist. Two ways past the HTML sanitiser and one way to stall the process, all reachable through preview_mail, which needs no send gate, no confirmation and no rate limit — and a handful of smaller findings from the same pass. Every fix ships with a test that fails on 0.1.1.
Security
- The injection detector was quadratic in a run of dashes, equals signs or hashes. Its
fake-delimiterpattern tried-{3,}at every position of the run and backtracked through every length at each: 100 000 dashes took 9.5 s of blocked event loop, and the schema admits 500 000 in the body, the quote and the HTML part each — minutes per preview, with nothing in front of it. The run is now anchored to its own start, and a timing test pins every heuristic against its own trigger at the schema ceiling. - The HTML sanitiser decoded at most seven digits of a numeric character reference; the tokenizer consumes every digit there is.
https://…washttps://to the recipient's client and a scheme-less104;ttps://to every pass here — a tracking pixel, or ajavascript:link, with an empty removal list and nothing in the confirmation dialog. Confirmed in Chrome. The digit run is now read in full, decoded once rather than in three passes, and what the tokenizer turns into U+FFFD is U+FFFD here too, never nothing. - Every removal in the sanitiser replaced its match with the empty string. To a tokenizer
<img sr onclick="x"c=…>is three inert attributes; cutting the handler out of the middle left<img src=…>— a beacon manufactured by the pass that removes handlers, after the pass that removes beacons had already run, and reported to the person approving as a click handler. Every removal now leaves a space behind, so no deletion can assemble a token out of pieces that were never one, and a second, counted run of the passes refuses the message if it still finds anything. - The approval fingerprint fed the attachment bytes to the hash one file after another with no boundary between them, so the same bytes split differently between two files were one digest — and a writer in
SMTP_ATTACHMENT_DIRcould move the tail of one approved file to the head of the next between the two calls of the token path. Each file's bytes are now length-prefixed. - An SMTP reply reaching the model was bounded in length and shape but not in what characters it carried;
ESC[2K ESC[1Aerases the line above it. Control characters are stripped from every upstream error text. - The
ELICITATIONandSMTP_ALLOWED_RECIPIENTSdiagnostics repeated whatever was pasted into the variable, at any length and with any control characters in it — and the branch that rejects a value is where a token pasted into the wrong variable arrives. Both cut the value to forty characters and strip the rest. confirm_tokenwas the one caller string without a ceiling; it has one. And a refusal for recipients off the allowlist named all of them — up to a hundred and fifty addresses of 320 characters — where it now names the first twenty and counts the rest.
Changed
test_connectiontries the server at most once every ten seconds. Every call is a login against the operator's own provider, and providers lock an account after a handful of failed logins in quick succession; a model that reads "authentication refused" and retries — the tool calls itself read-only, idempotent and cheap — must not be able to turn one wrong password into a locked mailbox. Inside the window the previous outcome is repeated, success or failure, withcached: trueand a note saying when the next real attempt is possible.mcp-approval0.8.1: a sealedrequestStateis single-use, spent on its first answer. This server already answered a retry from its own record of what went out; the library now agrees, and SECURITY.md describes the two layers rather than a transport the code stopped using in 0.1.0.- The tool reference marks the
essentialpreset and the tools that ask a person before they act, per tool rather than only in the introduction. A test keeps both sets in step with the code. - oxlint's
suspiciouscategory is on. Twelvesort()calls that mutated their input becametoSorted(), which needs target ES2023 — every supported Node has it.
Added
- A dependency review on every pull request, against the advisory database:
npm auditchecks the tree as it is, this checks the change. - A test that loads
tools/listbefore taking every tool down its success path, the way a real client does — a client that knows the output schema validatesstructuredContentagainst it, and only then. - 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.
Fixed
- The event-loop budget in the sanitiser tests is 3 s rather than 400 ms. The guard is against a pathological pattern that once took fourteen seconds; the sanitiser needs single-digit milliseconds. 400 ms was close enough to the runner's own variance that CI failed twice on a correct sanitiser.
- Container CVEs reach the Security tab again. The
container-scanjob ran Trivy only as a gate, so a finding existed for exactly as long as someone was reading the CI log: nothing was uploaded, no alert was raised, and a fixed CVE had no record of ever having been there. The job now reports first and gates second — one SARIF upload per architecture, each under its own category — the arrangement every other server in the family already had.
[0.1.1] - 2026-09-06
Fixed
preview_mailno longer describes itself as building "exactly the messagesend_mailwould build". It is registered whether or not sending is switched on, so withSMTP_ALLOW_SENDunset it was pointing at a tool absent from the sametools/list— and a reader holding a reference to a tool that is not there reads the gap as a broken server rather than as the default it is. The description now stands on its own, says that the sending tools register only whenSMTP_ALLOW_SENDis true, and points atget_server_infofor whether sending is on. No behaviour changes, and the default stays off.
[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.