mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
7 Commits
1.0.1
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
602bc223d9 | ||
|
|
6e984f376c | ||
|
|
7470125a23 | ||
|
|
469d254e2b | ||
|
|
7fde7ee19a | ||
|
|
d543508a23 | ||
|
|
f440069912 |
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -293,7 +293,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bichon-admin"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"console",
|
||||
@@ -312,7 +312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-cli"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
@@ -338,7 +338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-core"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-imap",
|
||||
"base64 0.22.1",
|
||||
@@ -396,7 +396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-server"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"bichon-smtp",
|
||||
@@ -421,7 +421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-smtp"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
|
||||
@@ -11,7 +11,7 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
edition = "2021"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -43,7 +43,7 @@ async fn run_interactive() {
|
||||
|
||||
let main_options = vec![
|
||||
"Reset Admin Password",
|
||||
"Migrate Legacy v0.3.7 Storage to v1.0.0",
|
||||
"Migrate Legacy v0.3.7 Storage to v1.0.x",
|
||||
"Exit",
|
||||
];
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use indicatif::{ProgressBar, ProgressStyle};
|
||||
pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
println!(
|
||||
"\n{}",
|
||||
style("MIGRATION: Bichon v0.3.7 Storage Architecture → v1.0.0")
|
||||
style("MIGRATION: Bichon v0.3.7 Storage Architecture → v1.0.x")
|
||||
.bold()
|
||||
.yellow()
|
||||
);
|
||||
@@ -20,7 +20,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
"{}",
|
||||
style(
|
||||
"This tool migrates data from the legacy v0.3.7 Tantivy-based storage \
|
||||
architecture to the new v1.0.0 \
|
||||
architecture to the new v1.0.x \
|
||||
separated index and Fjall-backed storage format."
|
||||
)
|
||||
.dim()
|
||||
@@ -32,7 +32,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
"Legacy v0.3.7 architecture:\n\
|
||||
• envelope metadata stored in Tantivy\n\
|
||||
• message data stored in Tantivy\n\n\
|
||||
New v1.0.0 architecture:\n\
|
||||
New v1.0.x architecture:\n\
|
||||
• mail indexes stored in Tantivy\n\
|
||||
• attachment indexes stored in Tantivy\n\
|
||||
• raw message data stored in Fjall\n\
|
||||
@@ -172,7 +172,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("✔").green(),
|
||||
style("Legacy v0.x Tantivy-based storage detected. Migration to v1.0 is required.")
|
||||
style("Legacy v0.3.7 Tantivy-based storage detected. Migration to v1.0 is required.")
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("✔").green(),
|
||||
style("No legacy v0.x storage layout was detected at the specified paths.").green()
|
||||
style("No legacy v0.3.7 storage layout was detected at the specified paths.").green()
|
||||
);
|
||||
|
||||
println!(
|
||||
|
||||
@@ -27,19 +27,17 @@ use bichon_core::{
|
||||
users::{permissions::Permission, view::UserView},
|
||||
};
|
||||
|
||||
use crate::BichonCliConfig as BichonCliConfig;
|
||||
|
||||
pub async fn verify_user_and_get_account(
|
||||
config: &BichonCliConfig,
|
||||
theme: &ColorfulTheme,
|
||||
only_nosync: bool,
|
||||
) -> MinimalAccount {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/api/v1/current-user", config.base_url);
|
||||
use crate::BichonCliConfig;
|
||||
|
||||
async fn fetch_json<T: serde::de::DeserializeOwned>(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
token: &str,
|
||||
label: &str,
|
||||
) -> T {
|
||||
let response = match client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.get(url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
@@ -59,19 +57,15 @@ pub async fn verify_user_and_get_account(
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "No error detail provided".to_string());
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||
|
||||
if !status.is_success() {
|
||||
eprintln!(
|
||||
"\n{} Server returned an error (Status: {})",
|
||||
style("✘ API Error:").red().bold(),
|
||||
style(status).yellow()
|
||||
);
|
||||
|
||||
if status == 401 {
|
||||
eprintln!(
|
||||
"{} Your API Token seems to be invalid or expired.",
|
||||
@@ -83,36 +77,66 @@ pub async fn verify_user_and_get_account(
|
||||
style("Context:").dim()
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!("{} {}", style("Response:").dim(), error_body);
|
||||
eprintln!("{} {}", style("Response:").dim(), body);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let user: UserView = response.json().await.expect("Failed to parse user data");
|
||||
println!("Welcome, {}!", style(&user.username).cyan());
|
||||
|
||||
let account_list_url = format!(
|
||||
"{}/api/v1/minimal-account-list?only_nosync={only_nosync}",
|
||||
config.base_url
|
||||
);
|
||||
let acc_response = client
|
||||
.get(&account_list_url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to fetch account list");
|
||||
|
||||
if !acc_response.status().is_success() {
|
||||
panic!(
|
||||
"Failed to retrieve accounts. Status: {}",
|
||||
acc_response.status()
|
||||
if body.is_empty() {
|
||||
eprintln!(
|
||||
"\n{} Server returned an empty response for [{}] (Status: {})",
|
||||
style("✘ Empty Response:").red().bold(),
|
||||
label,
|
||||
status
|
||||
);
|
||||
eprintln!(
|
||||
"{} This may be caused by a reverse proxy or middleware issue.",
|
||||
style("Tip:").cyan()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let accounts: Vec<MinimalAccount> = acc_response
|
||||
.json()
|
||||
.await
|
||||
.expect("Failed to parse minimal account list");
|
||||
match serde_json::from_str::<T>(&body) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"\n{} Failed to parse response for [{}]: {}",
|
||||
style("✘ Parse Error:").red().bold(),
|
||||
label,
|
||||
e
|
||||
);
|
||||
eprintln!("{} Raw body: {}", style("Debug:").dim(), body);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_user_and_get_account(
|
||||
config: &BichonCliConfig,
|
||||
theme: &ColorfulTheme,
|
||||
only_nosync: bool,
|
||||
) -> MinimalAccount {
|
||||
let client = Client::new();
|
||||
|
||||
let user: UserView = fetch_json(
|
||||
&client,
|
||||
&format!("{}/api/v1/current-user", config.base_url),
|
||||
&config.api_token,
|
||||
"current-user",
|
||||
)
|
||||
.await;
|
||||
|
||||
println!("Welcome, {}!", style(&user.username).cyan());
|
||||
|
||||
let accounts: Vec<MinimalAccount> = fetch_json(
|
||||
&client,
|
||||
&format!(
|
||||
"{}/api/v1/minimal-account-list?only_nosync={only_nosync}",
|
||||
config.base_url
|
||||
),
|
||||
&config.api_token,
|
||||
"minimal-account-list",
|
||||
)
|
||||
.await;
|
||||
|
||||
if accounts.is_empty() {
|
||||
println!(
|
||||
@@ -129,6 +153,7 @@ pub async fn verify_user_and_get_account(
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let required_permission = Permission::DATA_IMPORT_BATCH;
|
||||
let mut selectable_accounts = Vec::new();
|
||||
let mut options = Vec::new();
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct SystemConfigurations {
|
||||
pub bichon_index_dir: Option<String>,
|
||||
pub bichon_data_dir: Option<String>,
|
||||
|
||||
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(),
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -46,6 +46,7 @@ use crate::rest::start_http_server;
|
||||
|
||||
pub mod common;
|
||||
pub mod error;
|
||||
pub mod mcp;
|
||||
pub mod rest;
|
||||
|
||||
#[global_allocator]
|
||||
|
||||
43
crates/server/src/mcp/mod.rs
Normal file
43
crates/server/src/mcp/mod.rs
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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::<ClientContext>()
|
||||
.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))
|
||||
})
|
||||
}
|
||||
318
crates/server/src/mcp/tools.rs
Normal file
318
crates/server/src/mcp/tools.rs
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<HashSet<u64>> {
|
||||
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<Vec<u64>>,
|
||||
) -> Option<HashSet<u64>> {
|
||||
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<u64> =
|
||||
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<String>,
|
||||
/// Filter by email subject line
|
||||
subject: Option<String>,
|
||||
/// Filter by sender email address
|
||||
from: Option<String>,
|
||||
/// Filter by recipient email address
|
||||
to: Option<String>,
|
||||
/// Start date as Unix timestamp in milliseconds
|
||||
since: Option<i64>,
|
||||
/// End date as Unix timestamp in milliseconds
|
||||
before: Option<i64>,
|
||||
/// Filter by specific account IDs
|
||||
account_ids: Option<Vec<u64>>,
|
||||
/// Filter by specific mailbox/folder IDs
|
||||
mailbox_ids: Option<Vec<u64>>,
|
||||
/// Only return messages that have attachments
|
||||
has_attachment: Option<bool>,
|
||||
/// Filter by attachment filename
|
||||
attachment_name: Option<String>,
|
||||
/// Filter by tags/labels
|
||||
tags: Option<Vec<String>>,
|
||||
/// Page number (1-based, default 1)
|
||||
page: Option<u64>,
|
||||
/// Results per page (default 50, max 500)
|
||||
page_size: Option<u64>,
|
||||
) -> Result<Text<String>, 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<Text<String>, 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<u64>,
|
||||
/// Results per page (default 50, max 500)
|
||||
page_size: Option<u64>,
|
||||
) -> Result<Text<String>, 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<String>,
|
||||
/// Filter by attachment filename
|
||||
attachment_name: Option<String>,
|
||||
/// Filter by file extension (e.g., pdf, docx, jpg)
|
||||
attachment_extension: Option<String>,
|
||||
/// Filter by category (document, image, spreadsheet, etc.)
|
||||
attachment_category: Option<String>,
|
||||
/// Filter by MIME content type (e.g., application/pdf)
|
||||
attachment_content_type: Option<String>,
|
||||
/// Filter by sender email address
|
||||
from: Option<String>,
|
||||
/// Start date as Unix timestamp in milliseconds
|
||||
since: Option<i64>,
|
||||
/// End date as Unix timestamp in milliseconds
|
||||
before: Option<i64>,
|
||||
/// Filter by specific account IDs
|
||||
account_ids: Option<Vec<u64>>,
|
||||
/// Minimum attachment size in bytes
|
||||
min_size: Option<u64>,
|
||||
/// Maximum attachment size in bytes
|
||||
max_size: Option<u64>,
|
||||
/// Page number (1-based, default 1)
|
||||
page: Option<u64>,
|
||||
/// Results per page (default 50, max 500)
|
||||
page_size: Option<u64>,
|
||||
) -> Result<Text<String>, 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<Text<String>, 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<Text<String>, 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<u64> =
|
||||
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<Text<String>, 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
136
web/src/api/mailbox/mock-mailboxes.ts
Normal file
136
web/src/api/mailbox/mock-mailboxes.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// Temporary mock data for download-folders responsive testing
|
||||
import { MailboxData, MailboxListResponse } from './api';
|
||||
|
||||
let _id = 1;
|
||||
const m = (overrides: Partial<MailboxData>): MailboxData => ({
|
||||
account_id: 1,
|
||||
attributes: [],
|
||||
delimiter: '/',
|
||||
exists: Math.floor(Math.random() * 5000) + 100,
|
||||
id: _id++,
|
||||
name: '',
|
||||
uid_next: null,
|
||||
uid_validity: null,
|
||||
unseen: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const SHORT = [
|
||||
m({ name: 'INBOX' }),
|
||||
m({ name: 'INBOX/Drafts' }),
|
||||
m({ name: 'INBOX/Sent' }),
|
||||
m({ name: 'INBOX/Trash' }),
|
||||
m({ name: 'INBOX/Archive' }),
|
||||
m({ name: 'INBOX/Spam' }),
|
||||
m({ name: 'INBOX/Templates' }),
|
||||
];
|
||||
|
||||
const PROJECTS = [
|
||||
m({ name: 'INBOX/Projects' }),
|
||||
m({ name: 'INBOX/Projects/Q4 2025 Financial Reports and Annual Budget Planning Review' }),
|
||||
m({ name: 'INBOX/Projects/Q4 2025 Financial Reports and Annual Budget Planning Review/Drafts' }),
|
||||
m({
|
||||
name: 'INBOX/Projects/Q4 2025 Financial Reports and Annual Budget Planning Review/Drafts/Revision 3 - Updated Forecast Models and Department Sign-off Required',
|
||||
attributes: [{ attr: 'HasChildren', extension: null }],
|
||||
}),
|
||||
m({
|
||||
name: 'INBOX/Projects/Q4 2025 Financial Reports and Annual Budget Planning Review/Drafts/Revision 3 - Updated Forecast Models and Department Sign-off Required/Comments from CFO',
|
||||
}),
|
||||
m({
|
||||
name: 'INBOX/Projects/Q4 2025 Financial Reports and Annual Budget Planning Review/Drafts/Revision 3 - Updated Forecast Models and Department Sign-off Required/Attachments',
|
||||
}),
|
||||
m({ name: 'INBOX/Projects/Q4 2025 Financial Reports and Annual Budget Planning Review/Final' }),
|
||||
m({ name: 'INBOX/Projects/Q4 2025 Financial Reports and Annual Budget Planning Review/Final/Approved with Amendments' }),
|
||||
m({ name: 'INBOX/Projects/Q1 2026 Strategic Initiative - Cloud Infrastructure Migration Assessment and Vendor Selection' }),
|
||||
m({ name: 'INBOX/Projects/Q1 2026 Strategic Initiative - Cloud Infrastructure Migration Assessment and Vendor Selection/Kickoff' }),
|
||||
m({ name: 'INBOX/Projects/Q1 2026 Strategic Initiative - Cloud Infrastructure Migration Assessment and Vendor Selection/Kickoff/Meeting Minutes and Action Items' }),
|
||||
m({
|
||||
name: 'INBOX/Projects/Q1 2026 Strategic Initiative - Cloud Infrastructure Migration Assessment and Vendor Selection/Vendor Proposals',
|
||||
attributes: [{ attr: 'HasNoChildren', extension: null }],
|
||||
}),
|
||||
m({ name: 'INBOX/Projects/Q1 2026 Strategic Initiative - Cloud Infrastructure Migration Assessment and Vendor Selection/Vendor Proposals/AWS Proposal Package' }),
|
||||
m({ name: 'INBOX/Projects/Q1 2026 Strategic Initiative - Cloud Infrastructure Migration Assessment and Vendor Selection/Vendor Proposals/Azure Proposal Package' }),
|
||||
m({ name: 'INBOX/Projects/Q1 2026 Strategic Initiative - Cloud Infrastructure Migration Assessment and Vendor Selection/Vendor Proposals/GCP Proposal Package' }),
|
||||
m({ name: 'INBOX/Projects/Q1 2026 Strategic Initiative - Cloud Infrastructure Migration Assessment and Vendor Selection/Internal Review and Scoring Committee' }),
|
||||
m({ name: 'INBOX/Projects/HR Portal Redesign - Employee Self-Service Platform Modernization' }),
|
||||
m({ name: 'INBOX/Projects/HR Portal Redesign - Employee Self-Service Platform Modernization/Wireframes and Mockups' }),
|
||||
m({ name: 'INBOX/Projects/HR Portal Redesign - Employee Self-Service Platform Modernization/Wireframes and Mockups/Iteration 1' }),
|
||||
m({ name: 'INBOX/Projects/HR Portal Redesign - Employee Self-Service Platform Modernization/Wireframes and Mockups/Iteration 2' }),
|
||||
m({ name: 'INBOX/Projects/HR Portal Redesign - Employee Self-Service Platform Modernization/Usability Testing Results and Feedback Compilation' }),
|
||||
];
|
||||
|
||||
const CLIENTS = [
|
||||
m({ name: 'INBOX/Clients' }),
|
||||
m({
|
||||
name: 'INBOX/Clients/Acme Corporation - Enterprise Software Licensing and Support Agreement Renewal 2026',
|
||||
attributes: [{ attr: 'HasChildren', extension: null }],
|
||||
}),
|
||||
m({ name: 'INBOX/Clients/Acme Corporation - Enterprise Software Licensing and Support Agreement Renewal 2026/Contract Documents' }),
|
||||
m({ name: 'INBOX/Clients/Acme Corporation - Enterprise Software Licensing and Support Agreement Renewal 2026/Contract Documents/Redlined Versions' }),
|
||||
m({
|
||||
name: 'INBOX/Clients/Acme Corporation - Enterprise Software Licensing and Support Agreement Renewal 2026/Contract Documents/Redlined Versions/Legal Review Round 1',
|
||||
}),
|
||||
m({
|
||||
name: 'INBOX/Clients/Acme Corporation - Enterprise Software Licensing and Support Agreement Renewal 2026/Contract Documents/Redlined Versions/Legal Review Round 2 - Final',
|
||||
}),
|
||||
m({
|
||||
name: 'INBOX/Clients/Acme Corporation - Enterprise Software Licensing and Support Agreement Renewal 2026/Invoices and Payment Records',
|
||||
}),
|
||||
m({ name: 'INBOX/Clients/Acme Corporation - Enterprise Software Licensing and Support Agreement Renewal 2026/Support Tickets and Correspondence' }),
|
||||
m({
|
||||
name: 'INBOX/Clients/Globex Industries - Multi-Year Digital Transformation Consulting Engagement',
|
||||
attributes: [{ attr: 'HasChildren', extension: null }],
|
||||
}),
|
||||
m({ name: 'INBOX/Clients/Globex Industries - Multi-Year Digital Transformation Consulting Engagement/Phase 1 Discovery and Assessment' }),
|
||||
m({ name: 'INBOX/Clients/Globex Industries - Multi-Year Digital Transformation Consulting Engagement/Phase 1 Discovery and Assessment/Stakeholder Interviews' }),
|
||||
m({ name: 'INBOX/Clients/Globex Industries - Multi-Year Digital Transformation Consulting Engagement/Phase 1 Discovery and Assessment/Current State Architecture Documentation' }),
|
||||
m({ name: 'INBOX/Clients/Globex Industries - Multi-Year Digital Transformation Consulting Engagement/Phase 2 Implementation Roadmap' }),
|
||||
m({ name: 'INBOX/Clients/Globex Industries - Multi-Year Digital Transformation Consulting Engagement/Phase 2 Implementation Roadmap/Sprint Planning and Resource Allocation' }),
|
||||
m({ name: 'INBOX/Clients/Globex Industries - Multi-Year Digital Transformation Consulting Engagement/Phase 2 Implementation Roadmap/Risk Assessment and Mitigation Strategies' }),
|
||||
m({ name: 'INBOX/Clients/Initech Solutions - Cybersecurity Audit and Compliance Remediation Program 2026' }),
|
||||
m({ name: 'INBOX/Clients/Initech Solutions - Cybersecurity Audit and Compliance Remediation Program 2026/Penetration Testing Reports' }),
|
||||
m({ name: 'INBOX/Clients/Initech Solutions - Cybersecurity Audit and Compliance Remediation Program 2026/Penetration Testing Reports/External Network Assessment' }),
|
||||
m({ name: 'INBOX/Clients/Initech Solutions - Cybersecurity Audit and Compliance Remediation Program 2026/Penetration Testing Reports/Internal Network Assessment' }),
|
||||
m({ name: 'INBOX/Clients/Initech Solutions - Cybersecurity Audit and Compliance Remediation Program 2026/Penetration Testing Reports/Web Application Security Scan Results' }),
|
||||
m({ name: 'INBOX/Clients/Initech Solutions - Cybersecurity Audit and Compliance Remediation Program 2026/Compliance Gap Analysis and Remediation Tracking' }),
|
||||
m({ name: 'INBOX/Clients/Massive Dynamic - Research Collaboration on Advanced Machine Learning Applications in Healthcare Informatics' }),
|
||||
m({ name: 'INBOX/Clients/Massive Dynamic - Research Collaboration on Advanced Machine Learning Applications in Healthcare Informatics/Data Sharing Agreements and Ethics Board Approvals' }),
|
||||
m({ name: 'INBOX/Clients/Massive Dynamic - Research Collaboration on Advanced Machine Learning Applications in Healthcare Informatics/Literature Review and Prior Art Analysis' }),
|
||||
m({ name: 'INBOX/Clients/Massive Dynamic - Research Collaboration on Advanced Machine Learning Applications in Healthcare Informatics/Model Training Datasets and Validation Results' }),
|
||||
];
|
||||
|
||||
const NOTIFICATIONS = [
|
||||
m({ name: 'INBOX/Notifications' }),
|
||||
m({ name: 'INBOX/Notifications/GitHub Enterprise - Pull Request Reviews and CI/CD Pipeline Status Updates' }),
|
||||
m({ name: 'INBOX/Notifications/Jira Service Management - Incident Response Alerts and Escalation Notifications' }),
|
||||
m({ name: 'INBOX/Notifications/Confluence - Documentation Updates and Page Modification Summaries' }),
|
||||
m({ name: 'INBOX/Notifications/Slack Workspace - Channel Highlights and Direct Message Digest Compilation' }),
|
||||
m({ name: 'INBOX/Notifications/Datadog - Application Performance Monitoring Alerts and Anomaly Detection Reports' }),
|
||||
m({ name: 'INBOX/Notifications/PagerDuty - On-Call Rotation Schedule and Incident Acknowledgment Confirmations' }),
|
||||
m({ name: 'INBOX/Notifications/Microsoft 365 - Calendar Invitations and Meeting Room Booking Confirmations' }),
|
||||
];
|
||||
|
||||
const NEWSLETTERS = [
|
||||
m({ name: 'INBOX/Newsletters' }),
|
||||
m({ name: 'INBOX/Newsletters/Rust Weekly - Community Updates, Crate Highlights, and RFC Progress Tracking Digest' }),
|
||||
m({
|
||||
name: 'INBOX/Newsletters/Systems Programming Insider - Deep Dive Articles on Memory Management and Concurrency Patterns',
|
||||
attributes: [{ attr: 'HasNoChildren', extension: null }],
|
||||
}),
|
||||
m({ name: 'INBOX/Newsletters/Cloud Native Computing Foundation - Kubernetes Ecosystem Updates and Project Maturity Reports' }),
|
||||
m({ name: 'INBOX/Newsletters/Software Architecture Monthly - Case Studies in Distributed Systems Design and Microservices Patterns' }),
|
||||
m({ name: 'INBOX/Newsletters/DevOps Weekly Digest - Tool Reviews, Pipeline Optimization Techniques, and Platform Engineering Insights' }),
|
||||
m({ name: 'INBOX/Newsletters/Information Security Briefing - CVE Disclosures, Threat Intelligence Reports, and Zero-Day Advisories' }),
|
||||
m({ name: 'INBOX/Newsletters/Tech Leadership Forum - Engineering Management Best Practices and Organizational Scaling Strategies' }),
|
||||
];
|
||||
|
||||
export const MOCK_MAILBOX_LIST: MailboxListResponse = {
|
||||
status: 'ready',
|
||||
mailboxes: [
|
||||
...SHORT,
|
||||
...PROJECTS,
|
||||
...CLIENTS,
|
||||
...NOTIFICATIONS,
|
||||
...NEWSLETTERS,
|
||||
],
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
104
web/src/api/system/mock-dashboard.ts
Normal file
104
web/src/api/system/mock-dashboard.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Temporary mock data for dashboard responsive testing
|
||||
import { DashboardStats } from './api';
|
||||
|
||||
const NOW = Date.now();
|
||||
const DAY = 86400000;
|
||||
|
||||
export const MOCK_DASHBOARD_STATS: DashboardStats = {
|
||||
account_count: 12,
|
||||
email_count: 145892,
|
||||
attachment_count: 34201,
|
||||
total_size_bytes: 128_849_018_880, // ~120 GB logical
|
||||
storage_usage_bytes: 85_899_345_920, // ~80 GB blob
|
||||
index_usage_bytes: 12_884_901_888, // ~12 GB index
|
||||
recent_activity: Array.from({ length: 30 }, (_, i) => ({
|
||||
timestamp_ms: NOW - (29 - i) * DAY,
|
||||
count: Math.floor(Math.random() * 2000) + 200,
|
||||
})),
|
||||
top_senders: [
|
||||
{ key: 'alexander.hamilton@verylongemaildomain-truncation-test.com', count: 4523 },
|
||||
{ key: 'noreply@github-enterprise-notifications.system.example.org', count: 3891 },
|
||||
{ key: 'jane.doe+project-alpha-beta-gamma@company-with-long-name.io', count: 3102 },
|
||||
{ key: 'newsletter-subscriptions@really-long-marketing-domain.co.uk', count: 2845 },
|
||||
{ key: 'support-tickets+priority-high@helpdesk.corporate.example.com', count: 2100 },
|
||||
{ key: 'bot-pipeline-ci-cd-failures@devops.internal.long-subdomain.net', count: 1789 },
|
||||
{ key: 'short@s.dev', count: 1500 },
|
||||
{ key: 'alerts-monitoring-productions-east-us@observability-platform.com', count: 1256 },
|
||||
{ key: 'weekly-digest-no-reply@newsletter.huge-media-conglomerate.org', count: 980 },
|
||||
{ key: 'invitations-events-calendar-reminders@social-network-app.io', count: 760 },
|
||||
],
|
||||
top_accounts: [
|
||||
{ key: 'primary.work.mailbox@enterprise-long-domain-name.com', count: 78500 },
|
||||
{ key: 'personal.archive+all@very-lengthy-personal-domain.me', count: 42300 },
|
||||
{ key: 'secondary.backup@another-extremely-long-domain.co', count: 15100 },
|
||||
{ key: 'team-leads@department-of-engineering.corp.example.org', count: 8992 },
|
||||
{ key: 'short@x.co', count: 1000 },
|
||||
],
|
||||
with_attachment_count: 18500,
|
||||
without_attachment_count: 15701,
|
||||
top_largest_emails: [
|
||||
{
|
||||
id: 'msg-001',
|
||||
subject: 'RE: [EXTERNAL] Q4 Financial Reports & Budget Planning Documents for Review - Please Provide Feedback by EOD Friday with Department Head Sign-off Required',
|
||||
size_bytes: 52_428_800,
|
||||
},
|
||||
{
|
||||
id: 'msg-002',
|
||||
subject: 'Fwd: Urgent: Client Presentation Draft - Version 7 Final (With Legal Team Amendments and Compliance Review Attached)',
|
||||
size_bytes: 48_234_496,
|
||||
},
|
||||
{
|
||||
id: 'msg-003',
|
||||
subject: 'Meeting Minutes: Cross-Functional Architecture Review Session - Microservices Migration Strategy and Timeline Discussion (Part 3 of 5)',
|
||||
size_bytes: 41_943_040,
|
||||
},
|
||||
{
|
||||
id: 'msg-004',
|
||||
subject: 'Invoice #INV-2026-04582 - Professional Services Engagement: Cloud Infrastructure Assessment and Remediation Planning Phase II Deliverables',
|
||||
size_bytes: 38_797_312,
|
||||
},
|
||||
{
|
||||
id: 'msg-005',
|
||||
subject: '[ACTION REQUIRED] Security Incident Response: Post-Mortem Analysis and Remediation Steps for CVE-2026-12345 - Department-Wide Mandatory Review',
|
||||
size_bytes: 35_651_584,
|
||||
},
|
||||
{
|
||||
id: 'msg-006',
|
||||
subject: 'Monthly Newsletter: Engineering Blog Digest - Articles on Distributed Systems, Rust Async Runtime Internals, and Performance Optimization Techniques',
|
||||
size_bytes: 31_457_280,
|
||||
},
|
||||
{
|
||||
id: 'msg-007',
|
||||
subject: 'Contract Review: Master Service Agreement Amendment #7 with Third-Party Vendor Integration Services for Payment Processing Platform',
|
||||
size_bytes: 28_311_552,
|
||||
},
|
||||
{
|
||||
id: 'msg-008',
|
||||
subject: 'Travel Itinerary & Expense Report: International Conference on Systems Programming - Accommodation, Flight, and Per Diem Documentation Package',
|
||||
size_bytes: 25_165_824,
|
||||
},
|
||||
{
|
||||
id: 'msg-009',
|
||||
subject: 'Re: [INTERNAL] Employee Onboarding Documentation Package - Benefits Enrollment, Tax Forms, Direct Deposit Setup, and IT Access Request Forms Bundle',
|
||||
size_bytes: 22_020_096,
|
||||
},
|
||||
{
|
||||
id: 'msg-010',
|
||||
subject: 'Data Export Request: Complete Transaction History 2024-2026 with Audit Trail and Compliance Certification for External Regulatory Review Board',
|
||||
size_bytes: 18_874_368,
|
||||
},
|
||||
],
|
||||
top_largest_attachments: [
|
||||
{ id: 'att-001', name: 'Q4_2025_Financial_Statements_Audited_with_Supporting_Schedules_and_Notes_v3_FINAL.xlsx', size_bytes: 45_254_100 },
|
||||
{ id: 'att-002', name: 'project_deliverables_package_phase_2_with_test_results_coverage_report_and_deployment_guide.zip', size_bytes: 38_900_500 },
|
||||
{ id: 'att-003', name: '2026-01-15_production_database_backup_full_with_transaction_logs_and_stored_procedures.sql.gz', size_bytes: 35_200_000 },
|
||||
{ id: 'att-004', name: 'client_presentation_deck_v7_final_approved_with_speaker_notes_and_embedded_video_demo.pptx', size_bytes: 31_000_000 },
|
||||
{ id: 'att-005', name: 'system_architecture_diagrams_microservices_v2_with_sequence_flows_and_deployment_topology.pdf', size_bytes: 28_500_000 },
|
||||
{ id: 'att-006', name: 'annual_company_event_photograph_high_resolution_group_photo_panorama_2026.jpg', size_bytes: 25_000_000 },
|
||||
{ id: 'att-007', name: 'complete_source_code_archive_feature_branch_refactor_auth_module_2026_01_15.tar.gz', size_bytes: 22_800_000 },
|
||||
{ id: 'att-008', name: 'product_demo_screencast_walkthrough_new_features_2026_release_candidate.mp4', size_bytes: 19_500_000 },
|
||||
{ id: 'att-009', name: 'legal_contract_review_package_with_redlined_amendments_and_counsel_opinion_letters.pdf', size_bytes: 16_200_000 },
|
||||
{ id: 'att-010', name: 'employee_training_module_compliance_and_security_awareness_2026_v2_interactive.iso', size_bytes: 12_800_000 },
|
||||
],
|
||||
system_version: '1.0.1',
|
||||
};
|
||||
@@ -31,13 +31,14 @@ import { Loader2, CheckSquare, Square } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
|
||||
//import { MOCK_MAILBOX_LIST } from '@/api/mailbox/mock-mailboxes'
|
||||
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AccountModel, update_account } from '@/api/account/api'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
|
||||
import { useTheme } from '@/context/theme-context'
|
||||
import React from 'react'
|
||||
@@ -108,7 +109,7 @@ function CustomLabel({
|
||||
</div>
|
||||
{exists !== undefined && (
|
||||
<span
|
||||
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
|
||||
className="text-sm opacity-60 min-w-[40px] text-right text-inherit mr-4"
|
||||
>
|
||||
{exists}
|
||||
</span>
|
||||
@@ -181,7 +182,7 @@ export function DownloadFoldersDialog({ currentRow, open, onOpenChange }: Props)
|
||||
setItemsWithChildren(itemsWithChildren);
|
||||
setExpandedItems(itemsWithChildren);
|
||||
const download_folders = data
|
||||
.filter(mailbox => currentRow.download_folders.includes(mailbox.name))
|
||||
.filter(mailbox => currentRow.download_folders?.includes(mailbox.name))
|
||||
.map(mailbox => mailbox.id.toString());
|
||||
setSelectedItems(download_folders);
|
||||
};
|
||||
@@ -404,7 +405,7 @@ export function DownloadFoldersDialog({ currentRow, open, onOpenChange }: Props)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-3xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{t('accounts.selectMailboxes')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -412,8 +413,8 @@ export function DownloadFoldersDialog({ currentRow, open, onOpenChange }: Props)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col pt-2 gap-2">
|
||||
<div className="flex-1 min-h-0 grid gap-4" style={{ gridTemplateRows: 'auto 1fr' }}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -479,7 +480,7 @@ export function DownloadFoldersDialog({ currentRow, open, onOpenChange }: Props)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[32rem] flex-1 min-h-0 w-full pr-4 -mr-4 py-1">
|
||||
<ScrollArea className="min-h-0 w-full py-1">
|
||||
{isLoading && (
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
|
||||
@@ -17,9 +17,11 @@ import { Mail, Users, Inbox, Zap, Paperclip } from 'lucide-react';
|
||||
import { formatBytes, formatNumber } from '@/lib/utils';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { get_dashboard_stats, INITIAL_DASHBOARD_STATS, TimeBucket } from '@/api/system/api';
|
||||
//import { MOCK_DASHBOARD_STATS } from '@/api/system/mock-dashboard';
|
||||
import { Main } from '@/components/layout/main';
|
||||
import { FixedHeader } from '@/components/layout/fixed-header';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LongText from '@/components/long-text';
|
||||
import { getToken } from '@/stores/authStore';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||
@@ -346,18 +348,18 @@ export default function MailArchiveDashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="!px-4 !pt-4 !pb-1">
|
||||
<CardTitle className="text-xs font-bold uppercase tracking-wider">{t('dashboard.top10Senders')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hasTopSenders ? (
|
||||
<Table>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">{t('dashboard.sender')}</TableHead>
|
||||
<TableHead className="text-right text-xs">{t('dashboard.count')}</TableHead>
|
||||
<TableHead className="w-[96px] text-right text-xs">{t('dashboard.count')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -368,16 +370,18 @@ export default function MailArchiveDashboard() {
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
||||
<span className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickSearch({ from: s.key })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors truncate max-w-[258px]"
|
||||
>
|
||||
{s.key}
|
||||
</button>
|
||||
<LongText className="max-w-[180px] md:max-w-[160px] lg:max-w-[200px] xl:max-w-[220px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickSearch({ from: s.key })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors"
|
||||
>
|
||||
{s.key}
|
||||
</button>
|
||||
</LongText>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,17 +396,17 @@ export default function MailArchiveDashboard() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="!px-4 !pt-4 !pb-1">
|
||||
<CardTitle className="text-xs font-bold uppercase tracking-wider">{t('dashboard.top10LargestEmails')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hasTopEmails ? (
|
||||
<Table>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">{t('dashboard.subject')}</TableHead>
|
||||
<TableHead className="text-right text-xs">{t('dashboard.size')}</TableHead>
|
||||
<TableHead className="w-[96px] text-right text-xs">{t('dashboard.size')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -413,16 +417,18 @@ export default function MailArchiveDashboard() {
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
||||
<span className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickSearch({ id: m.id })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors truncate max-w-[258px]"
|
||||
>
|
||||
{m.subject || t('dashboard.noSubject')}
|
||||
</button>
|
||||
<LongText className="max-w-[180px] md:max-w-[160px] lg:max-w-[200px] xl:max-w-[220px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickSearch({ id: m.id })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors"
|
||||
>
|
||||
{m.subject || t('dashboard.noSubject')}
|
||||
</button>
|
||||
</LongText>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -437,7 +443,7 @@ export default function MailArchiveDashboard() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="!px-4 !pt-4 !pb-1">
|
||||
<CardTitle className="text-xs font-bold uppercase tracking-wider">
|
||||
{t('dashboard.top10LargestAttachments')}
|
||||
@@ -445,11 +451,11 @@ export default function MailArchiveDashboard() {
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{stats?.top_largest_attachments?.length ? (
|
||||
<Table>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">{t('attachment.name')}</TableHead>
|
||||
<TableHead className="text-right text-xs">{t('dashboard.size')}</TableHead>
|
||||
<TableHead className="w-[96px] text-right text-xs">{t('dashboard.size')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -463,16 +469,18 @@ export default function MailArchiveDashboard() {
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
||||
<span className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickAttachmentSearch({ id: a.id })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors truncate max-w-[238px]"
|
||||
>
|
||||
{a.name || 'Unnamed'}
|
||||
</button>
|
||||
<LongText className="max-w-[160px] md:max-w-[140px] lg:max-w-[180px] xl:max-w-[200px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickAttachmentSearch({ id: a.id })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors"
|
||||
>
|
||||
{a.name || 'Unnamed'}
|
||||
</button>
|
||||
</LongText>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -489,17 +497,17 @@ export default function MailArchiveDashboard() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="!px-4 !pt-4 !pb-1">
|
||||
<CardTitle className="text-xs font-bold uppercase tracking-wider">{t('dashboard.top10Accounts')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hasTopAccounts ? (
|
||||
<Table>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">{t('dashboard.account')}</TableHead>
|
||||
<TableHead className="text-right text-xs">{t('dashboard.emails')}</TableHead>
|
||||
<TableHead className="w-[96px] text-right text-xs">{t('dashboard.emails')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -510,16 +518,18 @@ export default function MailArchiveDashboard() {
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
||||
<span className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickSearch({ account_ids: [getAccountIdByEmail(acc.key) || 0] })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors truncate max-w-[258px]"
|
||||
>
|
||||
{acc.key}
|
||||
</button>
|
||||
<LongText className="max-w-[180px] md:max-w-[160px] lg:max-w-[200px] xl:max-w-[220px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickSearch({ account_ids: [getAccountIdByEmail(acc.key) || 0] })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors"
|
||||
>
|
||||
{acc.key}
|
||||
</button>
|
||||
</LongText>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<SettingRow label="BICHON_SMTP_AUTH_REQUIRED" value={<BooleanBadge value={data!.bichon_smtp_auth_required} />} />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
icon={Bot}
|
||||
title={t("systemConfig.sections.integration.title")}
|
||||
description={t("systemConfig.sections.integration.desc")}
|
||||
>
|
||||
<SettingRow
|
||||
label="BICHON_ENABLE_MCP"
|
||||
value={<BooleanBadge value={data!.bichon_enable_mcp} />}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
icon={Zap}
|
||||
title={t("systemConfig.sections.performance.title")}
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "قواعد الوصول عبر الأصول",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "تكامل بروتوكول المساعد الذكي.",
|
||||
"title": "التكامل الخارجي"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "سلوك إخراج السجلات",
|
||||
"title": "التسجيل"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Adgangsregler for cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integration af AI-assistentprotokol.",
|
||||
"title": "Ekstern integration"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Log-output adfærd",
|
||||
"title": "Logning"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Cross-Origin-Zugriffsregeln",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integration des KI-Assistenten-Protokolls.",
|
||||
"title": "Externe Integration"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Verhalten der Log-Ausgabe",
|
||||
"title": "Protokollierung"
|
||||
|
||||
@@ -1318,6 +1318,10 @@
|
||||
"desc": "Cross-origin access rules",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "AI assistant protocol integration.",
|
||||
"title": "External Integration"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Log output behavior",
|
||||
"title": "Logging"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Reglas de acceso de origen cruzado",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integración del protocolo del asistente de IA.",
|
||||
"title": "Integración externa"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Comportamiento de salida de logs",
|
||||
"title": "Registro"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Cross-origin-käyttösäännöt",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Tekoälyavustajan protokollaintegraatio.",
|
||||
"title": "Ulkoinen integraatio"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Lokien tulostuskäyttäytyminen",
|
||||
"title": "Lokitukset"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Règles d'accès cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Intégration du protocole d'assistant IA.",
|
||||
"title": "Intégration externe"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Comportement de sortie des logs",
|
||||
"title": "Journalisation"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Regole di accesso cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integrazione del protocollo dell'assistente IA.",
|
||||
"title": "Integrazione esterna"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Comportamento dell'output dei log",
|
||||
"title": "Logging"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "クロスオリジンアクセスのルール",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "AIアシスタントプロトコルの統合。",
|
||||
"title": "外部統合"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "ログ出力の動作",
|
||||
"title": "ロギング"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "교차 출처 액세스 규칙",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "AI 어시스턴트 프로토콜 연동.",
|
||||
"title": "외부 연동"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "로그 출력 동작",
|
||||
"title": "로깅"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Cross-origin toegangsregels",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integratie van AI-assistentprotocol.",
|
||||
"title": "Externe integratie"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Gedrag van log-output",
|
||||
"title": "Logging"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Tilgangsregler for cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integrasjon av AI-assistentprotokoll.",
|
||||
"title": "Ekstern integrasjon"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Loggutdata-oppførsel",
|
||||
"title": "Logging"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Zasady dostępu cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integracja protokołu asystenta AI.",
|
||||
"title": "Zewnętrzna integracja"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Zachowanie wyjściowe logów",
|
||||
"title": "Logowanie"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Regras de acesso cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integração do protocolo do assistente de IA.",
|
||||
"title": "Integração externa"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Comportamento de saída de logs",
|
||||
"title": "Logging"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Правила кросс-доменного доступа",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Интеграция протокола ИИ-ассистента.",
|
||||
"title": "Внешняя интеграция"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Поведение вывода логов",
|
||||
"title": "Логирование"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Åtkomstregler för cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integrering av AI-assistentprotokoll.",
|
||||
"title": "Extern integration"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Loggutmatning",
|
||||
"title": "Loggning"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "跨來源資源共用規則",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "AI 助手協定集成。",
|
||||
"title": "外部集成"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "日誌輸出行為",
|
||||
"title": "日誌"
|
||||
|
||||
@@ -1318,6 +1318,10 @@
|
||||
"desc": "跨域访问规则",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "AI 助手协议集成。",
|
||||
"title": "外部集成"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "日志输出行为",
|
||||
"title": "日志"
|
||||
|
||||
Reference in New Issue
Block a user