use crate::{
ConnectionStatus, CreateDraftResponse, CreateMailboxResponse, DeleteBySenderResponse,
DeleteListIdResponse, DeleteMessagesResponse, DownloadAttachmentsResponse, DraftAttachment,
FindAttachmentsResponse, GetMessagesResponse, ListAccountsResponse, ListCapabilitiesResponse,
ListFlagsResponse, ListMailboxesResponse, MoveMessageResponse, RankListIdResponse,
RankSendersResponse, RankUnsubscribeResponse, SearchMessagesResponse, UnsubscribeResponse,
UpdateFlagsResponse,
};
use hashbrown::HashMap;
use rmcp::{
ErrorData as McpError, Peer, RoleServer, ServerHandler, ServiceExt,
handler::server::wrapper::{Json, Parameters},
model::{
CallToolRequestParams, CallToolResult, CancelTaskParams, CancelTaskResult,
CreateTaskResult, GetPromptRequestParams, GetPromptResult, GetTaskInfoParams,
GetTaskPayloadResult, GetTaskResult, GetTaskResultParams, ListPromptsResult,
ListTasksResult, Meta, PaginatedRequestParams, ProgressNotificationParam, PromptMessage,
PromptMessageRole, ServerCapabilities, ServerInfo, Task, TaskStatus,
},
prompt, prompt_handler, prompt_router,
service::RequestContext,
tool, tool_handler, tool_router,
};
use schemars::JsonSchema;
use serde::Deserialize;
use std::sync::Arc;
use tokio::task::JoinHandle;
fn default_true() -> bool {
true
}
fn default_false() -> bool {
false
}
fn utc_now_iso8601() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
fn mask_prefix_for_log(value: &str) -> String {
let char_count = value.chars().count();
if char_count <= 1 {
return "***".to_string();
}
let visible_len = 3_usize.min(char_count - 1);
let visible: String = value.chars().take(visible_len).collect();
format!("{visible}***")
}
fn account_log_hint(account: &str) -> String {
let account = account.trim();
if account.is_empty() {
return "<empty>".to_string();
}
if let Some((local, domain)) = account.rsplit_once('@')
&& !local.is_empty()
&& !domain.is_empty()
{
return format!("{}@{}", mask_prefix_for_log(local), domain);
}
mask_prefix_for_log(account)
}
fn make_progress_fn(meta: &Meta, peer: &Peer<RoleServer>) -> Option<crate::ProgressFn> {
let token = meta.get_progress_token()?.clone();
let peer = peer.clone();
Some(Arc::new(move |completed: u64, total: u64| {
let peer = peer.clone();
let token = token.clone();
tokio::spawn(async move {
let _ = peer
.notify_progress(
ProgressNotificationParam::new(token, completed as f64)
.with_total(total as f64),
)
.await;
});
}))
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "No arguments.")]
struct ListAccountsArgs {}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for listing mailboxes.")]
struct ListMailboxesArgs {
#[schemars(
description = "Optional account name. If omitted, list mailboxes across all accounts."
)]
account: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for checking IMAP connection status.")]
struct CheckConnectionArgs {
#[schemars(description = "Account name to check connectivity for.")]
account: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for listing IMAP server capabilities.")]
struct ListCapabilitiesArgs {
#[schemars(description = "Account name to query capabilities for.")]
account: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for fetching a paginated chunk of messages.")]
struct GetMessagesArgs {
#[schemars(description = "Mailbox name. Defaults to INBOX when omitted.")]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "Zero-based row offset. Defaults to 0.")]
offset: Option<u64>,
#[schemars(description = "Page size. Defaults to 25 and is clamped to 1..50.")]
limit: Option<u64>,
#[serde(default = "default_false")]
#[schemars(
description = "If true, include normalized markdown content (trimmed for context window safety)."
)]
include_content: bool,
#[serde(default = "default_false")]
#[schemars(
description = "If true, include the full raw headers map. Off by default — structured fields (subject, sender, to, cc, date, message_id, etc.) are always returned."
)]
include_headers: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for mailbox message search with optional filters.")]
struct SearchMessagesArgs {
#[schemars(description = "Mailbox name. Defaults to INBOX when omitted.")]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "General full-text search across message fields (IMAP TEXT search).")]
query: Option<String>,
#[schemars(description = "Filter by sender (IMAP FROM search).")]
sender_contains: Option<String>,
#[schemars(description = "Filter by subject (IMAP SUBJECT search).")]
subject_contains: Option<String>,
#[schemars(description = "Filter by recipient (IMAP TO search).")]
to_contains: Option<String>,
#[schemars(description = "Header key for header-based search.")]
header_key: Option<String>,
#[schemars(description = "Header value filter (used with header_key).")]
header_value_contains: Option<String>,
#[schemars(description = "Filter by flagged status.")]
flagged: Option<bool>,
#[schemars(description = "Filter by read/seen status.")]
read: Option<bool>,
#[serde(default = "default_false")]
#[schemars(description = "Include deleted messages. Defaults to false.")]
deleted: bool,
#[schemars(description = "Zero-based row offset. Defaults to 0.")]
offset: Option<u64>,
#[schemars(description = "Page size. Defaults to 25 and is clamped to 1..50.")]
limit: Option<u64>,
#[serde(default = "default_false")]
#[schemars(
description = "If true, include normalized markdown content (trimmed for context window safety)."
)]
include_content: bool,
#[serde(default = "default_false")]
#[schemars(
description = "If true, include the full raw headers map. Off by default — structured fields (subject, sender, to, cc, date, message_id, etc.) are always returned."
)]
include_headers: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for listing flags in use.")]
struct ListFlagsArgs {
#[schemars(description = "Mailbox to scan. Omit to scan all mailboxes in the account.")]
mailbox: Option<String>,
#[schemars(description = "Account name (required).")]
account: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for deleting one or more messages.")]
struct DeleteMessagesArgs {
#[schemars(description = "Mailbox name. Defaults to INBOX when omitted.")]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "Array of IMAP UIDs to delete. One or more UIDs, up to 500.")]
uids: Vec<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(
description = "Arguments for deleting all messages from a specific sender. The sender string is matched as a substring against the full From header (covers both display name and email address)."
)]
struct DeleteBySenderArgs {
#[schemars(description = "Mailbox containing the target UID. Defaults to INBOX when omitted.")]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(
description = "UID of a message from the sender to delete. The exact sender (email + display name) is extracted from this message and used to find all other messages from the same sender."
)]
uid: u32,
#[serde(default = "default_false")]
#[schemars(
description = "When true, search and delete across ALL mailboxes in the account (not just the source mailbox). Defaults to false."
)]
all_mailboxes: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for finding messages with attachments.")]
struct FindAttachmentsArgs {
#[schemars(description = "Mailbox name. Omit to scan all mailboxes in the account.")]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "Number of UIDs to skip. Defaults to 0.")]
offset: Option<u64>,
#[schemars(description = "Max UIDs to return. Defaults to 25, max 100.")]
limit: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for creating a draft message.")]
struct CreateDraftArgs {
#[schemars(
description = "Account name (required). Draft is saved to this account's Drafts folder."
)]
account: String,
#[serde(default)]
#[schemars(description = "Draft subject line.")]
subject: String,
#[serde(default)]
#[schemars(description = "Draft body content.")]
body: String,
#[serde(default)]
#[schemars(description = "To recipient email addresses.")]
to: Vec<String>,
#[serde(default)]
#[schemars(description = "Cc recipient email addresses.")]
cc: Vec<String>,
#[serde(default)]
#[schemars(description = "Bcc recipient email addresses.")]
bcc: Vec<String>,
#[serde(default)]
#[schemars(
description = "Attachments to include. Each entry requires a local filesystem 'path'. 'filename' and 'contentType' are optional and will be inferred when omitted."
)]
attachments: Vec<DraftAttachmentArg>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Attachment to attach to a draft.")]
struct DraftAttachmentArg {
#[schemars(description = "Local filesystem path to the file to attach (required).")]
path: String,
#[serde(default)]
#[schemars(
description = "Override filename to show in the email. Defaults to the file's basename."
)]
filename: Option<String>,
#[serde(default)]
#[schemars(
description = "MIME content type (e.g. 'application/pdf'). Inferred from extension when omitted."
)]
content_type: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for moving a message between mailboxes.")]
struct MoveMessageArgs {
#[schemars(description = "Source mailbox name.")]
mailbox: String,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "IMAP UID of the message to move.")]
uid: u32,
#[schemars(description = "Destination mailbox name.")]
destination: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for unsubscribe + optional list cleanup.")]
struct UnsubscribeMessageArgs {
#[schemars(
description = "Mailbox containing the target message. Defaults to INBOX. When deleting matching messages, all mailboxes are searched regardless of this value."
)]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "IMAP UID of the message.")]
uid: u32,
#[serde(default = "default_true")]
#[schemars(
description = "If true, bulk-delete matching messages. For List-Unsubscribe messages: deletes all from the exact sender with a List-Unsubscribe header. For List-Id-only messages: deletes all with the same List-Id."
)]
delete_matching: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for ranking senders by message count.")]
struct RankSendersArgs {
#[schemars(description = "Mailbox name. When omitted, scans ALL mailboxes in the account.")]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(
description = "Maximum number of senders to return. If omitted, returns all senders."
)]
limit: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for ranking mailing-list senders by message count.")]
struct RankUnsubscribeArgs {
#[schemars(description = "Mailbox name. When omitted, scans ALL mailboxes in the account.")]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "Maximum number of lists to return. If omitted, returns all lists.")]
limit: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for ranking mailing lists by List-Id header.")]
struct RankListIdArgs {
#[schemars(description = "Mailbox name. When omitted, scans ALL mailboxes in the account.")]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "Maximum number of lists to return. If omitted, returns all lists.")]
limit: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for deleting all messages with a specific List-Id.")]
struct DeleteListIdArgs {
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "The List-Id header value to match (from rank_list_id).")]
list_id: String,
#[schemars(description = "Mailbox to search. Omit to search all mailboxes.")]
mailbox: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for creating a new mailbox on the server.")]
struct CreateMailboxArgs {
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(
description = "Mailbox name to create. Use delimiter (usually '/') for nested mailboxes, e.g. 'Archive/2024'."
)]
mailbox_name: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(description = "Arguments for downloading message attachments to disk.")]
struct DownloadAttachmentsArgs {
#[schemars(description = "Mailbox name. Defaults to INBOX when omitted.")]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "IMAP UID of the message.")]
uid: u32,
#[schemars(description = "Directory to save attachments to. Defaults to current directory.")]
output_dir: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(
description = "Arguments for adding flags and/or setting an Apple Mail color on a message."
)]
struct AddFlagsArgs {
#[schemars(description = "Mailbox name. Defaults to INBOX when omitted.")]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "IMAP UID of the message.")]
uid: u32,
#[serde(default)]
#[schemars(
description = "Flags to add. System flags use backslash prefix (e.g. \"\\\\Seen\", \"\\\\Flagged\"). Custom keywords are plain strings. Cannot include \\\\Deleted or \\\\Recent."
)]
flags: Vec<String>,
#[schemars(
description = "Apple Mail color to set (case-insensitive): red, orange, yellow, green, blue, purple, gray. Sets \\\\Flagged + $MailFlagBit keywords. Replaces any existing color."
)]
color: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(
description = "Arguments for removing flags and/or clearing Apple Mail color from a message."
)]
struct RemoveFlagsArgs {
#[schemars(description = "Mailbox name. Defaults to INBOX when omitted.")]
mailbox: Option<String>,
#[schemars(
description = "Account name (required). Use list_accounts to discover valid names."
)]
account: String,
#[schemars(description = "IMAP UID of the message.")]
uid: u32,
#[serde(default)]
#[schemars(
description = "Flags to remove. System flags use backslash prefix (e.g. \"\\\\Seen\"). Cannot include \\\\Deleted or \\\\Recent."
)]
flags: Vec<String>,
#[serde(default = "default_false")]
#[schemars(
description = "If true, removes the Apple Mail color flag (\\\\Flagged + all $MailFlagBit keywords). Defaults to false."
)]
color: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct InboxSummaryArgs {
#[schemars(description = "Account name to summarize.")]
account: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct CleanupSenderArgs {
#[schemars(description = "Account name.")]
account: String,
#[schemars(description = "Sender email address or name to clean up.")]
sender: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct FindAttachmentsPromptArgs {
#[schemars(description = "Account name.")]
account: String,
#[schemars(description = "Mailbox to search. Defaults to INBOX.")]
mailbox: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct ComposeEmailArgs {
#[schemars(description = "Account name to send from.")]
account: String,
#[schemars(description = "Recipient email address.")]
to: Option<String>,
#[schemars(description = "Email subject line.")]
subject: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct UnsubscribeCleanupArgs {
#[schemars(description = "Account name.")]
account: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct ListIdCleanupArgs {
#[schemars(description = "Account name.")]
account: String,
}
const DESTRUCTIVE_TOOLS: &[&str] = &[
"delete_messages",
"delete_by_sender",
"delete_list_id",
"unsubscribe_message",
];
fn extract_account(args: &Option<serde_json::Map<String, serde_json::Value>>) -> Option<String> {
args.as_ref()?.get("account")?.as_str().map(String::from)
}
fn guess_content_type(filename: &str) -> String {
let ext = std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"pdf" => "application/pdf",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"heic" | "heif" => "image/heic",
"svg" => "image/svg+xml",
"txt" => "text/plain",
"md" | "markdown" => "text/markdown",
"csv" => "text/csv",
"tsv" => "text/tab-separated-values",
"json" => "application/json",
"xml" => "application/xml",
"html" | "htm" => "text/html",
"zip" => "application/zip",
"gz" | "tgz" => "application/gzip",
"tar" => "application/x-tar",
"7z" => "application/x-7z-compressed",
"rar" => "application/vnd.rar",
"doc" => "application/msword",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls" => "application/vnd.ms-excel",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt" => "application/vnd.ms-powerpoint",
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"rtf" => "application/rtf",
"mp3" => "audio/mpeg",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"wav" => "audio/wav",
"ics" => "text/calendar",
"vcf" => "text/vcard",
"eml" => "message/rfc822",
_ => "application/octet-stream",
}
.to_string()
}
struct ManagedTask {
meta: Task,
result: Arc<parking_lot::Mutex<Option<Result<CallToolResult, McpError>>>>,
handle: JoinHandle<()>,
}
struct TaskManager {
tasks: HashMap<String, ManagedTask>,
destructive_locks: HashMap<String, Arc<tokio::sync::Mutex<()>>>,
}
impl TaskManager {
fn new() -> Self {
Self {
tasks: HashMap::new(),
destructive_locks: HashMap::new(),
}
}
fn destructive_lock(&mut self, account: &str) -> Arc<tokio::sync::Mutex<()>> {
self.destructive_locks
.entry(account.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
fn refresh_status(&mut self, task_id: &str) {
if let Some(managed) = self.tasks.get_mut(task_id) {
if managed.meta.status != TaskStatus::Working {
return;
}
if managed.handle.is_finished() {
let now = utc_now_iso8601();
let status = match managed.result.try_lock() {
Some(guard) => match guard.as_ref() {
Some(Ok(_)) => TaskStatus::Completed,
Some(Err(_)) => TaskStatus::Failed,
None => {
TaskStatus::Failed
}
},
None => {
return;
}
};
managed.meta.status = status;
managed.meta.last_updated_at = now;
}
}
}
fn refresh_all(&mut self) {
let ids: Vec<String> = self.tasks.keys().cloned().collect();
for id in ids {
self.refresh_status(&id);
}
}
}
#[derive(Clone)]
pub struct AgentMailServer {
agentmail: Arc<crate::Agentmail>,
task_manager: Arc<parking_lot::Mutex<TaskManager>>,
}
#[tool_router]
impl AgentMailServer {
pub fn new(agentmail: crate::Agentmail) -> Self {
Self {
agentmail: Arc::new(agentmail),
task_manager: Arc::new(parking_lot::Mutex::new(TaskManager::new())),
}
}
#[tool(
name = "list_accounts",
description = "Return configured IMAP account names. Use this first to discover valid account selectors.",
annotations(read_only_hint = true)
)]
async fn list_accounts_tool(
&self,
Parameters(_args): Parameters<ListAccountsArgs>,
) -> Result<Json<ListAccountsResponse>, McpError> {
match self.agentmail.list_accounts().await {
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "list_mailboxes",
description = "List all mailboxes (folders) with message counts: total, unseen, and recent. Shows the full folder tree. Optionally filter to a single account.",
annotations(read_only_hint = true)
)]
async fn list_mailboxes_tool(
&self,
Parameters(args): Parameters<ListMailboxesArgs>,
) -> Result<Json<ListMailboxesResponse>, McpError> {
let account = args.account.filter(|s| !s.trim().is_empty());
match self.agentmail.list_mailboxes(account.as_deref()).await {
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "create_mailbox",
description = "Create a new mailbox (folder) on the IMAP server. Use delimiter (usually '/') for nested mailboxes.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true
)
)]
async fn create_mailbox_tool(
&self,
Parameters(args): Parameters<CreateMailboxArgs>,
) -> Result<Json<CreateMailboxResponse>, McpError> {
if args.mailbox_name.trim().is_empty() {
return Err(McpError::internal_error("mailbox_name is required", None));
}
match self
.agentmail
.create_mailbox(&args.account, &args.mailbox_name)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "check_connection",
description = "Test IMAP connectivity for an account. Connects, authenticates, and reports status.",
annotations(read_only_hint = true)
)]
async fn check_connection_tool(
&self,
Parameters(args): Parameters<CheckConnectionArgs>,
) -> Result<Json<ConnectionStatus>, McpError> {
match self.agentmail.check_connection(&args.account).await {
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "list_capabilities",
description = "List IMAP server capabilities for an account. Shows supported extensions like IDLE, MOVE, CONDSTORE, etc.",
annotations(read_only_hint = true)
)]
async fn list_capabilities_tool(
&self,
Parameters(args): Parameters<ListCapabilitiesArgs>,
) -> Result<Json<ListCapabilitiesResponse>, McpError> {
match self.agentmail.list_capabilities(&args.account).await {
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "get_messages",
description = "Fetch a paginated list of messages from a mailbox, newest-first. Returns metadata (subject, from, date, flags, UID) by default. Set include_content=true to also get the message body as markdown. Set include_headers=true for the full raw headers map. Defaults: mailbox=INBOX, offset=0, limit=25 (max 50).",
annotations(read_only_hint = true)
)]
async fn get_messages_tool(
&self,
Parameters(args): Parameters<GetMessagesArgs>,
) -> Result<Json<GetMessagesResponse>, McpError> {
let mailbox = args.mailbox.unwrap_or_else(|| "INBOX".to_string());
let offset = crate::clamp_usize(args.offset, 0, 0, 1_000_000);
let limit = crate::clamp_usize(args.limit, 25, 1, 50);
match self
.agentmail
.get_messages(
&mailbox,
&args.account,
offset,
limit,
args.include_content,
args.include_headers,
)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "search_messages",
description = "Search messages with filters: sender_contains, subject_contains, to_contains, query (full-text), read, flagged, and header key/value. Returns paginated results newest-first. Content excluded by default — set include_content=true to get message bodies. Set include_headers=true for the full raw headers map.",
annotations(read_only_hint = true)
)]
async fn search_messages_tool(
&self,
Parameters(args): Parameters<SearchMessagesArgs>,
) -> Result<Json<SearchMessagesResponse>, McpError> {
let mailbox = args.mailbox.unwrap_or_else(|| "INBOX".to_string());
let offset = crate::clamp_usize(args.offset, 0, 0, 1_000_000);
let limit = crate::clamp_usize(args.limit, 25, 1, 50);
let criteria = crate::SearchCriteria {
text: args.query,
from: args.sender_contains,
subject: args.subject_contains,
to: args.to_contains,
seen: args.read,
flagged: args.flagged,
deleted: Some(args.deleted),
header: match (args.header_key, args.header_value_contains) {
(Some(k), Some(v)) => Some((k, v)),
(Some(k), None) => Some((k, String::new())),
_ => None,
},
};
match self
.agentmail
.search_messages(
&mailbox,
&args.account,
&criteria,
offset,
limit,
args.include_content,
args.include_headers,
)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "list_flags",
description = "List all IMAP flags in use with counts per flag (e.g. \\Seen: 1234, \\Flagged: 56). Omit mailbox to scan the entire account across all mailboxes. Resolves Apple Mail $MailFlagBit color flags to color names (red, orange, yellow, green, blue, purple, gray).",
annotations(read_only_hint = true),
execution(task_support = "optional")
)]
async fn list_flags_tool(
&self,
meta: Meta,
client: Peer<RoleServer>,
Parameters(args): Parameters<ListFlagsArgs>,
) -> Result<Json<ListFlagsResponse>, McpError> {
let progress = make_progress_fn(&meta, &client);
match self
.agentmail
.list_flags(args.mailbox.as_deref(), &args.account, progress.as_ref())
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "delete_messages",
description = "Delete one or more messages by UID. Moves to Trash if configured, otherwise flags \\Deleted and expunges. Supports up to 500 UIDs per call.",
annotations(destructive_hint = true, idempotent_hint = true),
execution(task_support = "optional")
)]
async fn delete_messages_tool(
&self,
meta: Meta,
client: Peer<RoleServer>,
Parameters(args): Parameters<DeleteMessagesArgs>,
) -> Result<Json<DeleteMessagesResponse>, McpError> {
let mailbox = args.mailbox.unwrap_or_else(|| "INBOX".to_string());
if args.uids.is_empty() {
return Err(McpError::internal_error(
"uids must contain at least one UID",
None,
));
}
if args.uids.len() > 500 {
return Err(McpError::internal_error(
"uids supports up to 500 UIDs per call",
None,
));
}
let progress = make_progress_fn(&meta, &client);
match self
.agentmail
.delete_messages(&mailbox, &args.account, &args.uids, progress.as_ref())
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "delete_by_sender",
description = "Delete all messages from an exact sender. Takes a UID to identify the sender — extracts the full From header (display name + email) and deletes every message with an identical sender. Set allMailboxes=true to search and delete across the entire account. Ideal for bulk cleanup after rank_senders. For mailing list cleanup, use unsubscribe_message instead — it attempts one-click unsubscribe and only deletes bulk mail.",
annotations(destructive_hint = true),
execution(task_support = "optional")
)]
async fn delete_by_sender_tool(
&self,
meta: Meta,
client: Peer<RoleServer>,
Parameters(args): Parameters<DeleteBySenderArgs>,
) -> Result<Json<DeleteBySenderResponse>, McpError> {
let mailbox = args.mailbox.unwrap_or_else(|| "INBOX".to_string());
let progress = make_progress_fn(&meta, &client);
match self
.agentmail
.delete_by_sender(
&mailbox,
&args.account,
args.uid,
args.all_mailboxes,
progress.as_ref(),
)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "find_attachments",
description = "Scan for messages with attachments (multipart/mixed or multipart/related). Returns paginated UIDs (newest-first) and total count. Omit mailbox to scan the entire account with a per-mailbox breakdown. Use download_attachments with a specific UID to save files to disk.",
annotations(read_only_hint = true),
execution(task_support = "optional")
)]
async fn find_attachments_tool(
&self,
meta: Meta,
client: Peer<RoleServer>,
Parameters(args): Parameters<FindAttachmentsArgs>,
) -> Result<Json<FindAttachmentsResponse>, McpError> {
let offset = crate::clamp_usize(args.offset, 0, 0, 100_000);
let limit = crate::clamp_usize(args.limit, 25, 1, 100);
let progress = make_progress_fn(&meta, &client);
match self
.agentmail
.find_attachments(
args.mailbox.as_deref(),
&args.account,
offset,
limit,
progress.as_ref(),
)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "download_attachments",
description = "Download all attachments from a message to disk. Files are saved as {uid}_{originalname}. Returns file paths, content types, and sizes.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true
),
execution(task_support = "optional")
)]
async fn download_attachments_tool(
&self,
Parameters(args): Parameters<DownloadAttachmentsArgs>,
) -> Result<Json<DownloadAttachmentsResponse>, McpError> {
let mailbox = args.mailbox.unwrap_or_else(|| "INBOX".to_string());
let output_dir = args
.output_dir
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| ".".into()));
match self
.agentmail
.download_attachments(&mailbox, &args.account, args.uid, &output_dir)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "create_draft",
description = "Create and save a draft email. Composes an RFC822 message and appends it to the account's Drafts folder (creating the Drafts mailbox if necessary). Requires at least one recipient (to, cc, or bcc). Subject and body are optional. Supports optional attachments via local file paths.",
annotations(read_only_hint = false, destructive_hint = false)
)]
async fn create_draft_tool(
&self,
Parameters(args): Parameters<CreateDraftArgs>,
) -> Result<Json<CreateDraftResponse>, McpError> {
if args.to.is_empty() && args.cc.is_empty() && args.bcc.is_empty() {
return Err(McpError::internal_error(
"At least one recipient (to, cc, or bcc) is required",
None,
));
}
let mut loaded: Vec<DraftAttachment> = Vec::with_capacity(args.attachments.len());
for (i, a) in args.attachments.iter().enumerate() {
let data = match tokio::fs::read(&a.path).await {
Ok(d) => d,
Err(e) => {
return Err(McpError::internal_error(
format!(
"Failed to read attachment #{} at '{}': {}",
i + 1,
a.path,
e
),
None,
));
}
};
let filename = a
.filename
.clone()
.or_else(|| {
std::path::Path::new(&a.path)
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| format!("attachment-{}", i + 1));
let content_type = a
.content_type
.clone()
.unwrap_or_else(|| guess_content_type(&filename));
loaded.push(DraftAttachment {
filename,
content_type,
data,
});
}
match self
.agentmail
.create_draft(
&args.account,
args.subject.trim(),
&args.body,
&args.to,
&args.cc,
&args.bcc,
&loaded,
)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "move_message",
description = "Move a single message from one mailbox to another by UID. Uses IMAP MOVE command. Requires source mailbox, destination mailbox, and the message UID.",
annotations(read_only_hint = false, destructive_hint = false)
)]
async fn move_message_tool(
&self,
Parameters(args): Parameters<MoveMessageArgs>,
) -> Result<Json<MoveMessageResponse>, McpError> {
if args.mailbox.trim().is_empty() {
return Err(McpError::internal_error("mailbox is required", None));
}
if args.destination.trim().is_empty() {
return Err(McpError::internal_error("destination is required", None));
}
match self
.agentmail
.move_message(&args.mailbox, &args.account, args.uid, &args.destination)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "unsubscribe_message",
description = "Unsubscribe from a mailing list and delete matching messages across ALL mailboxes. Requires the message to have a List-Unsubscribe header. Attempts RFC 8058 one-click unsubscribe POST (best-effort — if it fails, messages are still deleted). When delete_matching is true, searches every mailbox for messages from the exact sender that have a List-Unsubscribe-Post header and deletes them. This ensures only bulk/marketing mail is removed, not legitimate messages from the same sender.",
annotations(destructive_hint = true, open_world_hint = true)
)]
async fn unsubscribe_message_tool(
&self,
meta: Meta,
client: Peer<RoleServer>,
Parameters(args): Parameters<UnsubscribeMessageArgs>,
) -> Result<Json<UnsubscribeResponse>, McpError> {
let mailbox = args.mailbox.unwrap_or_else(|| "INBOX".to_string());
let progress = make_progress_fn(&meta, &client);
match self
.agentmail
.unsubscribe_message(
&mailbox,
&args.account,
args.uid,
args.delete_matching,
progress.as_ref(),
)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "rank_senders",
description = "Rank all senders by message count. Omit mailbox to scan the entire account across all mailboxes. Groups by (email, display name) — 'Find My <noreply@apple.com>' and 'iCloud <noreply@apple.com>' are separate entries. Sorted by message count descending. Efficient: fetches only FROM+DATE headers using BODY.PEEK.",
annotations(read_only_hint = true),
execution(task_support = "optional")
)]
async fn rank_senders_tool(
&self,
meta: Meta,
client: Peer<RoleServer>,
Parameters(args): Parameters<RankSendersArgs>,
) -> Result<Json<RankSendersResponse>, McpError> {
let limit = args.limit.map(|v| v as usize);
let progress = make_progress_fn(&meta, &client);
match self
.agentmail
.group_by_sender(
args.mailbox.as_deref(),
&args.account,
limit,
progress.as_ref(),
)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "rank_unsubscribe",
description = "Rank bulk-mail senders by message count. Omit mailbox to scan the entire account. Includes messages with either List-Unsubscribe or List-Unsubscribe-Post. Grouped by sender (From), sorted by one-click support first then by count. To clean up a sender, pass the sampleUid and sampleMailbox to unsubscribe_message (not delete_by_sender). Returns count, unsubscribe URL, one-click flag, sample UID + mailbox.",
annotations(read_only_hint = true),
execution(task_support = "optional")
)]
async fn rank_unsubscribe_tool(
&self,
meta: Meta,
client: Peer<RoleServer>,
Parameters(args): Parameters<RankUnsubscribeArgs>,
) -> Result<Json<RankUnsubscribeResponse>, McpError> {
let limit = args.limit.map(|v| v as usize);
let progress = make_progress_fn(&meta, &client);
match self
.agentmail
.group_by_list(
args.mailbox.as_deref(),
&args.account,
limit,
progress.as_ref(),
)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "rank_list_id",
description = "Rank mailing lists by List-Id header (RFC 2919). Groups all messages from the same mailing list regardless of sender address — useful for lists like GitHub notifications where multiple senders share one List-Id. Omit mailbox to scan the entire account. Use delete_list_id to remove all messages from a list.",
annotations(read_only_hint = true),
execution(task_support = "optional")
)]
async fn rank_list_id_tool(
&self,
meta: Meta,
client: Peer<RoleServer>,
Parameters(args): Parameters<RankListIdArgs>,
) -> Result<Json<RankListIdResponse>, McpError> {
let limit = args.limit.map(|v| v as usize);
let progress = make_progress_fn(&meta, &client);
match self
.agentmail
.group_by_list_id(
args.mailbox.as_deref(),
&args.account,
limit,
progress.as_ref(),
)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "delete_list_id",
description = "Delete all messages with a specific List-Id across all mailboxes. Identifies the list by its List-Id header value (from rank_list_id). Deletes ALL messages from that mailing list regardless of sender address. Omit mailbox to search the entire account.",
annotations(destructive_hint = true),
execution(task_support = "optional")
)]
async fn delete_list_id_tool(
&self,
meta: Meta,
client: Peer<RoleServer>,
Parameters(args): Parameters<DeleteListIdArgs>,
) -> Result<Json<DeleteListIdResponse>, McpError> {
let progress = make_progress_fn(&meta, &client);
match self
.agentmail
.delete_list_id(
args.mailbox.as_deref(),
&args.account,
&args.list_id,
progress.as_ref(),
)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "add_flags",
description = "Add flags and/or set an Apple Mail color on a message. Flags use union semantics — existing flags are preserved. Use color for Apple Mail colored flags (red, orange, yellow, green, blue, purple, gray). Cannot set \\Deleted (use delete_messages) or \\Recent (read-only).",
annotations(read_only_hint = false, destructive_hint = false)
)]
async fn add_flags_tool(
&self,
Parameters(args): Parameters<AddFlagsArgs>,
) -> Result<Json<UpdateFlagsResponse>, McpError> {
let mailbox = args.mailbox.unwrap_or_else(|| "INBOX".to_string());
if args.flags.is_empty() && args.color.is_none() {
return Err(McpError::internal_error(
"At least one flag or a color is required",
None,
));
}
for flag in &args.flags {
let lower = flag.to_lowercase();
if lower == "\\deleted" {
return Err(McpError::internal_error(
"Cannot set \\Deleted via add_flags — use delete_messages instead",
None,
));
}
if lower == "\\recent" {
return Err(McpError::internal_error(
"Cannot set \\Recent — it is a read-only server flag",
None,
));
}
}
match self
.agentmail
.add_flags(
&mailbox,
&args.account,
args.uid,
&args.flags,
args.color.as_deref(),
)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
#[tool(
name = "remove_flags",
description = "Remove flags and/or clear Apple Mail color from a message. Only specified flags are removed; all others preserved. Set color=true to remove the colored flag (\\Flagged + all $MailFlagBit keywords). Cannot remove \\Deleted (use delete_messages) or \\Recent (read-only).",
annotations(read_only_hint = false, destructive_hint = false)
)]
async fn remove_flags_tool(
&self,
Parameters(args): Parameters<RemoveFlagsArgs>,
) -> Result<Json<UpdateFlagsResponse>, McpError> {
let mailbox = args.mailbox.unwrap_or_else(|| "INBOX".to_string());
if args.flags.is_empty() && !args.color {
return Err(McpError::internal_error(
"At least one flag or color=true is required",
None,
));
}
for flag in &args.flags {
let lower = flag.to_lowercase();
if lower == "\\deleted" {
return Err(McpError::internal_error(
"Cannot remove \\Deleted via remove_flags — use delete_messages instead",
None,
));
}
if lower == "\\recent" {
return Err(McpError::internal_error(
"Cannot remove \\Recent — it is a read-only server flag",
None,
));
}
}
match self
.agentmail
.remove_flags(&mailbox, &args.account, args.uid, &args.flags, args.color)
.await
{
Ok(data) => Ok(Json(data)),
Err(e) => Err(McpError::internal_error(e.to_string(), None)),
}
}
}
#[prompt_router]
impl AgentMailServer {
#[prompt(
name = "inbox-summary",
description = "Get a comprehensive overview of your inbox: mailbox structure, unread counts, top senders by volume, and recent messages."
)]
async fn inbox_summary_prompt(
&self,
params: Parameters<InboxSummaryArgs>,
) -> Vec<PromptMessage> {
vec![PromptMessage::new_text(
PromptMessageRole::User,
format!(
"Give me a comprehensive overview of my email for account \"{}\". \
First, list all mailboxes to see the folder structure, message totals, and unread counts. \
Then use rank_senders with limit 20 (omit mailbox to scan the entire account) to show me the top senders by volume. \
Finally, show me the 10 most recent unread messages using search_messages with read=false.",
params.0.account
),
)]
}
#[prompt(
name = "cleanup-sender",
description = "Find and bulk-delete all emails from a specific sender. Shows a preview before deleting."
)]
async fn cleanup_sender_prompt(
&self,
params: Parameters<CleanupSenderArgs>,
) -> Vec<PromptMessage> {
vec![PromptMessage::new_text(
PromptMessageRole::User,
format!(
"Help me clean up all emails from \"{}\" in account \"{}\". \
First, search for messages from this sender in INBOX to see how many there are. \
Show me the 5 most recent ones with include_content=false so I can confirm. \
Then wait for my confirmation before bulk-deleting them all.",
params.0.sender, params.0.account
),
)]
}
#[prompt(
name = "find-attachments",
description = "Scan a mailbox for messages with attachments and list them for review or download."
)]
async fn find_attachments_prompt(
&self,
params: Parameters<FindAttachmentsPromptArgs>,
) -> Vec<PromptMessage> {
let mailbox = params.0.mailbox.as_deref().unwrap_or("INBOX");
vec![PromptMessage::new_text(
PromptMessageRole::User,
format!(
"Find all messages with attachments in mailbox \"{}\" for account \"{}\". \
Use find_attachments to get the UIDs. \
Show me the first 10 so I can see who sent them and the subjects. \
I may ask you to download specific attachments afterward.",
mailbox, params.0.account
),
)]
}
#[prompt(
name = "compose-email",
description = "Draft a new email message with guided composition."
)]
async fn compose_email_prompt(
&self,
params: Parameters<ComposeEmailArgs>,
) -> Vec<PromptMessage> {
let mut instructions = format!(
"Help me compose a new email from account \"{}\".",
params.0.account
);
if let Some(ref to) = params.0.to {
instructions.push_str(&format!(" The recipient is \"{}\".", to));
}
if let Some(ref subject) = params.0.subject {
instructions.push_str(&format!(" The subject is \"{}\".", subject));
}
instructions.push_str(
" Ask me what I want to say, help me write the body, then use create_draft \
(with optional attachments) to save it. Show me a preview of the draft before saving.",
);
vec![PromptMessage::new_text(
PromptMessageRole::User,
instructions,
)]
}
#[prompt(
name = "unsubscribe-cleanup",
description = "Identify high-volume mailing lists and unsubscribe + bulk-delete them."
)]
async fn unsubscribe_cleanup_prompt(
&self,
params: Parameters<UnsubscribeCleanupArgs>,
) -> Vec<PromptMessage> {
vec![PromptMessage::new_text(
PromptMessageRole::User,
format!(
"Help me clean up mailing list clutter in account \"{}\". \
Step 1: Use rank_unsubscribe (omit mailbox to scan the entire account) to get a ranked list \
of bulk-mail senders. Messages with either List-Unsubscribe or List-Unsubscribe-Post are \
included. Results are grouped by sender and sorted by one-click support first, then count. \
The unsubscribe URL comes from the newest message per sender. \
Step 2: Present me with the ranked list so I can pick which ones to clean up. \
Step 3: For each one I approve, call unsubscribe_message with the sample UID and mailbox, \
and delete_matching=true. Deletion matches by exact sender + either List-Unsubscribe \
or List-Unsubscribe-Post header to ensure only bulk mail is removed. The unsubscribe \
POST is best-effort — if it fails, the messages are still deleted across all mailboxes.",
params.0.account
),
)]
}
#[prompt(
name = "list-id-cleanup",
description = "Identify mailing lists by List-Id and bulk-delete entire lists."
)]
async fn list_id_cleanup_prompt(
&self,
params: Parameters<ListIdCleanupArgs>,
) -> Vec<PromptMessage> {
vec![PromptMessage::new_text(
PromptMessageRole::User,
format!(
"Help me clean up mailing lists in account \"{}\". \
Step 1: Use rank_list_id (omit mailbox to scan the entire account) to get a ranked list \
of mailing lists grouped by their List-Id header. This groups all messages from the same \
mailing list regardless of sender — useful for lists like GitHub notifications where \
multiple senders share one List-Id. \
Step 2: Present me with the ranked list so I can see which lists have the most messages. \
Show the list name, message count, and the unique senders for each. \
Step 3: For each list I approve, call delete_list_id with the list_id value to remove \
all messages from that mailing list across all mailboxes.",
params.0.account
),
)]
}
}
#[tool_handler]
#[prompt_handler]
impl ServerHandler for AgentMailServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_prompts()
.enable_tasks()
.build(),
)
.with_instructions(
"AgentMail is a full-featured IMAP email client. \
Start with list_accounts to discover configured accounts. \
Use list_mailboxes to see folder structure and message counts. \
Read messages with get_messages (paginated, newest-first) or search_messages (with filters). \
Use search_messages to find specific messages by sender, subject, or content. \
Manage email: delete_messages, delete_by_sender, delete_list_id, move_message, create_draft (supports attachments), create_mailbox, unsubscribe_message. \
rank_senders, rank_unsubscribe, rank_list_id, list_flags, and find_attachments accept an optional mailbox — omit it to scan the entire account. \
All-mailbox scans automatically skip Trash, Junk, Spam, and Drafts. \
Two cleanup workflows: (1) rank_senders → delete_by_sender for unwanted personal senders, (2) rank_unsubscribe → unsubscribe_message for mailing lists. \
Never use delete_by_sender for mailing list cleanup — it deletes ALL messages from a sender including non-bulk ones. \
rank_list_id groups by List-Id header (RFC 2919) — all messages from the same mailing list regardless of sender. Use delete_list_id to remove an entire list. \
rank_senders groups by (email, display name) — same email with different display names are separate entries. \
rank_unsubscribe returns sample UIDs + mailboxes that can be passed directly to unsubscribe_message. \
unsubscribe_message deletes by matching sender + either unsubscribe header when delete_matching=true; the unsubscribe POST is best-effort and never blocks deletion. \
list_flags resolves Apple Mail $MailFlagBit color flags to named colors (red, orange, yellow, green, blue, purple, gray). \
find_attachments detects multipart/mixed and multipart/related; download_attachments saves them to disk. \
All reads use BODY.PEEK to avoid marking messages as read.",
)
}
async fn enqueue_task(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CreateTaskResult, McpError> {
let task_id = uuid::Uuid::new_v4().to_string();
let now = utc_now_iso8601();
let is_destructive = DESTRUCTIVE_TOOLS.contains(&request.name.as_ref());
let destructive_lock = if is_destructive {
let account = extract_account(&request.arguments).ok_or_else(|| {
McpError::invalid_params(
"destructive tools require an 'account' argument for task queuing",
None,
)
})?;
Some(self.task_manager.lock().destructive_lock(&account))
} else {
None
};
let result_slot: Arc<parking_lot::Mutex<Option<Result<CallToolResult, McpError>>>> =
Arc::new(parking_lot::Mutex::new(None));
let server = self.clone();
let slot = Arc::clone(&result_slot);
let handle = tokio::spawn(async move {
let _guard = match destructive_lock {
Some(ref lock) => Some(lock.lock().await),
None => None,
};
let result = server.call_tool(request, context).await;
*slot.lock() = Some(result);
});
let task = Task::new(task_id.clone(), TaskStatus::Working, now.clone(), now)
.with_poll_interval(2000);
let managed = ManagedTask {
meta: task.clone(),
result: result_slot,
handle,
};
self.task_manager.lock().tasks.insert(task_id, managed);
Ok(CreateTaskResult::new(task))
}
async fn list_tasks(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListTasksResult, McpError> {
let mut mgr = self.task_manager.lock();
mgr.refresh_all();
let tasks: Vec<Task> = mgr.tasks.values().map(|m| m.meta.clone()).collect();
Ok(ListTasksResult::new(tasks))
}
async fn get_task_info(
&self,
request: GetTaskInfoParams,
_context: RequestContext<RoleServer>,
) -> Result<GetTaskResult, McpError> {
let mut mgr = self.task_manager.lock();
mgr.refresh_status(&request.task_id);
let managed = mgr.tasks.get(&request.task_id).ok_or_else(|| {
McpError::invalid_params(format!("unknown task: {}", request.task_id), None)
})?;
Ok(GetTaskResult {
meta: None,
task: managed.meta.clone(),
})
}
async fn get_task_result(
&self,
request: GetTaskResultParams,
_context: RequestContext<RoleServer>,
) -> Result<GetTaskPayloadResult, McpError> {
let mut mgr = self.task_manager.lock();
mgr.refresh_status(&request.task_id);
let managed = mgr.tasks.get(&request.task_id).ok_or_else(|| {
McpError::invalid_params(format!("unknown task: {}", request.task_id), None)
})?;
match managed.meta.status {
TaskStatus::Working => Err(McpError::invalid_params("task is still running", None)),
TaskStatus::Cancelled => Err(McpError::invalid_params("task was cancelled", None)),
_ => {
let result = managed.result.try_lock().and_then(|mut guard| guard.take());
match result {
Some(Ok(call_result)) => {
let value = serde_json::to_value(call_result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(GetTaskPayloadResult::new(value))
}
Some(Err(e)) => Err(e),
None => Err(McpError::internal_error(
"task result already consumed",
None,
)),
}
}
}
}
async fn cancel_task(
&self,
request: CancelTaskParams,
_context: RequestContext<RoleServer>,
) -> Result<CancelTaskResult, McpError> {
let mut mgr = self.task_manager.lock();
let managed = mgr.tasks.get_mut(&request.task_id).ok_or_else(|| {
McpError::invalid_params(format!("unknown task: {}", request.task_id), None)
})?;
if managed.meta.status == TaskStatus::Working {
managed.handle.abort();
managed.meta.status = TaskStatus::Cancelled;
managed.meta.last_updated_at = utc_now_iso8601();
}
Ok(CancelTaskResult {
meta: None,
task: managed.meta.clone(),
})
}
}
pub async fn serve_on<T>(
transport: T,
mk: crate::Agentmail,
) -> Result<(), Box<dyn std::error::Error>>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,
{
let server = AgentMailServer::new(mk);
let service = server.serve(transport).await.inspect_err(|e| {
eprintln!("agentmail: server error: {}", e);
})?;
service.waiting().await?;
Ok(())
}
pub async fn serve_stdio(mk: crate::Agentmail) -> Result<(), Box<dyn std::error::Error>> {
for account in mk.account_names() {
let account_hint = account_log_hint(&account);
match mk.check_connection(&account).await {
Ok(status) if status.connected => {
eprintln!("agentmail: {} connected", account_hint);
}
Ok(status) => {
eprintln!(
"agentmail: {} connection failed: {}",
account_hint,
status.error.as_deref().unwrap_or("unknown")
);
}
Err(e) => {
eprintln!("agentmail: {} credential error: {}", account_hint, e);
}
}
}
let server = AgentMailServer::new(mk);
let service = server
.serve(rmcp::transport::io::stdio())
.await
.inspect_err(|e| {
eprintln!("agentmail: server error: {}", e);
})?;
service.waiting().await?;
Ok(())
}