Phase B + folders: mail_folder_list, mail_search, mail_thread, mail_move

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.
This commit is contained in:
Kayos 2026-05-21 07:26:44 -07:00
parent 2240bf745e
commit 4251f514e6
2 changed files with 384 additions and 0 deletions

View file

@ -63,6 +63,15 @@ pub struct AttachmentMeta {
pub size: usize, pub size: usize,
} }
#[derive(Debug, Clone, Serialize)]
pub struct FolderEntry {
pub name: String,
pub delimiter: Option<String>,
pub attributes: Vec<String>,
/// True if this mailbox can be SELECTed (no `\Noselect` attribute).
pub selectable: bool,
}
const DEFAULT_LIMIT: u32 = 50; const DEFAULT_LIMIT: u32 = 50;
const MAX_LIMIT: u32 = 500; const MAX_LIMIT: u32 = 500;
const SNIPPET_LEN: usize = 240; const SNIPPET_LEN: usize = 240;
@ -295,10 +304,245 @@ pub async fn read(
}) })
} }
// =============================================================================
// folders
// =============================================================================
pub async fn list_folders(account: &Account) -> Result<Vec<FolderEntry>> {
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<FolderEntry> = Vec::new();
while let Some(item) = stream.next().await {
let name = item.context("LIST stream item")?;
let attrs: Vec<String> = 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<Vec<ListEntry>> {
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<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));
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<Vec<ListEntry>> {
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<u32> = {
let set = session
.uid_search(&q)
.await
.with_context(|| format!("UID SEARCH (thread {id_unbraced})"))?;
let mut v: Vec<u32> = 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 // 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<tokio_rustls::client::TlsStream<TcpStream>>,
uids: &[u32],
) -> Result<Vec<ListEntry>> {
if uids.is_empty() {
return Ok(Vec::new());
}
let seq = uids
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.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<ListEntry> = 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<String> { fn addr_list(addrs: Option<&mail_parser::Address>) -> Vec<String> {
let Some(addrs) = addrs else { return vec![] }; let Some(addrs) = addrs else { return vec![] };
let mut out = vec![]; let mut out = vec![];

View file

@ -119,6 +119,55 @@ pub struct ListArgs {
pub folder: Option<String>, pub folder: Option<String>,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct FolderListArgs {
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SearchArgs {
#[serde(default)]
pub account: Option<String>,
/// 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<String>,
/// Max entries — default 50, max 500.
#[serde(default)]
pub limit: u32,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ThreadArgs {
#[serde(default)]
pub account: Option<String>,
/// Seed Message-ID. Angle brackets optional — `abc@host` and
/// `<abc@host>` both work.
pub message_id: String,
/// IMAP folder. Default `INBOX`.
#[serde(default)]
pub folder: Option<String>,
/// Max entries — default 50, max 500.
#[serde(default)]
pub limit: u32,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct MoveArgs {
#[serde(default)]
pub account: Option<String>,
pub uid: u32,
/// Source folder. Default `INBOX`.
#[serde(default)]
pub from_folder: Option<String>,
/// Destination folder — must already exist on the server.
pub to_folder: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ReadArgs { pub struct ReadArgs {
#[serde(default)] #[serde(default)]
@ -210,6 +259,97 @@ impl MailService {
serde_json::to_string(&entries).map_err(|e| e.to_string()) 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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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( #[tool(
name = "mail_inbox_read", 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." 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."