carrier/crates/mail-mcp/src/tools.rs
Kayos 2240bf745e mail-mcp v0.1 — Rust MCP server for Sulkta email
Phase A: mail_send + mail_inbox_list + mail_inbox_read. Replaces
scripts/kayos_mail.py with a typed MCP server. Outbound guarantees Date,
Message-ID (own-domain), User-Agent, MIME-Version, multipart/alternative
for HTML+text, multipart/mixed for attachments, In-Reply-To +
References for threading.

Single account in v0.1 (default_account from config). Phase B adds
multi-account + threading + search; Phase C adds mark + attachments +
reply helper.

Stack: rmcp 0.1 (matches aldabra), lettre 0.11 + tokio-rustls, async-imap
0.10, mail-parser 0.9. Stderr-only logging (stdout is the MCP transport).

Smoke verified 2026-05-21: send -> land -> read kayos@sulkta.com round
trip, DKIM-Signature + Authentication-Results pass at the rspamd relay.
2026-05-21 06:50:25 -07:00

260 lines
8.6 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 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 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<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_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()
}
}
}