carrier/crates/mail-mcp/src/tools.rs
Sulkta b2716e0f4c 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 user@example.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.
2026-05-21 07:26:44 -07:00

400 lines
13 KiB
Rust

//! `MailService` — the rmcp tool surface.
//!
//! Three tools exposed in v0.1:
//! - `mail_send`
//! - `mail_inbox_list`
//! - `mail_inbox_read`
//!
//! All tool methods return `Result<String, String>` where the success path
//! holds a JSON-serialized payload and the error path is a pre-rendered
//! message string suitable for surfacing to the LLM.
use std::sync::Arc;
use rmcp::{
model::{ServerCapabilities, ServerInfo},
schemars,
tool, ServerHandler,
};
use serde::Deserialize;
use crate::config::Config;
use crate::{imap as imap_mod, smtp as smtp_mod};
// =============================================================================
// service
// =============================================================================
#[derive(Clone)]
pub struct MailService {
inner: Arc<MailInner>,
}
struct MailInner {
config: Config,
}
impl MailService {
pub fn new(config: Config) -> Self {
Self {
inner: Arc::new(MailInner { config }),
}
}
}
// =============================================================================
// args
// =============================================================================
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct AttachmentArg {
/// Filename as the recipient should see it.
pub filename: String,
/// Base64-encoded payload (no `data:` URI prefix).
pub content_base64: String,
/// MIME type, e.g. `application/pdf` or `image/png`.
pub mime_type: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SendArgs {
/// Account name to send from. Falls back to `default_account` from config.
#[serde(default)]
pub account: Option<String>,
/// Recipient — single string or array of strings.
pub to: ToField,
#[serde(default)]
pub cc: Vec<String>,
#[serde(default)]
pub bcc: Vec<String>,
pub subject: String,
/// Plain-text body. Required even when also sending HTML (text part
/// shows up in `multipart/alternative` first per RFC 2046).
pub body: String,
/// Optional HTML body. When present, the message becomes
/// `multipart/alternative` (text first, then html).
#[serde(default)]
pub body_html: Option<String>,
#[serde(default)]
pub attachments: Vec<AttachmentArg>,
/// Message-ID of the parent message — sets `In-Reply-To` header.
#[serde(default)]
pub in_reply_to: Option<String>,
/// Full thread chain of Message-IDs — sets `References` header.
#[serde(default)]
pub references: Vec<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(untagged)]
pub enum ToField {
One(String),
Many(Vec<String>),
}
impl ToField {
fn into_vec(self) -> Vec<String> {
match self {
ToField::One(s) => vec![s],
ToField::Many(v) => v,
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ListArgs {
#[serde(default)]
pub account: Option<String>,
/// YYYY-MM-DD; passed as IMAP SINCE.
#[serde(default)]
pub since: Option<String>,
/// If true, only list messages without the \Seen flag.
#[serde(default)]
pub unread_only: bool,
/// Max entries to return — default 50, max 500.
#[serde(default)]
pub limit: u32,
/// IMAP folder. Default `INBOX`.
#[serde(default)]
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 "bob@example.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)]
pub account: Option<String>,
/// UID of the message (stable across selects, unlike sequence numbers).
pub uid: u32,
/// IMAP folder. Default `INBOX`.
#[serde(default)]
pub folder: Option<String>,
/// `text` (default) returns the text/plain part (falls back to html-stripped if absent).
/// `html` returns the html part.
/// `raw_eml` returns the full RFC822 source.
#[serde(default)]
pub format: Option<String>,
}
// =============================================================================
// tools
// =============================================================================
#[tool(tool_box)]
impl MailService {
#[tool(
name = "mail_send",
description = "Send mail via the configured SMTP relay. Sets RFC-correct Date, Message-ID (with own-domain), From, MIME-Version, User-Agent. Supports multipart/alternative when body_html is present and multipart/mixed when attachments are attached. Use `in_reply_to` + `references` for thread continuation. Returns JSON {message_id, sent_at}."
)]
async fn mail_send(
&self,
#[tool(aggr)] args: SendArgs,
) -> Result<String, String> {
let account = self
.inner
.config
.account(args.account.as_deref())
.map_err(|e| e.to_string())?;
let input = smtp_mod::SendInput {
to: args.to.into_vec(),
cc: args.cc,
bcc: args.bcc,
subject: args.subject,
body: args.body,
body_html: args.body_html,
attachments: args
.attachments
.into_iter()
.map(|a| smtp_mod::AttachmentSpec {
filename: a.filename,
content_base64: a.content_base64,
mime_type: a.mime_type,
})
.collect(),
in_reply_to: args.in_reply_to,
references: args.references,
};
let out = smtp_mod::send(account, input)
.await
.map_err(|e| format!("{e:#}"))?;
serde_json::to_string(&serde_json::json!({
"message_id": out.message_id,
"sent_at": out.sent_at,
}))
.map_err(|e| e.to_string())
}
#[tool(
name = "mail_inbox_list",
description = "List messages in an IMAP folder (default INBOX), newest UID first. Supports SINCE date (YYYY-MM-DD) and unread-only filter. Each entry has uid, message_id, from, to, subject, date, has_attachments, flags. Does NOT mark messages as read (BODY.PEEK). Returns JSON array."
)]
async fn mail_inbox_list(
&self,
#[tool(aggr)] args: ListArgs,
) -> Result<String, String> {
let account = self
.inner
.config
.account(args.account.as_deref())
.map_err(|e| e.to_string())?;
let entries = imap_mod::list(
account,
imap_mod::ListOpts {
since: args.since,
unread_only: args.unread_only,
limit: args.limit,
folder: args.folder,
},
)
.await
.map_err(|e| format!("{e:#}"))?;
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 \"bob@example.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."
)]
async fn mail_inbox_read(
&self,
#[tool(aggr)] args: ReadArgs,
) -> Result<String, String> {
let account = self
.inner
.config
.account(args.account.as_deref())
.map_err(|e| e.to_string())?;
let out = imap_mod::read(
account,
args.uid,
args.folder.as_deref(),
args.format.as_deref().unwrap_or("text"),
)
.await
.map_err(|e| format!("{e:#}"))?;
serde_json::to_string(&out).map_err(|e| e.to_string())
}
}
// =============================================================================
// ServerHandler — capabilities must be set explicitly. rmcp 0.1.x's
// `#[tool(tool_box)]` does NOT auto-fill ServerInfo capabilities, so
// without `enable_tools()` the client reads an empty capability set and
// never asks for tools/list. (Same lesson aldabra learned the hard way.)
// =============================================================================
#[tool(tool_box)]
impl ServerHandler for MailService {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
instructions: Some(
"mail-mcp — Rust MCP server for Sulkta-hosted email. \
Tools: mail_send, mail_inbox_list, mail_inbox_read. \
Default account from config; pass `account` to switch. \
Reads use BODY.PEEK so they don't toggle the \\Seen flag."
.into(),
),
..Default::default()
}
}
}