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

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
}