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.
260 lines
8.6 KiB
Rust
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()
|
|
}
|
|
}
|
|
}
|