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,
|
||||
}
|
||||
|
||||
#[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 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<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
|
||||
// =============================================================================
|
||||
|
||||
/// 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> {
|
||||
let Some(addrs) = addrs else { return vec![] };
|
||||
let mut out = vec![];
|
||||
|
|
|
|||
|
|
@ -119,6 +119,55 @@ pub struct ListArgs {
|
|||
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)]
|
||||
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<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(
|
||||
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."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue