Carrier - Rust MCP server for Sulkta email. 10 tools, multi-account, attachment-safe.
Find a file
Kayos b681953824 Phase C: mail_mark, mail_attachment_get, mail_reply
Three new tools complete the planned Phase C scope:

- mail_mark { uid, action, folder? }: action is one of read, unread,
  flagged, unflagged, trash, archive. read/unread toggle \Seen via UID
  STORE +/-FLAGS.SILENT (idempotent, no fetch round-trip). flagged/
  unflagged the same for \Flagged. trash is a MOVE to Trash. archive
  errors out with a clear pointer to mail_move because Sulkta's Dovecot
  doesn't ship a canonical Archive folder.

- mail_attachment_get { uid, attachment_index, folder? }: fetches the
  full RFC822 (within the existing 20 MB raw_eml cap), parses with
  mail-parser, returns the N-th attachment as base64. The index matches
  mail_inbox_read's attachments[] ordering. Returns {filename,
  mime_type, size, content_base64}. SAFETY note in the tool description
  warns the LLM not to execute / render / open attachment bytes
  blindly.

- mail_reply { uid, body, body_html?, attachments?, reply_all?,
  to_override? }: fetches the original to pull From / Subject /
  Message-Id / References, then sends with proper In-Reply-To +
  References + 'Re: ' subject prefix (skipped if already prefixed).
  reply_all=true echoes the original Cc. to_override replaces To.
  Threading headers still set against the original regardless of
  to_override.

Smoke verified 2026-05-21:
- Send kayos->kayos with a 54-byte text attachment
- mail_inbox_read shows attachments=[('smoke.txt', 54)]
- mail_attachment_get returns the exact bytes (b'Hello from mail-mcp
  Phase C smoke!\r\nLine 2.\r\nLine 3.\r\n', 54 bytes)
- mail_mark unread -> flags=[] (\Seen cleared)
- mail_mark flagged -> flags=['\\Flagged']
- mail_reply -> message lands as 'Re: mail-mcp phase-C smoke' with
  In-Reply-To = parent Message-Id and References = parent Message-Id

ServerHandler instructions updated to enumerate all 10 tools + the new
attachment-safety note. Tools live on the wire: mail_send,
mail_inbox_list, mail_inbox_read, mail_folder_list, mail_search,
mail_thread, mail_move, mail_mark, mail_attachment_get, mail_reply.

Test count 27 -> 33: 2 for MarkAction::parse (alias coverage + unknown
rejection), 4 for tools::extract_addr (display-name strip + bare-addr
passthrough + garbage tolerance + ToField unwrap).
2026-05-21 08:42:39 -07:00
crates/mail-mcp Phase C: mail_mark, mail_attachment_get, mail_reply 2026-05-21 08:42:39 -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.