mail-mcp v0.1 — Rust MCP server for Sulkta email

Phase A: mail_send + mail_inbox_list + mail_inbox_read. Replaces
scripts/kayos_mail.py with a typed MCP server. Outbound guarantees Date,
Message-ID (own-domain), User-Agent, MIME-Version, multipart/alternative
for HTML+text, multipart/mixed for attachments, In-Reply-To +
References for threading.

Single account in v0.1 (default_account from config). Phase B adds
multi-account + threading + search; Phase C adds mark + attachments +
reply helper.

Stack: rmcp 0.1 (matches aldabra), lettre 0.11 + tokio-rustls, async-imap
0.10, mail-parser 0.9. Stderr-only logging (stdout is the MCP transport).

Smoke verified 2026-05-21: send -> land -> read kayos@sulkta.com round
trip, DKIM-Signature + Authentication-Results pass at the rspamd relay.
This commit is contained in:
Kayos 2026-05-21 06:50:25 -07:00
commit 2240bf745e
11 changed files with 3552 additions and 0 deletions

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
target/
**/*.rs.bk
Cargo.lock.bak
# Local config files MUST NOT be tracked — they carry credentials.
config.toml
**/config.toml
!config.example.toml
# Editor / OS junk
.idea/
.vscode/
*.swp
.DS_Store

2246
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

77
Cargo.toml Normal file
View file

@ -0,0 +1,77 @@
# Cargo workspace root for mail-mcp.
#
# One crate today (mail-mcp), workspace shape so we can grow without
# rework. Same pattern as aldabra.
#
# Workspace deps pinned here; each crate references with `foo = { workspace = true }`.
[workspace]
resolver = "2"
members = ["crates/mail-mcp"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "http://192.168.0.5:3001/Sulkta-Coop/mail-mcp"
authors = ["Cobb <cobb@sulkta.com>", "Kayos <kayos@sulkta.com>"]
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
# MCP — same crate aldabra uses. Pinned to 0.1 series; bump together
# across repos when we move.
rmcp = { version = "0.1", features = ["server", "transport-io"] }
schemars = "0.8"
# SMTP — lettre handles RFC-5322 headers (Date, Message-ID), STARTTLS,
# multipart/alternative + multipart/mixed natively. rustls-tls so we
# don't pull openssl.
lettre = { version = "0.11", default-features = false, features = [
"tokio1-rustls-tls",
"smtp-transport",
"builder",
"hostname",
] }
# IMAP — async-imap is tokio-native and supports UID-based addressing
# (which we use throughout the API surface).
async-imap = { version = "0.10", default-features = false, features = ["runtime-tokio"] }
tokio-rustls = "0.26"
rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "ring"] }
rustls-pki-types = "1"
webpki-roots = "0.26"
# Email parsing on the read side. mail-parser is fast, no_std-friendly,
# and handles the RFC-5322 + MIME zoo without surprises.
mail-parser = "0.9"
# Config + serde
toml = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# UUID for Message-ID generation when lettre's auto isn't appropriate
# (we want our own domain in the Message-ID, not lettre's local-hostname
# default).
uuid = { version = "1", features = ["v4"] }
# Base64 for attachments
base64 = "0.22"
# Errors
anyhow = "1"
thiserror = "1"
# Stream adapter (.next() on async-imap fetch streams)
futures = "0.3"
# Logging — stderr only, never stdout (stdio is the MCP transport).
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Dirs lookup for `~/.config/mail-mcp/config.toml` default path
dirs = "5"
# Shell-style env-var expansion for the `password_file` setting
# (`~/.config/...` paths). shellexpand is small + maintained.
shellexpand = "3"

73
README.md Normal file
View file

@ -0,0 +1,73 @@
# 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
- `From``name <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
```bash
cargo build --release
```
Binary lands at `target/release/mail-mcp`.
## Config
```bash
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.com``kayos@sulkta.com — IMAP/SMTP`.
## MCP wiring (Claude Code / kayos-house)
```json
{
"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-workspace``memory/spec-mail-mcp.md`.

27
config.example.toml Normal file
View file

@ -0,0 +1,27 @@
# mail-mcp config — copy to ~/.config/mail-mcp/config.toml, chmod 600.
#
# Passwords are NEVER inline. Each account names an env var (`password_env`)
# AND a fallback file (`password_file`). Lookup order:
# 1. env var
# 2. shell-format file (`KEY=VALUE` per line)
# 3. hard fail with a vault-pointer hint
#
# Vault canonical: bw.sulkta.com → "kayos@sulkta.com — IMAP/SMTP".
default_account = "kayos"
[accounts.kayos]
from_name = "Kayos"
from_addr = "kayos@sulkta.com"
smtp_host = "mail.sulkta.com"
smtp_port = 587
smtp_starttls = true
imap_host = "mail.sulkta.com"
imap_port = 993
imap_tls = true
username = "kayos@sulkta.com"
password_env = "KAYOS_SMTP_PASS"
password_file = "~/.config/kayos-mail/smtp.env"
# Optional: pin Message-ID domain. Defaults to the part of `from_addr`
# after the @ if unset.
# message_id_domain = "sulkta.com"

View file

@ -0,0 +1,43 @@
# mail-mcp — the binary. Stdio MCP server exposing SMTP send + IMAP
# read tools. Spawned per-session by any MCP client.
[package]
name = "mail-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Rust MCP server for Sulkta-hosted email (SMTP send + IMAP read)"
[[bin]]
name = "mail-mcp"
path = "src/main.rs"
[dependencies]
tokio = { workspace = true }
rmcp = { workspace = true }
schemars = { workspace = true }
lettre = { workspace = true }
async-imap = { workspace = true }
tokio-rustls = { workspace = true }
rustls = { workspace = true }
rustls-pki-types = { workspace = true }
webpki-roots = { workspace = true }
mail-parser = { workspace = true }
toml = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
base64 = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
futures = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dirs = { workspace = true }
shellexpand = { workspace = true }

View file

@ -0,0 +1,134 @@
//! TOML config + password resolution.
//!
//! Config path: `$MAIL_MCP_CONFIG` env, or `~/.config/mail-mcp/config.toml`.
//!
//! Password lookup per account:
//! 1. env var named by `password_env`
//! 2. file at `password_file` (shell-format: `KEY=VALUE`)
//! 3. hard fail with a vault-pointer hint — never silent
//!
//! Vault canonical: `bw.sulkta.com` → `kayos@sulkta.com — IMAP/SMTP`.
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
pub default_account: String,
pub accounts: HashMap<String, Account>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Account {
pub from_name: String,
pub from_addr: String,
pub smtp_host: String,
pub smtp_port: u16,
#[serde(default = "default_true")]
pub smtp_starttls: bool,
pub imap_host: String,
pub imap_port: u16,
#[serde(default = "default_true")]
pub imap_tls: bool,
pub username: String,
pub password_env: String,
pub password_file: Option<String>,
/// If unset, derived from the part of `from_addr` after `@`.
pub message_id_domain: Option<String>,
}
fn default_true() -> bool {
true
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_path()?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("read config {}", path.display()))?;
let cfg: Self = toml::from_str(&text)
.with_context(|| format!("parse config {}", path.display()))?;
if !cfg.accounts.contains_key(&cfg.default_account) {
return Err(anyhow!(
"default_account `{}` not in [accounts.*]",
cfg.default_account
));
}
Ok(cfg)
}
/// Resolve the named account (or `default_account` if `None`).
pub fn account<'a>(&'a self, name: Option<&str>) -> Result<&'a Account> {
let key = name.unwrap_or(&self.default_account);
self.accounts
.get(key)
.ok_or_else(|| anyhow!("unknown account `{key}` — check [accounts.*] in config.toml"))
}
}
impl Account {
/// Resolve the password from env, then from `password_file`, then fail.
pub fn resolve_password(&self) -> Result<String> {
if let Ok(v) = std::env::var(&self.password_env) {
if !v.is_empty() {
return Ok(v);
}
}
if let Some(path) = &self.password_file {
let expanded = shellexpand::tilde(path).into_owned();
if std::path::Path::new(&expanded).is_file() {
let text = std::fs::read_to_string(&expanded)
.with_context(|| format!("read password_file {expanded}"))?;
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
if k.trim() == self.password_env {
return Ok(strip_quotes(v.trim()).to_string());
}
}
}
}
}
Err(anyhow!(
"no password for `{}`. Set ${} or write {}. Vault: bw.sulkta.com → `{} — IMAP/SMTP`",
self.username,
self.password_env,
self.password_file.as_deref().unwrap_or("(no file configured)"),
self.from_addr,
))
}
/// Domain used to qualify Message-IDs (so they read as `<uuid@sulkta.com>`,
/// not `<uuid@<container-hostname>>`).
pub fn msgid_domain(&self) -> &str {
if let Some(d) = &self.message_id_domain {
return d;
}
match self.from_addr.split_once('@') {
Some((_, d)) => d,
None => "localhost",
}
}
}
fn strip_quotes(s: &str) -> &str {
let s = s.strip_prefix('"').unwrap_or(s);
let s = s.strip_suffix('"').unwrap_or(s);
let s = s.strip_prefix('\'').unwrap_or(s);
s.strip_suffix('\'').unwrap_or(s)
}
fn config_path() -> Result<PathBuf> {
if let Ok(p) = std::env::var("MAIL_MCP_CONFIG") {
return Ok(PathBuf::from(shellexpand::tilde(&p).into_owned()));
}
let home = dirs::config_dir()
.ok_or_else(|| anyhow!("could not resolve $XDG_CONFIG_HOME / ~/.config"))?;
Ok(home.join("mail-mcp").join("config.toml"))
}

383
crates/mail-mcp/src/imap.rs Normal file
View file

@ -0,0 +1,383 @@
//! Inbound IMAP via `async-imap` + `tokio-rustls`.
//!
//! Two surfaces:
//! - `list(account, opts)` → newest-first summary array
//! - `read(account, uid, folder, format)` → full message
//!
//! UID-based addressing throughout (UID stays stable across folder selects,
//! sequence numbers don't).
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use async_imap::types::Fetch;
use futures::StreamExt;
use mail_parser::{MessageParser, MimeHeaders};
use rustls::pki_types::ServerName;
use serde::Serialize;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use crate::config::Account;
#[derive(Debug, Clone, Default)]
pub struct ListOpts {
pub since: Option<String>, // YYYY-MM-DD
pub unread_only: bool,
pub limit: u32, // 0 means default (50)
pub folder: Option<String>, // None → INBOX
}
#[derive(Debug, Clone, Serialize)]
pub struct ListEntry {
pub uid: u32,
pub message_id: Option<String>,
pub from: Vec<String>,
pub to: Vec<String>,
pub subject: String,
pub date: Option<String>,
pub snippet: String,
pub has_attachments: bool,
pub flags: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReadOutput {
pub uid: u32,
pub message_id: Option<String>,
pub from: Vec<String>,
pub to: Vec<String>,
pub cc: Vec<String>,
pub subject: String,
pub date: Option<String>,
pub headers: serde_json::Value,
pub body: String,
pub format: String,
pub attachments: Vec<AttachmentMeta>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AttachmentMeta {
pub filename: String,
pub mime_type: String,
pub size: usize,
}
const DEFAULT_LIMIT: u32 = 50;
const MAX_LIMIT: u32 = 500;
const SNIPPET_LEN: usize = 240;
// =============================================================================
// list
// =============================================================================
pub async fn list(account: &Account, opts: ListOpts) -> Result<Vec<ListEntry>> {
let folder = opts.folder.as_deref().unwrap_or("INBOX");
let limit = match opts.limit {
0 => DEFAULT_LIMIT,
n if n > MAX_LIMIT => MAX_LIMIT,
n => n,
};
let mut session = open_session(account).await?;
session
.select(folder)
.await
.with_context(|| format!("SELECT {folder}"))?;
// Build the SEARCH query.
let mut search_terms: Vec<String> = vec!["ALL".into()];
if opts.unread_only {
search_terms = vec!["UNSEEN".into()];
}
if let Some(since) = &opts.since {
let imap_date = format_imap_since(since)
.with_context(|| format!("`since` must be YYYY-MM-DD, got `{since}`"))?;
search_terms.push(format!("SINCE {imap_date}"));
}
let query = search_terms.join(" ");
let uids: Vec<u32> = {
let set = session
.uid_search(&query)
.await
.with_context(|| format!("UID SEARCH {query}"))?;
let mut v: Vec<u32> = set.into_iter().collect();
v.sort_unstable_by(|a, b| b.cmp(a)); // newest UID first
v.truncate(limit as usize);
v
};
let mut out: Vec<ListEntry> = Vec::with_capacity(uids.len());
if uids.is_empty() {
session.logout().await.ok();
return Ok(out);
}
let seq = uids
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(",");
// BODY.PEEK so we don't toggle \Seen as a side effect of listing.
let fetch_query = "(UID FLAGS INTERNALDATE BODY.PEEK[HEADER] RFC822.SIZE)";
let mut stream = session
.uid_fetch(&seq, fetch_query)
.await
.with_context(|| format!("UID FETCH {seq}"))?;
while let Some(msg_res) = stream.next().await {
let msg = msg_res.context("UID FETCH stream item")?;
let entry = fetch_to_list_entry(&msg);
out.push(entry);
}
drop(stream);
session.logout().await.ok();
// Preserve newest-first ordering even if the server reordered.
out.sort_by(|a, b| b.uid.cmp(&a.uid));
Ok(out)
}
fn fetch_to_list_entry(msg: &Fetch) -> ListEntry {
let uid = msg.uid.unwrap_or(0);
let flags: Vec<String> = msg.flags().map(|f| format!("{f:?}")).collect();
let header_bytes = msg.header().unwrap_or(&[]);
let parser = MessageParser::default();
let parsed = parser.parse(header_bytes);
let (from, to, subject, date, message_id) = if let Some(m) = parsed.as_ref() {
(
addr_list(m.from()),
addr_list(m.to()),
m.subject().unwrap_or_default().to_string(),
m.date().map(|d| d.to_rfc3339()),
m.message_id().map(|s| s.to_string()),
)
} else {
(vec![], vec![], String::new(), None, None)
};
// We didn't fetch the body for the list view — snippet stays empty.
// (read() fetches the body separately.)
let snippet = String::new();
// has_attachments is best-guessed from Content-Type in the header
// block, since we don't pull the body. multipart/mixed almost always
// means attachments are present.
let has_attachments = parsed
.as_ref()
.and_then(|m| m.content_type())
.map(|ct| {
let main = ct.ctype().to_ascii_lowercase();
let sub = ct.subtype().map(|s| s.to_ascii_lowercase());
main == "multipart" && sub.as_deref() == Some("mixed")
})
.unwrap_or(false);
ListEntry {
uid,
message_id,
from,
to,
subject,
date,
snippet,
has_attachments,
flags,
}
}
// =============================================================================
// read
// =============================================================================
pub async fn read(
account: &Account,
uid: u32,
folder: Option<&str>,
format: &str,
) -> Result<ReadOutput> {
let folder = folder.unwrap_or("INBOX");
let format = match format {
"text" | "html" | "raw_eml" => format,
other => {
return Err(anyhow!(
"format must be one of `text`, `html`, `raw_eml` — got `{other}`"
))
}
};
let mut session = open_session(account).await?;
session
.select(folder)
.await
.with_context(|| format!("SELECT {folder}"))?;
// BODY[] = full RFC822 message. We parse with mail-parser, then either
// return the text part, html part, or raw.
let mut stream = session
.uid_fetch(uid.to_string(), "(UID FLAGS BODY.PEEK[])")
.await
.with_context(|| format!("UID FETCH {uid}"))?;
let first = stream
.next()
.await
.ok_or_else(|| anyhow!("no message at UID {uid} in {folder}"))?
.context("UID FETCH stream")?;
let raw_body = first.body().unwrap_or(&[]).to_vec();
drop(stream);
session.logout().await.ok();
let parser = MessageParser::default();
let parsed = parser
.parse(&raw_body)
.ok_or_else(|| anyhow!("could not parse message bytes"))?;
let body = match format {
"raw_eml" => String::from_utf8_lossy(&raw_body).into_owned(),
"html" => parsed
.body_html(0)
.map(|s| s.into_owned())
.or_else(|| parsed.body_text(0).map(|s| s.into_owned()))
.unwrap_or_default(),
_ => parsed
.body_text(0)
.map(|s| s.into_owned())
.or_else(|| parsed.body_html(0).map(|s| s.into_owned()))
.unwrap_or_default(),
};
let attachments: Vec<AttachmentMeta> = parsed
.attachments()
.map(|att| AttachmentMeta {
filename: att.attachment_name().unwrap_or("attachment").to_string(),
mime_type: format!(
"{}/{}",
att.content_type()
.map(|ct| ct.ctype().to_string())
.unwrap_or_else(|| "application".into()),
att.content_type()
.and_then(|ct| ct.subtype().map(|s| s.to_string()))
.unwrap_or_else(|| "octet-stream".into()),
),
size: att.contents().len(),
})
.collect();
// Headers as a flat JSON map (last-write-wins on duplicates is fine for v0.1).
let mut headers = serde_json::Map::new();
for h in parsed.headers() {
let name = h.name();
let val = h.value().as_text().map(|s| s.to_string()).unwrap_or_default();
headers.insert(name.to_string(), serde_json::Value::String(val));
}
let subject = parsed.subject().unwrap_or_default().to_string();
let snippet_unused: String = body.chars().take(SNIPPET_LEN).collect();
let _ = snippet_unused; // suppress unused (kept structure-wise for symmetry)
Ok(ReadOutput {
uid,
message_id: parsed.message_id().map(|s| s.to_string()),
from: addr_list(parsed.from()),
to: addr_list(parsed.to()),
cc: addr_list(parsed.cc()),
subject,
date: parsed.date().map(|d| d.to_rfc3339()),
headers: serde_json::Value::Object(headers),
body,
format: format.to_string(),
attachments,
})
}
// =============================================================================
// helpers
// =============================================================================
fn addr_list(addrs: Option<&mail_parser::Address>) -> Vec<String> {
let Some(addrs) = addrs else { return vec![] };
let mut out = vec![];
for a in addrs.iter() {
let email = a.address().unwrap_or("");
if email.is_empty() {
continue;
}
match a.name() {
Some(n) if !n.is_empty() => out.push(format!("{n} <{email}>")),
_ => out.push(email.to_string()),
}
}
out
}
fn format_imap_since(iso_date: &str) -> Result<String> {
// YYYY-MM-DD → DD-Mon-YYYY (IMAP requires uppercase 3-letter month).
let parts: Vec<&str> = iso_date.split('-').collect();
if parts.len() != 3 {
return Err(anyhow!("expected YYYY-MM-DD"));
}
let y: u32 = parts[0].parse().context("year")?;
let m: u32 = parts[1].parse().context("month")?;
let d: u32 = parts[2].parse().context("day")?;
let mon = match m {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
_ => return Err(anyhow!("month must be 1..=12")),
};
Ok(format!("{d:02}-{mon}-{y:04}"))
}
async fn open_session(
account: &Account,
) -> Result<async_imap::Session<tokio_rustls::client::TlsStream<TcpStream>>> {
if !account.imap_tls {
return Err(anyhow!(
"plain-IMAP (no TLS) not supported in v0.1 — set imap_tls=true and imap_port=993"
));
}
let addr = format!("{}:{}", account.imap_host, account.imap_port);
let tcp = TcpStream::connect(&addr)
.await
.with_context(|| format!("tcp connect {addr}"))?;
let root_store = rustls_roots();
let cfg = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(cfg));
let server_name = ServerName::try_from(account.imap_host.clone())
.with_context(|| format!("server name `{}`", account.imap_host))?;
let tls = connector
.connect(server_name, tcp)
.await
.with_context(|| format!("tls handshake {}", account.imap_host))?;
let client = async_imap::Client::new(tls);
// greeting was consumed by Client::new in async-imap >= 0.10
let session = client
.login(&account.username, account.resolve_password()?)
.await
.map_err(|(e, _client)| anyhow!("imap login failed: {e}"))?;
Ok(session)
}
fn rustls_roots() -> rustls::RootCertStore {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
roots
}

View file

@ -0,0 +1,66 @@
//! mail-mcp — MCP server entry point.
//!
//! Speaks MCP over stdio. Any MCP client (Claude Code, OpenClaw,
//! kayos-house's bundled claude binary) launches this as a subprocess
//! and gets `mail_send` + `mail_inbox_list` + `mail_inbox_read`.
//!
//! Logging is stderr-only — stdout belongs to the JSON-RPC transport.
mod config;
mod imap;
mod smtp;
mod tools;
use std::process::ExitCode;
use anyhow::Result;
use rmcp::{transport::stdio, ServiceExt};
use tracing_subscriber::EnvFilter;
use crate::config::Config;
use crate::tools::MailService;
#[tokio::main]
async fn main() -> ExitCode {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
// rustls 0.23 requires the process-wide default CryptoProvider to be set
// before any TLS handshake. lettre carries its own internal provider for
// SMTP; our IMAP path goes through tokio-rustls + raw `ClientConfig`,
// which needs this. Install once at startup; ignore the Err which only
// surfaces if a provider is already installed (idempotent).
let _ = rustls::crypto::ring::default_provider().install_default();
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!("{e:#}");
ExitCode::FAILURE
}
}
}
async fn run() -> Result<()> {
let cfg = Config::load()?;
tracing::info!(
accounts = cfg.accounts.len(),
default_account = %cfg.default_account,
"mail-mcp starting"
);
let service = MailService::new(cfg);
let server = service
.serve(stdio())
.await
.map_err(|e| anyhow::anyhow!("rmcp serve failed: {e}"))?;
server
.waiting()
.await
.map_err(|e| anyhow::anyhow!("rmcp wait failed: {e}"))?;
Ok(())
}

229
crates/mail-mcp/src/smtp.rs Normal file
View file

@ -0,0 +1,229 @@
//! Outbound SMTP via `lettre` with explicit header discipline.
//!
//! Headers we guarantee:
//! - `Date` — lettre auto, UTC, RFC 5322
//! - `Message-ID` — `<uuid-v4@{message_id_domain}>` — own-domain, not local hostname
//! - `From` — `name <addr>`
//! - `MIME-Version` — lettre auto
//! - `User-Agent` — `mail-mcp/<crate version>`
//! - `In-Reply-To` — if provided
//! - `References` — if provided (space-joined)
//!
//! Body shape:
//! - body only → `text/plain; charset=utf-8`
//! - body + body_html → `multipart/alternative` (text first per RFC 2046)
//! - attachments → `multipart/mixed` wrap around the alternative
//! (or around the singlepart text body if no html)
use anyhow::{anyhow, Context, Result};
use base64::Engine;
use lettre::message::header::ContentType;
use lettre::message::{Attachment, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use crate::config::Account;
#[derive(Debug, Clone)]
pub struct AttachmentSpec {
pub filename: String,
pub content_base64: String,
pub mime_type: String,
}
#[derive(Debug, Clone, Default)]
pub struct SendInput {
pub to: Vec<String>,
pub cc: Vec<String>,
pub bcc: Vec<String>,
pub subject: String,
pub body: String,
pub body_html: Option<String>,
pub attachments: Vec<AttachmentSpec>,
pub in_reply_to: Option<String>,
pub references: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SendOutput {
pub message_id: String,
pub sent_at: String, // RFC-3339
}
const USER_AGENT: &str = concat!("mail-mcp/", env!("CARGO_PKG_VERSION"));
pub async fn send(account: &Account, input: SendInput) -> Result<SendOutput> {
if input.to.is_empty() {
return Err(anyhow!("at least one `to` address required"));
}
// Build From, To, Cc, Bcc.
let from_str = format!("{} <{}>", account.from_name, account.from_addr);
let mut builder = Message::builder()
.from(from_str.parse().context("parse from address")?)
.subject(&input.subject);
for addr in &input.to {
builder = builder.to(addr.parse().with_context(|| format!("parse to `{addr}`"))?);
}
for addr in &input.cc {
builder = builder.cc(addr.parse().with_context(|| format!("parse cc `{addr}`"))?);
}
for addr in &input.bcc {
builder = builder
.bcc(addr.parse().with_context(|| format!("parse bcc `{addr}`"))?);
}
// Own-domain Message-ID. Lettre defaults to the local hostname; we
// want the sender domain so receivers don't see `<...@container-id>`.
let message_id = format!("<{}@{}>", uuid::Uuid::new_v4(), account.msgid_domain());
builder = builder.message_id(Some(message_id.clone()));
// Threading.
if let Some(parent) = &input.in_reply_to {
builder = builder.in_reply_to(parent.clone());
}
if !input.references.is_empty() {
builder = builder.references(input.references.join(" "));
}
// User-Agent — uses the lettre `user_agent()` shorthand which writes
// the standard header.
builder = builder.user_agent(USER_AGENT.to_string());
// Body.
let body_part: MultiPart = build_body(&input)?;
let email = builder
.multipart(body_part)
.context("compose message")?;
// SMTP transport. STARTTLS on submission port (587) is the canonical
// path; SMTPS-on-465 supported too if someone configures `smtp_starttls = false`.
let creds = Credentials::new(
account.username.clone(),
account.resolve_password()?,
);
let transport: AsyncSmtpTransport<Tokio1Executor> = if account.smtp_starttls {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&account.smtp_host)
.with_context(|| format!("smtp starttls relay {}", account.smtp_host))?
.port(account.smtp_port)
.credentials(creds)
.build()
} else {
AsyncSmtpTransport::<Tokio1Executor>::relay(&account.smtp_host)
.with_context(|| format!("smtp relay {}", account.smtp_host))?
.port(account.smtp_port)
.credentials(creds)
.build()
};
let sent_at = chrono_rfc3339_now();
transport
.send(email)
.await
.with_context(|| format!("send to {}:{}", account.smtp_host, account.smtp_port))?;
Ok(SendOutput {
message_id,
sent_at,
})
}
fn build_body(input: &SendInput) -> Result<MultiPart> {
// Inner content: plain or alternative(plain, html).
let inner_alternative: Option<MultiPart> = input.body_html.as_ref().map(|html| {
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(input.body.clone()),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html.clone()),
)
});
let plain_only: SinglePart = SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(input.body.clone());
if input.attachments.is_empty() {
// No attachments — return alternative if html else a one-part
// "mixed" wrapping just the plain body (keeps return type uniform).
if let Some(alt) = inner_alternative {
return Ok(alt);
}
// Wrap the singlepart in a "mixed" container so we always return
// MultiPart. This adds one MIME boundary but is RFC-valid.
return Ok(MultiPart::mixed().singlepart(plain_only));
}
// Have attachments — multipart/mixed wraps either the alternative
// (if html provided) or just the plain body.
let mut mixed = if let Some(alt) = inner_alternative {
MultiPart::mixed().multipart(alt)
} else {
MultiPart::mixed().singlepart(plain_only)
};
for att in &input.attachments {
let bytes = base64::engine::general_purpose::STANDARD
.decode(&att.content_base64)
.with_context(|| format!("attachment `{}`: invalid base64", att.filename))?;
let content_type: ContentType = att.mime_type.parse().with_context(|| {
format!(
"attachment `{}`: invalid mime_type `{}`",
att.filename, att.mime_type
)
})?;
mixed = mixed.singlepart(
Attachment::new(att.filename.clone()).body(bytes, content_type),
);
}
Ok(mixed)
}
/// Render `now` as RFC-3339 UTC (`2026-05-21T06:42:18Z`). We avoid pulling
/// the full `chrono` crate just for this; build the string by hand from
/// std time.
fn chrono_rfc3339_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// RFC-3339 via the same algorithm `httpdate` uses, simplified for UTC.
let (year, month, day, hour, minute, second) = civil_from_unix(secs as i64);
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hour, minute, second
)
}
/// Convert a unix timestamp (UTC) to civil (Y,M,D,h,m,s). Copy of the
/// classic Howard Hinnant algorithm — works for the full proleptic Gregorian range.
fn civil_from_unix(t: i64) -> (i64, u32, u32, u32, u32, u32) {
let days = t.div_euclid(86_400);
let secs_of_day = t.rem_euclid(86_400);
let hour = (secs_of_day / 3600) as u32;
let minute = ((secs_of_day % 3600) / 60) as u32;
let second = (secs_of_day % 60) as u32;
// 0000-03-01 is the start of the cycle ("era").
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe =
(doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { (mp + 3) as u32 } else { (mp - 9) as u32 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d, hour, minute, second)
}

View file

@ -0,0 +1,260 @@
//! `MailService` — the rmcp tool surface.
//!
//! Three tools exposed in v0.1:
//! - `mail_send`
//! - `mail_inbox_list`
//! - `mail_inbox_read`
//!
//! All tool methods return `Result<String, String>` where the success path
//! holds a JSON-serialized payload and the error path is a pre-rendered
//! message string suitable for surfacing to the LLM.
use std::sync::Arc;
use rmcp::{
model::{ServerCapabilities, ServerInfo},
schemars,
tool, ServerHandler,
};
use serde::Deserialize;
use crate::config::Config;
use crate::{imap as imap_mod, smtp as smtp_mod};
// =============================================================================
// service
// =============================================================================
#[derive(Clone)]
pub struct MailService {
inner: Arc<MailInner>,
}
struct MailInner {
config: Config,
}
impl MailService {
pub fn new(config: Config) -> Self {
Self {
inner: Arc::new(MailInner { config }),
}
}
}
// =============================================================================
// args
// =============================================================================
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct AttachmentArg {
/// Filename as the recipient should see it.
pub filename: String,
/// Base64-encoded payload (no `data:` URI prefix).
pub content_base64: String,
/// MIME type, e.g. `application/pdf` or `image/png`.
pub mime_type: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SendArgs {
/// Account name to send from. Falls back to `default_account` from config.
#[serde(default)]
pub account: Option<String>,
/// Recipient — single string or array of strings.
pub to: ToField,
#[serde(default)]
pub cc: Vec<String>,
#[serde(default)]
pub bcc: Vec<String>,
pub subject: String,
/// Plain-text body. Required even when also sending HTML (text part
/// shows up in `multipart/alternative` first per RFC 2046).
pub body: String,
/// Optional HTML body. When present, the message becomes
/// `multipart/alternative` (text first, then html).
#[serde(default)]
pub body_html: Option<String>,
#[serde(default)]
pub attachments: Vec<AttachmentArg>,
/// Message-ID of the parent message — sets `In-Reply-To` header.
#[serde(default)]
pub in_reply_to: Option<String>,
/// Full thread chain of Message-IDs — sets `References` header.
#[serde(default)]
pub references: Vec<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(untagged)]
pub enum ToField {
One(String),
Many(Vec<String>),
}
impl ToField {
fn into_vec(self) -> Vec<String> {
match self {
ToField::One(s) => vec![s],
ToField::Many(v) => v,
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ListArgs {
#[serde(default)]
pub account: Option<String>,
/// YYYY-MM-DD; passed as IMAP SINCE.
#[serde(default)]
pub since: Option<String>,
/// If true, only list messages without the \Seen flag.
#[serde(default)]
pub unread_only: bool,
/// Max entries to return — default 50, max 500.
#[serde(default)]
pub limit: u32,
/// IMAP folder. Default `INBOX`.
#[serde(default)]
pub folder: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ReadArgs {
#[serde(default)]
pub account: Option<String>,
/// UID of the message (stable across selects, unlike sequence numbers).
pub uid: u32,
/// IMAP folder. Default `INBOX`.
#[serde(default)]
pub folder: Option<String>,
/// `text` (default) returns the text/plain part (falls back to html-stripped if absent).
/// `html` returns the html part.
/// `raw_eml` returns the full RFC822 source.
#[serde(default)]
pub format: Option<String>,
}
// =============================================================================
// tools
// =============================================================================
#[tool(tool_box)]
impl MailService {
#[tool(
name = "mail_send",
description = "Send mail via Sulkta's SMTP relay. Sets RFC-correct Date, Message-ID (with own-domain), From, MIME-Version, User-Agent. Supports multipart/alternative when body_html is present and multipart/mixed when attachments are attached. Use `in_reply_to` + `references` for thread continuation. Returns JSON {message_id, sent_at}."
)]
async fn mail_send(
&self,
#[tool(aggr)] args: SendArgs,
) -> Result<String, String> {
let account = self
.inner
.config
.account(args.account.as_deref())
.map_err(|e| e.to_string())?;
let input = smtp_mod::SendInput {
to: args.to.into_vec(),
cc: args.cc,
bcc: args.bcc,
subject: args.subject,
body: args.body,
body_html: args.body_html,
attachments: args
.attachments
.into_iter()
.map(|a| smtp_mod::AttachmentSpec {
filename: a.filename,
content_base64: a.content_base64,
mime_type: a.mime_type,
})
.collect(),
in_reply_to: args.in_reply_to,
references: args.references,
};
let out = smtp_mod::send(account, input)
.await
.map_err(|e| format!("{e:#}"))?;
serde_json::to_string(&serde_json::json!({
"message_id": out.message_id,
"sent_at": out.sent_at,
}))
.map_err(|e| e.to_string())
}
#[tool(
name = "mail_inbox_list",
description = "List messages in an IMAP folder (default INBOX), newest UID first. Supports SINCE date (YYYY-MM-DD) and unread-only filter. Each entry has uid, message_id, from, to, subject, date, has_attachments, flags. Does NOT mark messages as read (BODY.PEEK). Returns JSON array."
)]
async fn mail_inbox_list(
&self,
#[tool(aggr)] args: ListArgs,
) -> Result<String, String> {
let account = self
.inner
.config
.account(args.account.as_deref())
.map_err(|e| e.to_string())?;
let entries = imap_mod::list(
account,
imap_mod::ListOpts {
since: args.since,
unread_only: args.unread_only,
limit: args.limit,
folder: args.folder,
},
)
.await
.map_err(|e| format!("{e:#}"))?;
serde_json::to_string(&entries).map_err(|e| e.to_string())
}
#[tool(
name = "mail_inbox_read",
description = "Fetch one message by UID from an IMAP folder. format=text (default) returns the text/plain part, format=html returns the HTML part, format=raw_eml returns the full RFC822 source. Attachment payloads are NOT inlined — only filename/mime_type/size metadata. Does NOT mark as read."
)]
async fn mail_inbox_read(
&self,
#[tool(aggr)] args: ReadArgs,
) -> Result<String, String> {
let account = self
.inner
.config
.account(args.account.as_deref())
.map_err(|e| e.to_string())?;
let out = imap_mod::read(
account,
args.uid,
args.folder.as_deref(),
args.format.as_deref().unwrap_or("text"),
)
.await
.map_err(|e| format!("{e:#}"))?;
serde_json::to_string(&out).map_err(|e| e.to_string())
}
}
// =============================================================================
// ServerHandler — capabilities must be set explicitly. rmcp 0.1.x's
// `#[tool(tool_box)]` does NOT auto-fill ServerInfo capabilities, so
// without `enable_tools()` the client reads an empty capability set and
// never asks for tools/list. (Same lesson aldabra learned the hard way.)
// =============================================================================
#[tool(tool_box)]
impl ServerHandler for MailService {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
instructions: Some(
"mail-mcp — Rust MCP server for Sulkta-hosted email. \
Tools: mail_send, mail_inbox_list, mail_inbox_read. \
Default account from config; pass `account` to switch. \
Reads use BODY.PEEK so they don't toggle the \\Seen flag."
.into(),
),
..Default::default()
}
}
}