Skip to content

v0.1.0

Latest

Choose a tag to compare

@github-actions github-actions released this 03 Sep 22:10
v0.1.0
c5d5bff

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 -i and exposes no port.
  • Seven tools. Four are always available and cannot put a message on the wire —
    get_server_info, validate_recipients, preview_mail and test_connection. Three send:
    send_mail, reply_mail and forward_mail.
  • SMTP_ALLOW_SEND defaults to false. 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_RECIPIENTS is 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=false takes 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
    true nor false stops the server, like SMTP_TLS and unlike SMTP_ALLOW_SEND, because it
    is the only variable here that defaults to on.
  • The sender is fixed by SMTP_FROM. There is no from parameter.
  • 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, poster and
    background, each srcset candidate 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_mail is
    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 of DATA,
    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_mail reports the same as text_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_mail reports the fields as suspicious_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_info reports elicitation_enabled and a confirmation field that follows the
    configuration, so it never claims a human is asked when ELICITATION=false says 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 marked outcome=refused, declined or
    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 like Invoice_bcc=[quiet@evil.example]
    cannot hand a parser a bcc field that never existed.
  • Every tool declares an outputSchema and answers with structuredContent beside the text
    block, so a client does not have to parse prose to use a result. preview_mail carries the
    untrusted marker as untrusted: true and source: "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_TOOLS narrow the tool list; essential selects 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-hosts and
    svg-asset-set rather 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.example has one
    allowlisted domain to a naive parser and becomes two RCPT commands at the SMTP server — the
    first a bare ceo that 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_mail answers 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's what and consequence, 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 lstat and open cannot 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/html opens
    in a browser with none of a mail client's restrictions and none of the sanitising the HTML
    part of a message gets; application/zip passes the magic-byte check on its own bytes and
    can carry the executable that check never sees. Both remain available through
    SMTP_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 Date and 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.env once 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 as Invalid login: <html>… rather than as markup at position zero. test_connection
    closes the session it opens instead of leaving an authenticated connection pooled for the
    lifetime of the process.
  • A Message-ID, in_reply_to or references entry 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 anyOf
    branches rather than a two-entry type array. The two-call confirm_token prompt is an error
    result, which is what "the message was not sent" means and what lets a tool that declares an
    output schema answer without structuredContent.