diff --git a/crates/mail-mcp/src/imap.rs b/crates/mail-mcp/src/imap.rs index 954d4e4..a50c8a5 100644 --- a/crates/mail-mcp/src/imap.rs +++ b/crates/mail-mcp/src/imap.rs @@ -63,6 +63,15 @@ pub struct AttachmentMeta { pub size: usize, } +#[derive(Debug, Clone, Serialize)] +pub struct FolderEntry { + pub name: String, + pub delimiter: Option, + pub attributes: Vec, + /// True if this mailbox can be SELECTed (no `\Noselect` attribute). + pub selectable: bool, +} + const DEFAULT_LIMIT: u32 = 50; const MAX_LIMIT: u32 = 500; const SNIPPET_LEN: usize = 240; @@ -295,10 +304,245 @@ pub async fn read( }) } +// ============================================================================= +// folders +// ============================================================================= + +pub async fn list_folders(account: &Account) -> Result> { + let mut session = open_session(account).await?; + // IMAP LIST "" "*" enumerates every mailbox visible to the user. + let mut stream = session + .list(Some(""), Some("*")) + .await + .context("IMAP LIST")?; + + let mut out: Vec = Vec::new(); + while let Some(item) = stream.next().await { + let name = item.context("LIST stream item")?; + let attrs: Vec = name + .attributes() + .iter() + .map(|a| format!("{a:?}")) + .collect(); + let selectable = !attrs.iter().any(|a| a.eq_ignore_ascii_case("Noselect")); + out.push(FolderEntry { + name: name.name().to_string(), + delimiter: name.delimiter().map(|s| s.to_string()), + attributes: attrs, + selectable, + }); + } + drop(stream); + session.logout().await.ok(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +// ============================================================================= +// search — raw IMAP SEARCH query passthrough +// ============================================================================= + +pub async fn search( + account: &Account, + query: &str, + folder: Option<&str>, + limit: u32, +) -> Result> { + if query.trim().is_empty() { + return Err(anyhow!("search query is empty")); + } + // Reject queries that contain CR/LF — those would let a caller inject + // additional IMAP commands. We're more restrictive than RFC; IMAP + // search keys never legitimately span newlines. + if query.contains('\r') || query.contains('\n') { + return Err(anyhow!("search query must not contain CR or LF")); + } + let folder = folder.unwrap_or("INBOX"); + let limit = match 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}"))?; + + let uids: Vec = { + let set = session + .uid_search(query) + .await + .with_context(|| format!("UID SEARCH {query}"))?; + let mut v: Vec = set.into_iter().collect(); + v.sort_unstable_by(|a, b| b.cmp(a)); + v.truncate(limit as usize); + v + }; + + let entries = fetch_summaries(&mut session, &uids).await?; + session.logout().await.ok(); + Ok(entries) +} + +// ============================================================================= +// thread — follow References / In-Reply-To chain +// ============================================================================= + +pub async fn thread( + account: &Account, + message_id: &str, + folder: Option<&str>, + limit: u32, +) -> Result> { + let id_unbraced = strip_msgid_braces(message_id); + if id_unbraced.is_empty() { + return Err(anyhow!("message_id is empty")); + } + if id_unbraced.contains('"') || id_unbraced.contains('\r') || id_unbraced.contains('\n') { + return Err(anyhow!("message_id must not contain quotes, CR, or LF")); + } + let folder = folder.unwrap_or("INBOX"); + let limit = match 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}"))?; + + // Single search: messages where Message-Id IS the seed OR References + // contains the seed OR In-Reply-To contains the seed. + // + // IMAP OR is binary, so we nest: OR a (OR b c) — matches a OR b OR c. + let q = format!( + "OR HEADER \"Message-Id\" \"{id}\" OR HEADER References \"{id}\" HEADER \"In-Reply-To\" \"{id}\"", + id = id_unbraced, + ); + let uids: Vec = { + let set = session + .uid_search(&q) + .await + .with_context(|| format!("UID SEARCH (thread {id_unbraced})"))?; + let mut v: Vec = set.into_iter().collect(); + // Oldest first for thread reads (root → leaves). + v.sort_unstable(); + v.truncate(limit as usize); + v + }; + + let mut entries = fetch_summaries(&mut session, &uids).await?; + session.logout().await.ok(); + // fetch_summaries returns newest-first; flip for thread display. + entries.sort_by(|a, b| a.uid.cmp(&b.uid)); + Ok(entries) +} + +fn strip_msgid_braces(s: &str) -> &str { + let s = s.trim(); + let s = s.strip_prefix('<').unwrap_or(s); + s.strip_suffix('>').unwrap_or(s) +} + +// ============================================================================= +// move — UID MOVE with COPY+EXPUNGE fallback +// ============================================================================= + +pub async fn move_msg( + account: &Account, + uid: u32, + from_folder: Option<&str>, + to_folder: &str, +) -> Result<()> { + let from = from_folder.unwrap_or("INBOX"); + if to_folder.is_empty() { + return Err(anyhow!("to_folder cannot be empty")); + } + if to_folder.contains('\r') || to_folder.contains('\n') { + return Err(anyhow!("to_folder must not contain CR or LF")); + } + + let mut session = open_session(account).await?; + session + .select(from) + .await + .with_context(|| format!("SELECT {from}"))?; + + // Try MOVE (RFC 6851); fall back to COPY + STORE \Deleted + EXPUNGE. + let uid_str = uid.to_string(); + match session.uid_mv(&uid_str, to_folder).await { + Ok(()) => { + session.logout().await.ok(); + return Ok(()); + } + Err(e) => { + tracing::debug!(error = %e, "UID MOVE unsupported, falling back to COPY+EXPUNGE"); + } + } + + session + .uid_copy(&uid_str, to_folder) + .await + .with_context(|| format!("UID COPY {uid} -> {to_folder}"))?; + { + let store = session + .uid_store(&uid_str, "+FLAGS (\\Deleted)") + .await + .with_context(|| format!("UID STORE +FLAGS \\Deleted on {uid}"))?; + let mut store = Box::pin(store); + while store.next().await.is_some() {} + } + { + let expunge = session.uid_expunge(&uid_str).await.context("UID EXPUNGE")?; + let mut expunge = Box::pin(expunge); + while expunge.next().await.is_some() {} + } + + session.logout().await.ok(); + Ok(()) +} + // ============================================================================= // helpers // ============================================================================= +/// Common fetch path used by `list`, `search`, and `thread`. Takes an +/// already-SELECTed session and a UID set, returns summary entries. +async fn fetch_summaries( + session: &mut async_imap::Session>, + uids: &[u32], +) -> Result> { + if uids.is_empty() { + return Ok(Vec::new()); + } + let seq = uids + .iter() + .map(|u| u.to_string()) + .collect::>() + .join(","); + 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}"))?; + + let mut out: Vec = Vec::with_capacity(uids.len()); + while let Some(msg_res) = stream.next().await { + let msg = msg_res.context("UID FETCH stream item")?; + out.push(fetch_to_list_entry(&msg)); + } + drop(stream); + // Default to newest-first; callers (thread) re-sort if they want oldest-first. + out.sort_by(|a, b| b.uid.cmp(&a.uid)); + Ok(out) +} + + fn addr_list(addrs: Option<&mail_parser::Address>) -> Vec { let Some(addrs) = addrs else { return vec![] }; let mut out = vec![]; diff --git a/crates/mail-mcp/src/tools.rs b/crates/mail-mcp/src/tools.rs index a4ea54c..e4e39c1 100644 --- a/crates/mail-mcp/src/tools.rs +++ b/crates/mail-mcp/src/tools.rs @@ -119,6 +119,55 @@ pub struct ListArgs { pub folder: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct FolderListArgs { + #[serde(default)] + pub account: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct SearchArgs { + #[serde(default)] + pub account: Option, + /// Raw IMAP SEARCH query — e.g. `SUBJECT "invoice"` or + /// `FROM "cobb@sulkta.com" SINCE 21-May-2026`. ALL / OR / NOT + /// combinators supported. CR/LF rejected to block injection. + pub query: String, + /// IMAP folder. Default `INBOX`. + #[serde(default)] + pub folder: Option, + /// Max entries — default 50, max 500. + #[serde(default)] + pub limit: u32, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ThreadArgs { + #[serde(default)] + pub account: Option, + /// Seed Message-ID. Angle brackets optional — `abc@host` and + /// `` both work. + pub message_id: String, + /// IMAP folder. Default `INBOX`. + #[serde(default)] + pub folder: Option, + /// Max entries — default 50, max 500. + #[serde(default)] + pub limit: u32, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct MoveArgs { + #[serde(default)] + pub account: Option, + pub uid: u32, + /// Source folder. Default `INBOX`. + #[serde(default)] + pub from_folder: Option, + /// Destination folder — must already exist on the server. + pub to_folder: String, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct ReadArgs { #[serde(default)] @@ -210,6 +259,97 @@ impl MailService { serde_json::to_string(&entries).map_err(|e| e.to_string()) } + #[tool( + name = "mail_folder_list", + description = "Enumerate every IMAP folder/mailbox visible to the account. Returns JSON array of {name, delimiter, attributes, selectable}. `selectable=false` means the folder can't be SELECTed (parent-of-children-only nodes carry the \\Noselect attribute). Sorted by name." + )] + async fn mail_folder_list( + &self, + #[tool(aggr)] args: FolderListArgs, + ) -> Result { + let account = self + .inner + .config + .account(args.account.as_deref()) + .map_err(|e| e.to_string())?; + let folders = imap_mod::list_folders(account) + .await + .map_err(|e| format!("{e:#}"))?; + serde_json::to_string(&folders).map_err(|e| e.to_string()) + } + + #[tool( + name = "mail_search", + description = "Raw IMAP SEARCH passthrough against a folder (default INBOX). Examples: `SUBJECT \"invoice\"`, `FROM \"cobb@sulkta.com\"`, `SINCE 21-May-2026 UNSEEN`, `OR SUBJECT \"foo\" SUBJECT \"bar\"`. CR/LF in the query are rejected (anti-injection). Returns the same summary shape as mail_inbox_list, newest UID first." + )] + async fn mail_search( + &self, + #[tool(aggr)] args: SearchArgs, + ) -> Result { + let account = self + .inner + .config + .account(args.account.as_deref()) + .map_err(|e| e.to_string())?; + let entries = imap_mod::search( + account, + &args.query, + args.folder.as_deref(), + args.limit, + ) + .await + .map_err(|e| format!("{e:#}"))?; + serde_json::to_string(&entries).map_err(|e| e.to_string()) + } + + #[tool( + name = "mail_thread", + description = "Fetch all messages in a thread by seed Message-ID — matches the seed itself plus any message whose References or In-Reply-To header contains the seed. Returns summary entries oldest-first (root → leaves). Pass message_id with or without angle brackets." + )] + async fn mail_thread( + &self, + #[tool(aggr)] args: ThreadArgs, + ) -> Result { + let account = self + .inner + .config + .account(args.account.as_deref()) + .map_err(|e| e.to_string())?; + let entries = imap_mod::thread( + account, + &args.message_id, + args.folder.as_deref(), + args.limit, + ) + .await + .map_err(|e| format!("{e:#}"))?; + serde_json::to_string(&entries).map_err(|e| e.to_string()) + } + + #[tool( + name = "mail_move", + description = "Move a message by UID from one folder to another. Uses IMAP UID MOVE (RFC 6851) when supported, falls back to COPY + STORE +FLAGS \\Deleted + UID EXPUNGE. Destination folder must already exist on the server. Returns JSON {ok:true}." + )] + async fn mail_move( + &self, + #[tool(aggr)] args: MoveArgs, + ) -> Result { + let account = self + .inner + .config + .account(args.account.as_deref()) + .map_err(|e| e.to_string())?; + imap_mod::move_msg( + account, + args.uid, + args.from_folder.as_deref(), + &args.to_folder, + ) + .await + .map_err(|e| format!("{e:#}"))?; + Ok(r#"{"ok":true}"#.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."