Rust MCP server for Sulkta email (SMTP send + IMAP read). Replaces scripts/kayos_mail.py.
Find a file
Kayos 7c8e246544 cleanup pass — 17 findings from Opus code-quality audit
Applied from the cleanup-agent report (separate from the security
audit's 18 fixes earlier today):

HIGH:
- HIGH-1: replaced hand-rolled civil_from_unix + chrono_rfc3339_now
  with chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true).
  ~50 LOC of brittle Hinnant-algorithm civil-calendar math gone.
  Plus 5 unit tests retired with the function. chrono pulls a small
  default-features=false slice (clock + serde-not-included).
- HIGH-2: extracted shared reject_imap_unsafe() helper. validate_mailbox
  and the mail_thread message_id check both go through it. The
  message_id check now also rejects '{' (literal-form opener) for
  symmetry — same byte set as validate_mailbox.
- HIGH-3: mail_reply uses smtp::ensure_angle_brackets() on the parent
  Message-Id + each References entry. mail-parser strips brackets;
  lettre writes through verbatim; strict RFC-5322 receivers will drop
  the threading link if brackets are missing. Now canonical.
- HIGH-4: extract_addr moved from tools.rs to smtp.rs as
  smtp::extract_bare_addr. Module hygiene — RFC-5322 mailbox parsing
  belongs in the SMTP-side module, not the rmcp surface.
- HIGH-5: mail_reply Re:-prefix check now non-allocating —
  subject.get(..3).map(|s| s.eq_ignore_ascii_case("re:")) instead of
  .to_ascii_lowercase().starts_with("re:") which allocated a fresh
  String for the comparison.

MED:
- MED-1: dropped thiserror dep (workspace + crate). Never derived.
- MED-6: ReadOutput.headers is now typed BTreeMap<String,String> instead
  of serde_json::Value::Object. Wire JSON shape unchanged; downstream
  consumers can .get(name) directly without the .as_str() dance.
- MED-8: fetch_to_list_entry returns Option<ListEntry> and drops
  entries when the server omits UID. Was uid=0 silent fallback;
  now we log a warning and skip.
- MED-10: introduced PRIMARY_BODY_PART = 0 const, replaced 4 magic 0s
  at parsed.body_text(0) / parsed.body_html(0) call sites.
- MED-11: skip insert of empty-valued headers in the flat headers map.
  was producing "key":"" entries for headers mail-parser couldn't
  render to a flat string.

LOW:
- LOW-1: collapsed MailService { inner: Arc<MailInner> } to
  MailService { config: Arc<Config> }. The MailInner wrapper served no
  purpose with a single field.
- LOW-2: rewrote to_field_collapses_to_vec test with let bindings
  instead of single-arm match.
- LOW-4: format_imap_since year range tightened from 1900..=9999 to
  1970..=9999 (unix-epoch floor; we don't use pre-epoch IMAP SINCE).
- LOW-5: promoted max_encoded to MAX_ATTACHMENT_BASE64_BYTES const.
- LOW-6: SendOutput now #[derive(Serialize)] — mail_send and
  mail_reply tools use it via the new IntoMcpError trait instead of
  serde_json::json!() boilerplate.
- LOW-7: added IntoMcpError trait — anyhow::Result<T: Serialize>
  -> Result<String, String>. Removes 10 copy-pasted
  .map_err(|e| format!("{e:#}"))? + serialize chains.
- LOW-9: documented the 20 MB read cap vs 25 MB send cap asymmetry
  via comments on both consts.
- LOW-10: UID MOVE fallback log demoted to trace! and renamed field
  imap_mv_err so log analytics doesn't flag the graceful fallback as
  an error.
- LOW-13: SMTP From header built via Mailbox::new() instead of
  format!("{} <{}>")-then-.parse(). One alloc, one parse pass gone.

INFO:
- INFO-3: lettre 'hostname' feature dropped from Cargo.toml. We
  override Message-ID with our own UUID@from_domain; lettre never
  needed the system hostname.

Deferred from this pass:
- MED-2 with_session wrapper — substantial refactor across 8 IMAP
  functions for moderate DRY win; saving for a Phase E lifecycle pass.
- MED-7 / LOW-14 typed address shape — would change wire JSON for
  mail_inbox_list/read; backwards-incompatible.
- MED-12 narrow UID MOVE fallback — needs async-imap error-variant
  taxonomy research.
- LOW-11 / LOW-12 stringly format / action enums — auditor flagged as
  marginal; keep stringly with description-enumerated values.

Test count: 33 -> 31 (-5 civil_from_unix, +3 extract_bare_addr, +3
ensure_angle_brackets, -1 stale extract_addr in wrong module). All
passing. Wire smoke verified — send / list / read round trip clean,
headers map is now a flat dict with no empties, chrono-rendered
timestamps match the prior shape.
2026-05-21 09:09:21 -07:00
crates/mail-mcp cleanup pass — 17 findings from Opus code-quality audit 2026-05-21 09:09:21 -07:00
.gitignore mail-mcp v0.1 — Rust MCP server for Sulkta email 2026-05-21 06:50:25 -07:00
Cargo.lock cleanup pass — 17 findings from Opus code-quality audit 2026-05-21 09:09:21 -07:00
Cargo.toml cleanup pass — 17 findings from Opus code-quality audit 2026-05-21 09:09:21 -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.