Carrier - Rust MCP server for Sulkta email. 10 tools, multi-account, attachment-safe.
Find a file
Kayos f4b3199e86 audit-fix sprint: 12 findings from the max-effort adversarial pass
Threats closed (CRIT/HIGH):

- CRIT-1 (mail_move folder injection via uid_copy fallback):
  validate_mailbox() rejects CR/LF/NUL/"/\\ on every folder arg
  (list/read/search/thread/move). async-imap's uid_copy doesn't quote
  the destination — quoting metacharacters would have smuggled COPY
  targets. We refuse the characters outright rather than escape.

- HIGH-1 (mail_thread message_id backslash bypass): seed Message-ID
  rejection set extended from {", CR, LF} to {", \\, CR, LF, {}.
  A bare \\ inside the IMAP quoted-string would escape the closing
  quote and confuse the server's parser. { also opener of literal-form.

- CRIT-2 / HIGH (search literal-form): mail_search now rejects the
  IMAP {N} literal-form opener via has_imap_literal(). CR/LF were
  already blocked.

- HIGH-3 (strip_quotes asymmetric strip): only strips matching pairs.
  A password starting with " but lacking a closing " no longer
  silently loses its leading char.

- HIGH-4 (no attachment size cap): new MAX_ATTACHMENT_BYTES (25 MB
  decoded, matches Gmail), MAX_ATTACHMENTS (25), MAX_BODY_BYTES
  (5 MB on body + body_html), MAX_TOTAL_RECIPIENTS (100). Pre-decode
  bound on encoded base64 length prevents giant-payload OOM before
  the decode buffer allocates.

- HIGH-5 (raw_eml fetch unbounded): RFC822.SIZE pre-flight on
  mail_inbox_read refuses messages > MAX_RAW_EML_BYTES (20 MB) before
  the body transfer.

- HIGH-6 (flat headers map empties for structured variants):
  switched from h.value().as_text() (which returns None for Address /
  DateTime / ContentType / Received) to Message::header_raw(name)
  which returns the un-decoded header value as &str uniformly across
  all variants. Date / From / To / Subject / Content-Type /
  DKIM-Signature etc. all populate correctly now.

- HIGH-9 (password resolved after TLS handshake): resolve_password()
  now runs at the top of open_session(), before TCP connect, so a
  missing/unreadable credential errors before the IMAP server logs
  an unauthenticated session that fail2ban could pattern on.

MED/LOW:

- MED-8 mail_search tool description: clarifies that CR/LF + {N}
  literal-form are rejected but the query is otherwise raw — caller
  must not pass untrusted input.

- MED-10 ServerHandler instructions: lists all 7 tools (not just the
  original 3) and explains UID stability + BODY.PEEK posture.

- LOW-2 snippet_unused dead code: deleted.

Smoke verified 2026-05-21:
- send -> land -> read round trip clean
- headers map now shows Date / From / To / Subject / Message-ID /
  User-Agent / Content-Type / DKIM-Signature populated
- 4 injection probes all cleanly rejected: CR in folder, {5}hello
  search literal, message_id with \\, folder with "
- mail_move INBOX <-> Junk round trip clean

Findings explicitly verified NOT-exploitable by the audit (no code
change needed): lettre CR/LF filter on Subject/Message-Id, lettre
mailbox rfc2822 parser, MIME-boundary randomness, rustls hostname
verification, password leakage in error paths, MIME smuggling via
filename, format_imap_since negative-year bypass.

Deferred (separate follow-ups): session pool (MED-6), partial-body
fetch in mail_inbox_read (MED-9), canonical Flag display rendering
(LOW-3), JSON schema 0=default sentinel (LOW-5), config chmod check
(LOW-6), proper unit/integration test suite (INFO-3).
2026-05-21 07:38:43 -07:00
crates/mail-mcp audit-fix sprint: 12 findings from the max-effort adversarial pass 2026-05-21 07:38:43 -07:00
.gitignore mail-mcp v0.1 — Rust MCP server for Sulkta email 2026-05-21 06:50:25 -07:00
Cargo.lock mail-mcp v0.1 — Rust MCP server for Sulkta email 2026-05-21 06:50:25 -07:00
Cargo.toml mail-mcp v0.1 — Rust MCP server for Sulkta email 2026-05-21 06:50:25 -07:00
config.example.toml mail-mcp v0.1 — Rust MCP server for Sulkta email 2026-05-21 06:50:25 -07:00
README.md mail-mcp v0.1 — Rust MCP server for Sulkta email 2026-05-21 06:50:25 -07:00

mail-mcp

Rust MCP server for Sulkta-hosted email. SMTP send + IMAP read with RFC-correct headers, multipart/alternative when HTML is included, multipart/mixed for attachments, threading via In-Reply-To/References.

Replaces the scripts/kayos_mail.py CLI path that lived in kayos/openclaw-workspace since 2026-04-23.

Why a server, not a CLI

kayos_mail.py shipped without Date or Message-ID headers until a 2026-05-18 patch — exactly the kind of header-discipline regression a typed Rust server prevents at compile time. The "no spam bin" framing is mostly upstream of any client (Rackham postfix + rspamd DKIM-sign at the relay; mail-tester scored 10/10 and port25 SpamAssassin 7.31 on 2026-05-20), but a correct client doesn't trip filters with bad MIME structure, broken threading, or missing headers.

Tools (v0.1)

  • mail_send — send mail. Args: account?, to, cc[]?, bcc[]?, subject, body, body_html?, attachments[]?, in_reply_to?, references[]?. Returns {message_id, sent_at}.
  • mail_inbox_list — list folder messages newest-first. Args: account?, since? (YYYY-MM-DD), unread_only?, limit? (default 50, max 500), folder? (default INBOX). Uses BODY.PEEK so it does not toggle \Seen.
  • mail_inbox_read — fetch one message by UID. Args: account?, uid, folder?, format? (text|html|raw_eml). Attachment payloads are not inlined — only filename/mime_type/size metadata.

Headers we guarantee on outbound

  • Date — UTC, RFC 5322 (lettre auto)
  • Message-ID<UUIDv4@<from_addr_domain>> — own-domain, never the container hostname
  • Fromname <addr>
  • MIME-Version: 1.0
  • User-Agent: mail-mcp/<version>
  • In-Reply-To + References when threading args present
  • Content-Type correct for the body shape (text-only / alternative / mixed)

DKIM-Signature is applied by the relay (rspamd on Rackham), not the client.

Build

cargo build --release

Binary lands at target/release/mail-mcp.

Config

mkdir -p ~/.config/mail-mcp
cp config.example.toml ~/.config/mail-mcp/config.toml
chmod 600 ~/.config/mail-mcp/config.toml

Edit accounts as needed. Passwords are NEVER inline:

  1. Looked up from the env var named in password_env
  2. Falling back to password_file (shell-format: KEY=VALUE per line)
  3. Hard-failing with a vault-pointer hint if neither resolves

Vault canonical: bw.sulkta.comkayos@sulkta.com — IMAP/SMTP.

MCP wiring (Claude Code / kayos-house)

{
  "mcpServers": {
    "mail-mcp": {
      "command": "/usr/local/bin/mail-mcp",
      "args": []
    }
  }
}

Logging is stderr-only — stdout is the JSON-RPC transport.

Future phases

  • Phase B (~200 LOC): multi-account routing across all configured [accounts.*], plus mail_thread and mail_search.
  • Phase C (~150 LOC): mail_mark (read/unread/flag/trash/archive), mail_attachment_get, mail_reply helper.

Full locked spec: kayos/openclaw-workspacememory/spec-mail-mcp.md.