Phase B per the spec (multi-account already supported via the
account arg) + full folder support:
- mail_folder_list : enumerate IMAP folders. Returns {name, delimiter,
attributes, selectable}. selectable=false flags
\Noselect mailboxes (parent-of-children only).
- mail_search : raw IMAP SEARCH passthrough against any folder.
ALL/OR/NOT combinators supported. CR/LF in the
query rejected (anti-injection).
- mail_thread : seed Message-ID -> matches the seed itself plus
any message whose References or In-Reply-To
contains the seed. Oldest-first ordering (root
-> leaves). Brackets on the seed are optional.
- mail_move : UID MOVE (RFC 6851) with COPY + STORE +FLAGS
\Deleted + UID EXPUNGE fallback. Destination
folder must already exist.
Refactor: shared fetch_summaries() helper used by search + thread.
Smoke verified 2026-05-21 against kayos@sulkta.com:
- 6 folders listed (DMARC, Drafts, INBOX, Junk, Sent, Trash)
- SEARCH "SUBJECT \"mail-mcp smoke\"" finds uid 27
- THREAD on uid 27's Message-ID returns 1 msg (the seed)
- MOVE INBOX(27) -> Junk(2) -> INBOX(28) round trip clean
Build gotcha: async-imap's uid_store and uid_expunge return non-Unpin
streams (unlike uid_fetch). Pinned via Box::pin inside scoped blocks.
|
||
|---|---|---|
| crates/mail-mcp | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| config.example.toml | ||
| README.md | ||
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). UsesBODY.PEEKso 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 hostnameFrom—name <addr>MIME-Version: 1.0User-Agent: mail-mcp/<version>In-Reply-To+Referenceswhen threading args presentContent-Typecorrect 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:
- Looked up from the env var named in
password_env - Falling back to
password_file(shell-format:KEY=VALUEper line) - Hard-failing with a vault-pointer hint if neither resolves
Vault canonical: bw.sulkta.com → kayos@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.*], plusmail_threadandmail_search. - Phase C (~150 LOC):
mail_mark(read/unread/flag/trash/archive),mail_attachment_get,mail_replyhelper.
Full locked spec: kayos/openclaw-workspace → memory/spec-mail-mcp.md.