//! `MailService` — the rmcp tool surface. //! //! Three tools exposed in v0.1: //! - `mail_send` //! - `mail_inbox_list` //! - `mail_inbox_read` //! //! All tool methods return `Result` 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, } 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, /// Recipient — single string or array of strings. pub to: ToField, #[serde(default)] pub cc: Vec, #[serde(default)] pub bcc: Vec, 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, #[serde(default)] pub attachments: Vec, /// Message-ID of the parent message — sets `In-Reply-To` header. #[serde(default)] pub in_reply_to: Option, /// Full thread chain of Message-IDs — sets `References` header. #[serde(default)] pub references: Vec, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(untagged)] pub enum ToField { One(String), Many(Vec), } impl ToField { fn into_vec(self) -> Vec { match self { ToField::One(s) => vec![s], ToField::Many(v) => v, } } } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct ListArgs { #[serde(default)] pub account: Option, /// YYYY-MM-DD; passed as IMAP SINCE. #[serde(default)] pub since: Option, /// 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, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct ReadArgs { #[serde(default)] pub account: Option, /// UID of the message (stable across selects, unlike sequence numbers). pub uid: u32, /// IMAP folder. Default `INBOX`. #[serde(default)] pub folder: Option, /// `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, } // ============================================================================= // tools // ============================================================================= #[tool(tool_box)] impl MailService { #[tool( name = "mail_send", description = "Send mail via Sulkta's 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 { 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 { 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_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 { 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() } } }