From 602bc223d9516e4b83d34c5d45d81f31dde0b289 Mon Sep 17 00:00:00 2001 From: rustmailer Date: Mon, 18 May 2026 07:56:23 +0800 Subject: [PATCH] feat: add poem MCP server as optional service endpoint --- crates/core/src/migrate/store.rs | 5 +- crates/core/src/settings/cli.rs | 8 + crates/core/src/settings/mod.rs | 2 + crates/server/Cargo.toml | 2 + crates/server/src/main.rs | 1 + crates/server/src/mcp/mod.rs | 43 +++ crates/server/src/mcp/tools.rs | 318 ++++++++++++++++++ crates/server/src/rest/mod.rs | 16 +- web/src/api/system/api.ts | 2 + .../settings/configurations/index.tsx | 14 +- web/src/locales/ar.json | 4 + web/src/locales/da.json | 4 + web/src/locales/de.json | 4 + web/src/locales/en.json | 4 + web/src/locales/es.json | 4 + web/src/locales/fi.json | 4 + web/src/locales/fr.json | 4 + web/src/locales/it.json | 4 + web/src/locales/jp.json | 4 + web/src/locales/ko.json | 4 + web/src/locales/nl.json | 4 + web/src/locales/no.json | 4 + web/src/locales/pl.json | 4 + web/src/locales/pt.json | 4 + web/src/locales/ru.json | 4 + web/src/locales/sv.json | 4 + web/src/locales/zh-tw.json | 4 + web/src/locales/zh.json | 4 + 28 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 crates/server/src/mcp/mod.rs create mode 100644 crates/server/src/mcp/tools.rs diff --git a/crates/core/src/migrate/store.rs b/crates/core/src/migrate/store.rs index a343e16..ec68759 100644 --- a/crates/core/src/migrate/store.rs +++ b/crates/core/src/migrate/store.rs @@ -13,10 +13,7 @@ use fjall::{ CompressionType, Database, Keyspace, KeyspaceCreateOptions, KvSeparationOptions, }; use mail_parser::MessageParser; -use tantivy::{ - indexer::{LogMergePolicy, NoMergePolicy}, - Index, IndexWriter, TantivyDocument, -}; +use tantivy::{indexer::NoMergePolicy, Index, IndexWriter, TantivyDocument}; use uuid::Uuid; use crate::{ diff --git a/crates/core/src/settings/cli.rs b/crates/core/src/settings/cli.rs index 3c1f091..799ae9b 100644 --- a/crates/core/src/settings/cli.rs +++ b/crates/core/src/settings/cli.rs @@ -250,6 +250,14 @@ pub struct Settings { )] pub bichon_enable_smtp: bool, + #[clap( + long, + env, + default_value = "false", + help = "Enable the MCP (Model Context Protocol) server for AI assistant integration" + )] + pub bichon_enable_mcp: bool, + #[clap( long, env, diff --git a/crates/core/src/settings/mod.rs b/crates/core/src/settings/mod.rs index 827e836..9f5cb90 100644 --- a/crates/core/src/settings/mod.rs +++ b/crates/core/src/settings/mod.rs @@ -54,6 +54,7 @@ pub struct SystemConfigurations { pub bichon_index_dir: Option, pub bichon_data_dir: Option, + pub bichon_enable_mcp: bool, pub bichon_enable_smtp: bool, pub bichon_smtp_port: u16, pub bichon_smtp_encryption: String, @@ -88,6 +89,7 @@ impl From<&Settings> for SystemConfigurations { bichon_base_url: s.bichon_base_url.clone(), bichon_index_dir: s.bichon_index_dir.clone(), bichon_data_dir: s.bichon_data_dir.clone(), + bichon_enable_mcp: s.bichon_enable_mcp, bichon_enable_smtp: s.bichon_enable_smtp, bichon_smtp_port: s.bichon_smtp_port, bichon_smtp_encryption: s.bichon_smtp_encryption.to_string(), diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 171a0ce..bf6d03b 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -30,6 +30,8 @@ tokio.workspace = true http.workspace = true urlencoding.workspace = true mimalloc.workspace = true +poem-mcpserver = { version = "0.3", features = ["streamable-http"] } +schemars = "1.0" [dev-dependencies] poem = { version = "3.1.12", features = ["test"] } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 922388f..e57409e 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -46,6 +46,7 @@ use crate::rest::start_http_server; pub mod common; pub mod error; +pub mod mcp; pub mod rest; #[global_allocator] diff --git a/crates/server/src/mcp/mod.rs b/crates/server/src/mcp/mod.rs new file mode 100644 index 0000000..c0fbd82 --- /dev/null +++ b/crates/server/src/mcp/mod.rs @@ -0,0 +1,43 @@ +// +// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use bichon_core::common::auth::ClientContext; +use poem::Request; +use poem_mcpserver::{streamable_http, McpServer}; + +mod tools; +use tools::BichonMcpTools; + +/// Create a Poem endpoint that handles MCP Streamable HTTP requests. +/// +/// The endpoint requires authentication via `ApiGuard` middleware (applied at +/// the route level in `rest/mod.rs`). The guard stores a `ClientContext` in +/// request extensions, which the server factory reads to configure per-session +/// tool authorization. +pub fn mcp_endpoint() -> impl poem::IntoEndpoint { + streamable_http::endpoint(|req: &Request| { + let ctx = req + .extensions() + .get::() + .expect("ApiGuard middleware must provide ClientContext in request extensions") + .clone(); + McpServer::new() + .with_server_info("bichon-mcp", env!("CARGO_PKG_VERSION")) + .tools(BichonMcpTools::new(ctx)) + }) +} diff --git a/crates/server/src/mcp/tools.rs b/crates/server/src/mcp/tools.rs new file mode 100644 index 0000000..4ed6d39 --- /dev/null +++ b/crates/server/src/mcp/tools.rs @@ -0,0 +1,318 @@ +// +// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::collections::HashSet; + +use bichon_core::{ + account::migration::AccountModel, + common::auth::ClientContext, + dashboard::DashboardStats, + message::search::{ + search_attachment_impl, search_messages_impl, AttachmentSearchFilter, AttachmentSearchRequest, + EmailSearchFilter, EmailSearchRequest, SortBy, + }, + message::{content::retrieve_email_content, list::get_thread_messages}, + users::permissions::Permission, +}; +use poem_mcpserver::{content::Text, Tools}; + +/// Tools for interacting with the Bichon email archive via MCP. +/// +/// Provides email search, content retrieval, thread viewing, attachment search, +/// dashboard statistics, and account management capabilities. +pub struct BichonMcpTools { + context: ClientContext, +} + +impl BichonMcpTools { + pub fn new(context: ClientContext) -> Self { + Self { context } + } + + /// Derive the set of account IDs that the current user is authorized to access. + /// Returns None if the user has global access (DATA_READ_ALL or ACCOUNT_MANAGE_ALL), + /// meaning "all accounts are accessible". + fn authorized_accounts(&self) -> Option> { + if self.context.has_permission(None, Permission::DATA_READ_ALL) + || self.context.has_permission(None, Permission::ACCOUNT_MANAGE_ALL) + { + None + } else { + Some( + self.context + .user + .account_access_map + .keys() + .copied() + .collect(), + ) + } + } + + /// Restrict the given account_ids to only those the user is authorized to access. + /// If the user has global access, the input is returned as-is. + fn restrict_account_ids( + &self, + account_ids: Option>, + ) -> Option> { + let global = self.authorized_accounts(); + match (global, account_ids) { + // User has global access → return input as HashSet (or None) + (None, Some(ids)) => Some(ids.into_iter().collect()), + (None, None) => None, + // User is scoped → intersect with authorized set + (Some(authorized), Some(ids)) => { + let filtered: HashSet = + ids.into_iter().filter(|id| authorized.contains(id)).collect(); + if filtered.is_empty() { + Some(filtered) + } else { + Some(filtered) + } + } + (Some(authorized), None) => Some(authorized), + } + } +} + +#[Tools] +impl BichonMcpTools { + /// Search archived emails using full-text and structured filters. + async fn search_emails( + &self, + /// Full-text search across all message fields (subject, body, from, to, etc.) + text: Option, + /// Filter by email subject line + subject: Option, + /// Filter by sender email address + from: Option, + /// Filter by recipient email address + to: Option, + /// Start date as Unix timestamp in milliseconds + since: Option, + /// End date as Unix timestamp in milliseconds + before: Option, + /// Filter by specific account IDs + account_ids: Option>, + /// Filter by specific mailbox/folder IDs + mailbox_ids: Option>, + /// Only return messages that have attachments + has_attachment: Option, + /// Filter by attachment filename + attachment_name: Option, + /// Filter by tags/labels + tags: Option>, + /// Page number (1-based, default 1) + page: Option, + /// Results per page (default 50, max 500) + page_size: Option, + ) -> Result, String> { + let authorized_ids = self.restrict_account_ids(account_ids); + + let filter = EmailSearchFilter { + text, + subject, + from, + to, + since, + before, + account_ids: authorized_ids.clone(), + mailbox_ids: mailbox_ids.map(|v| v.into_iter().collect()), + has_attachment, + attachment_name, + tags: tags.map(|v| v.into_iter().collect()), + ..Default::default() + }; + + let request = EmailSearchRequest { + filter, + page: page.unwrap_or(1), + page_size: page_size.unwrap_or(50).min(500), + sort_by: Some(SortBy::DATE), + desc: Some(true), + }; + + search_messages_impl(authorized_ids, request) + .map(|result| Text(serde_json::to_string_pretty(&result).unwrap_or_default())) + .map_err(|e| format!("Search failed: {:#}", e)) + } + + /// Retrieve the full content of a specific email including plain text, HTML body, + /// and attachment metadata (filenames, sizes, content hashes). + async fn get_email_content( + &self, + /// The ID of the email account + account_id: u64, + /// The envelope ID of the email message + envelope_id: String, + ) -> Result, String> { + self.context + .require_permission(Some(account_id), Permission::DATA_READ) + .map_err(|e| format!("Permission denied: {:#}", e))?; + + retrieve_email_content(account_id, envelope_id) + .map(|content| Text(serde_json::to_string_pretty(&content).unwrap_or_default())) + .map_err(|e| format!("Failed to retrieve email content: {:#}", e)) + } + + /// Retrieve all messages in a specific email thread/conversation. + async fn get_thread( + &self, + /// The ID of the email account + account_id: u64, + /// The thread ID (from the thread_id field of an envelope) + thread_id: String, + /// Page number (1-based, default 1) + page: Option, + /// Results per page (default 50, max 500) + page_size: Option, + ) -> Result, String> { + self.context + .require_permission(Some(account_id), Permission::DATA_READ) + .map_err(|e| format!("Permission denied: {:#}", e))?; + + get_thread_messages( + account_id, + &thread_id, + page.unwrap_or(1), + page_size.unwrap_or(50).min(500), + ) + .map(|result| Text(serde_json::to_string_pretty(&result).unwrap_or_default())) + .map_err(|e| format!("Failed to retrieve thread: {:#}", e)) + } + + /// Search email attachments using filters like filename, extension, content type, + /// category, sender, and date range. + async fn search_attachments( + &self, + /// Full-text search in attachment content and metadata + text: Option, + /// Filter by attachment filename + attachment_name: Option, + /// Filter by file extension (e.g., pdf, docx, jpg) + attachment_extension: Option, + /// Filter by category (document, image, spreadsheet, etc.) + attachment_category: Option, + /// Filter by MIME content type (e.g., application/pdf) + attachment_content_type: Option, + /// Filter by sender email address + from: Option, + /// Start date as Unix timestamp in milliseconds + since: Option, + /// End date as Unix timestamp in milliseconds + before: Option, + /// Filter by specific account IDs + account_ids: Option>, + /// Minimum attachment size in bytes + min_size: Option, + /// Maximum attachment size in bytes + max_size: Option, + /// Page number (1-based, default 1) + page: Option, + /// Results per page (default 50, max 500) + page_size: Option, + ) -> Result, String> { + let authorized_ids = self.restrict_account_ids(account_ids); + + let filter = AttachmentSearchFilter { + text, + attachment_name, + attachment_extension, + attachment_category, + attachment_content_type, + from, + since, + before, + account_ids: authorized_ids.clone(), + min_size, + max_size, + ..Default::default() + }; + + let request: AttachmentSearchRequest = + serde_json::from_value(serde_json::json!({ + "filter": filter, + "page": page.unwrap_or(1), + "page_size": page_size.unwrap_or(50).min(500), + "sort_by": "DATE", + "desc": true, + })) + .map_err(|e| format!("Invalid request: {e}"))?; + + search_attachment_impl(authorized_ids, request) + .map(|result| Text(serde_json::to_string_pretty(&result).unwrap_or_default())) + .map_err(|e| format!("Attachment search failed: {:#}", e)) + } + + /// Retrieve summary statistics about the email archive including total emails, + /// attachments, storage usage, top senders, and recent activity. + async fn get_dashboard_stats(&self) -> Result, String> { + if !self.context.has_permission(None, Permission::SYSTEM_ACCESS) { + return Err("Permission denied: requires system:access".into()); + } + + DashboardStats::get(self.context.clone()) + .await + .map(|stats| Text(serde_json::to_string_pretty(&stats).unwrap_or_default())) + .map_err(|e| format!("Failed to retrieve dashboard stats: {:#}", e)) + } + + /// List all email accounts the current user has access to. + /// Returns minimal account information (ID and email address). + async fn list_accounts(&self) -> Result, String> { + let accounts = AccountModel::minimal_list(false) + .map_err(|e| format!("Failed to list accounts: {:#}", e))?; + + let global_access = self + .context + .has_permission(None, Permission::ACCOUNT_MANAGE_ALL); + + let visible: Vec<_> = if global_access { + accounts + } else { + let authorized: HashSet = + self.context.user.account_access_map.keys().copied().collect(); + accounts + .into_iter() + .filter(|a| authorized.contains(&a.id)) + .collect() + }; + + Ok(Text( + serde_json::to_string_pretty(&visible).unwrap_or_default(), + )) + } + + /// Retrieve detailed configuration and status of a specific email account. + async fn get_account( + &self, + /// The ID of the email account + account_id: u64, + ) -> Result, String> { + self.context + .require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS) + .map_err(|e| format!("Permission denied: {:#}", e))?; + + let account = AccountModel::get(account_id) + .map_err(|e| format!("Account not found: {:#}", e))?; + + Ok(Text( + serde_json::to_string_pretty(&account).unwrap_or_default(), + )) + } +} diff --git a/crates/server/src/rest/mod.rs b/crates/server/src/rest/mod.rs index f9b0b32..def7362 100644 --- a/crates/server/src/rest/mod.rs +++ b/crates/server/src/rest/mod.rs @@ -30,6 +30,7 @@ use bichon_core::settings::cli::SETTINGS; use super::error::ApiErrorResponse; use crate::common::auth::ApiGuard; use crate::common::timeout::{Timeout, TIMEOUT_HEADER}; +use crate::mcp::mcp_endpoint; use api::create_openapi_service; use assets::FrontEndAssets; use bichon_core::raise_error; @@ -123,7 +124,20 @@ pub async fn start_http_server() -> BichonResult<()> { .nest("/api-docs/spec.yaml", spec_yaml) .nest("/oauth2/callback", get(oauth2_callback)) .nest("/api/status", get(get_status)) - .nest("/api/login", post(login)) + .nest("/api/login", post(login)); + + let app_logic = if SETTINGS.bichon_enable_mcp { + app_logic.nest_no_strip( + "/mcp", + mcp_endpoint() + .with(ApiGuard) + .with(Timeout), + ) + } else { + app_logic + }; + + let app_logic = app_logic .nest_no_strip("/api/v1", open_api_route) .nest_no_strip( "/assets", diff --git a/web/src/api/system/api.ts b/web/src/api/system/api.ts index 21645dc..4fe5868 100644 --- a/web/src/api/system/api.ts +++ b/web/src/api/system/api.ts @@ -138,6 +138,8 @@ export type ServerConfigurations = { bichon_smtp_auth_required: boolean bichon_smtp_tls_key_path?: string | null bichon_smtp_tls_cert_path?: string | null + + bichon_enable_mcp: boolean } export const get_dashboard_stats = async () => { diff --git a/web/src/features/settings/configurations/index.tsx b/web/src/features/settings/configurations/index.tsx index bc0ba82..adca0e5 100644 --- a/web/src/features/settings/configurations/index.tsx +++ b/web/src/features/settings/configurations/index.tsx @@ -29,7 +29,8 @@ import { Activity, InfoIcon, Mail, - Zap + Zap, + Bot } from "lucide-react" import { get_system_configurations } from "@/api/system/api" import { useQuery } from "@tanstack/react-query" @@ -180,6 +181,17 @@ export default function ServerConfigurationsPage() { } /> + + } + /> + +