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:
parent
2240bf745e
commit
4251f514e6
2 changed files with 384 additions and 0 deletions
|
|
@ -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![];
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue