mod config;
mod imap_client;
mod smtp_client;
use config::EmailConfig;
use rmcp::{
ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::*,
tool, tool_handler, tool_router,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct EmailReadParams {
#[serde(default = "default_count")]
pub count: u32,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct EmailSendParams {
pub to: String,
pub subject: String,
pub body: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct EmailSearchParams {
pub query: String,
#[serde(default = "default_max_results")]
pub max_results: u32,
}
fn default_count() -> u32 {
5
}
fn default_max_results() -> u32 {
10
}
#[derive(Debug, Clone)]
struct EmailServer {
config: Option<EmailConfig>,
config_error: Option<String>,
tool_router: ToolRouter<Self>,
}
impl EmailServer {
fn new() -> Self {
let (config, config_error) = match EmailConfig::from_env() {
Ok(c) => (Some(c), None),
Err(e) => (None, Some(format!("{e:#}"))),
};
Self {
config,
config_error,
tool_router: Self::tool_router(),
}
}
fn require_config(&self) -> Result<&EmailConfig, rmcp::ErrorData> {
self.config.as_ref().ok_or_else(|| {
let msg = format!(
"Email not configured: {}\n\n{}",
self.config_error.as_deref().unwrap_or("unknown error"),
EmailConfig::setup_instructions()
);
rmcp::ErrorData::internal_error(msg, None)
})
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for EmailServer {
fn get_info(&self) -> ServerInfo {
let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build());
info.server_info = Implementation::new("koda-email", env!("CARGO_PKG_VERSION"));
info.instructions = Some(
"Email server for reading, sending, and searching emails via IMAP/SMTP. \
Configure with KODA_EMAIL_* environment variables."
.to_string(),
);
info
}
}
#[tool_router]
impl EmailServer {
#[tool(
name = "EmailRead",
description = "Read recent emails from INBOX. Returns subject, sender, date, and a text snippet for each email. Use 'count' to control how many (default 5, max 20)."
)]
async fn email_read(
&self,
params: Parameters<EmailReadParams>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let config = self.require_config()?;
let count = params.0.count.clamp(1, 20);
match imap_client::read_emails(config, count).await {
Ok(emails) if emails.is_empty() => Ok(CallToolResult::success(vec![Content::text(
"No emails found in INBOX.",
)])),
Ok(emails) => {
let output = format_email_list(&emails);
Ok(CallToolResult::success(vec![Content::text(output)]))
}
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error reading emails: {e:#}"
))])),
}
}
#[tool(
name = "EmailSend",
description = "Send an email via SMTP. Requires 'to' (recipient), 'subject', and 'body'."
)]
async fn email_send(
&self,
params: Parameters<EmailSendParams>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let config = self.require_config()?;
let p = ¶ms.0;
match smtp_client::send_email(config, &p.to, &p.subject, &p.body).await {
Ok(msg) => Ok(CallToolResult::success(vec![Content::text(msg)])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error sending email: {e:#}"
))])),
}
}
#[tool(
name = "EmailSearch",
description = "Search emails in INBOX. Plain text searches subject and body. Use 'from:addr' to search by sender, 'subject:text' to search by subject line."
)]
async fn email_search(
&self,
params: Parameters<EmailSearchParams>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let config = self.require_config()?;
let p = ¶ms.0;
let max = p.max_results.clamp(1, 50);
match imap_client::search_emails(config, &p.query, max).await {
Ok(emails) if emails.is_empty() => Ok(CallToolResult::success(vec![Content::text(
format!("No emails found matching: {}", p.query),
)])),
Ok(emails) => {
let output = format!(
"Found {} result(s) for \"{}\":\n\n{}",
emails.len(),
p.query,
format_email_list(&emails)
);
Ok(CallToolResult::success(vec![Content::text(output)]))
}
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error searching emails: {e:#}"
))])),
}
}
}
fn format_email_list(emails: &[imap_client::EmailSummary]) -> String {
emails
.iter()
.enumerate()
.map(|(i, e)| {
format!(
"{}. [{}] {}\n From: {}\n Date: {}\n {}\n",
i + 1,
e.uid,
e.subject,
e.from,
e.date,
if e.snippet.is_empty() {
"(no preview)".to_string()
} else {
e.snippet.clone()
}
)
})
.collect::<Vec<_>>()
.join("\n")
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
if std::env::args().any(|a| a == "--version" || a == "-V") {
println!("koda-email {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.with_writer(std::io::stderr)
.init();
tracing::info!("koda-email MCP server starting...");
let server = EmailServer::new();
if server.config.is_none() {
tracing::warn!(
"Email credentials not configured. Tools will return setup instructions.\n{}",
EmailConfig::setup_instructions()
);
}
let service = server.serve(rmcp::transport::io::stdio()).await?;
service.waiting().await?;
Ok(())
}