refactor!: replace Tantivy search engine with DuckDB

This commit is contained in:
rustmailer
2026-03-03 12:30:26 +08:00
parent ef4ab3496e
commit d2936ed4a7
52 changed files with 3594 additions and 1821 deletions
Generated
+1158 -111
View File
File diff suppressed because it is too large Load Diff
+27 -16
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "bichon" name = "bichon"
version = "0.3.7" version = "1.0.0"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
@@ -27,8 +27,8 @@ opt-level = 3
codegen-units = 1 codegen-units = 1
[dependencies] [dependencies]
chrono = "0.4.43" chrono = "0.4.44"
clap = { version = "4.5.56", features = ["derive", "env"] } clap = { version = "4.5.60", features = ["derive", "env"] }
mimalloc = "0.1.48" mimalloc = "0.1.48"
native_db = "0.8.2" native_db = "0.8.2"
itertools = "0.14.0" itertools = "0.14.0"
@@ -62,35 +62,35 @@ reqwest = { version = "0.12.24", default-features = false, features = [
] } ] }
tokio-socks = "0.5.2" tokio-socks = "0.5.2"
http = "1.4.0" http = "1.4.0"
regex = "1.12.2" regex = "1.12.3"
email_address = "0.2.9" email_address = "0.2.9"
futures = "0.3.31" futures = "0.3.32"
utf7-imap = "0.3.2" utf7-imap = "0.3.2"
imap-proto = "0.16.6" imap-proto = "0.16.6"
mail-parser = { version = '0.11.1', features = ["serde"] } mail-parser = { version = '0.11.2', features = ["serde"] }
# mail-send = "0.5.2" # mail-send = "0.5.2"
tokio-rustls = { version = "0.26.4", default-features = false, features = [ tokio-rustls = { version = "0.26.4", default-features = false, features = [
"ring", "ring",
"tls12", "tls12",
] } ] }
timeago = "0.5.0" timeago = "0.6.0"
ahash = "0.8.12" ahash = "0.8.12"
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] } oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
url = { version = "2.5.8", features = ["serde"] } url = { version = "2.5.8", features = ["serde"] }
sysinfo = "0.38.0" sysinfo = "0.38.2"
num_cpus = "1.17.0" num_cpus = "1.17.0"
cacache = { version = "13.1.0", default-features = false, features = [ cacache = { version = "13.1.0", default-features = false, features = [
"tokio-runtime", "tokio-runtime",
"mmap", "mmap",
] } ] }
rand = "0.9.2" rand = "0.10.0"
encoding_rs = "0.8.35" encoding_rs = "0.8.35"
async-imap = { version = "0.11.1", default-features = false, features = [ async-imap = { version = "0.11.2", default-features = false, features = [
"runtime-tokio", "runtime-tokio",
"compress", "compress",
] } ] }
webpki-roots = "1.0.5" webpki-roots = "1.0.6"
rustls = { version = "0.23.36", default-features = false, features = ["ring"] } rustls = { version = "0.23.37", default-features = false, features = ["ring"] }
rustls-pki-types = "1.14.0" rustls-pki-types = "1.14.0"
tokio-io-timeout = "1.2.1" tokio-io-timeout = "1.2.1"
semver = "1.0.27" semver = "1.0.27"
@@ -98,7 +98,7 @@ governor = "0.10.4"
lru = "0.16.3" lru = "0.16.3"
mime_guess = "2.0.5" mime_guess = "2.0.5"
hex = "0.4.3" hex = "0.4.3"
time = { version = "0.3.46", features = [ time = { version = "0.3.47", features = [
"formatting", "formatting",
"parsing", "parsing",
"local-offset", "local-offset",
@@ -114,16 +114,27 @@ gethostname = "1.1.0"
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] } tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
itoa = "1.0.17" itoa = "1.0.17"
html2text = "0.16.7" html2text = "0.16.7"
bytes = "1.11.0" bytes = "1.11.1"
dialoguer = "0.12.0" dialoguer = "0.12.0"
console = "0.16.2" console = "0.16.2"
toml = "0.9.8" toml = "0.9.8"
memmap2 = "0.9.9" memmap2 = "0.9.10"
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" } outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
compressed-rtf = "1.0.0" compressed-rtf = "1.0.0"
codepage-strings = "1.0.2" codepage-strings = "1.0.2"
mail-send = "0.5.2" mail-send = "0.5.2"
duckdb = { version = "1.4.4", features = [
"chrono",
"bundled",
"r2d2",
"appender-arrow",
] }
arrow = { version = "56.2.0", features = ["ffi"] }
r2d2 = { version = "0.8", default-features = false }
refinery = { version = "0.9", default-features = false }
refinery-core = { version = "0.9", default-features = false }
[dev-dependencies] [dev-dependencies]
#bincode = "1.3.3" #bincode = "1.3.3"
#secret-lib = "1.0.0" #secret-lib = "1.0.0"
tempfile = "3.24.0" tempfile = "3.26.0"
+3
View File
@@ -1,6 +1,9 @@
use std::{io::Result, process::Command}; use std::{io::Result, process::Command};
fn main() -> Result<()> { fn main() -> Result<()> {
if cfg!(target_os = "windows") {
println!("cargo:rustc-link-lib=Rstrtmgr");
}
let output = Command::new("git") let output = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"]) .args(&["rev-parse", "--short", "HEAD"])
.output() .output()
+2
View File
@@ -21,6 +21,7 @@ use bichon::{
modules::{ modules::{
common::rustls::RustMailerTls, common::rustls::RustMailerTls,
context::{executors::BichonContext, Initialize}, context::{executors::BichonContext, Initialize},
duckdb::init::DuckDBManager,
error::BichonResult, error::BichonResult,
logger, logger,
rest::start_http_server, rest::start_http_server,
@@ -69,6 +70,7 @@ async fn initialize() -> BichonResult<()> {
// SETTINGS.validate()?; // SETTINGS.validate()?;
SignalManager::initialize().await?; SignalManager::initialize().await?;
DataDirManager::initialize().await?; DataDirManager::initialize().await?;
DuckDBManager::initialize().await?;
UserManager::initialize().await?; UserManager::initialize().await?;
RustMailerTls::initialize().await?; RustMailerTls::initialize().await?;
BichonContext::initialize().await?; BichonContext::initialize().await?;
+1 -1
View File
@@ -58,7 +58,7 @@ impl BatchAccountRoleRequest {
} }
for id in &self.account_ids { for id in &self.account_ids {
let exists = AccountModel::find(*id).await?; // Assuming an exists helper let exists = AccountModel::async_find(*id).await?; // Assuming an exists helper
if exists.is_none() { if exists.is_none() {
return Err(raise_error!( return Err(raise_error!(
format!("Account ID {} not found", id), format!("Account ID {} not found", id),
+146 -24
View File
@@ -33,7 +33,7 @@ use crate::{
state::AccountRunningState, state::AccountRunningState,
}, },
cache::imap::mailbox::MailBox, cache::imap::mailbox::MailBox,
database::{list_all_impl, with_transaction}, database::{list_all_impl, secondary_find_impl, with_transaction},
error::BichonResult, error::BichonResult,
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER}, indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel, DEFAULT_ADMIN_USER_ID}, users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel, DEFAULT_ADMIN_USER_ID},
@@ -51,14 +51,14 @@ use crate::modules::database::count_by_unique_secondary_key_impl;
use crate::modules::database::delete_impl; use crate::modules::database::delete_impl;
use crate::modules::database::manager::DB_MANAGER; use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{ use crate::modules::database::{
paginate_query_primary_scan_all_impl, secondary_find_impl, update_impl, async_secondary_find_impl, paginate_query_primary_scan_all_impl, update_impl,
}; };
use crate::modules::error::code::ErrorCode; use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::token::OAuth2AccessToken; use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::rest::response::DataPage; use crate::modules::rest::response::DataPage;
use crate::raise_error; use crate::raise_error;
pub type AccountModel = AccountV3; pub type AccountModel = AccountV4;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)] #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
pub enum AccountType { pub enum AccountType {
@@ -158,6 +158,42 @@ impl AccountV3 {
fn pk(&self) -> String { fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id) format!("{}_{}", self.created_at, self.id)
} }
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 4, version = 4, from = AccountV3)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV4 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub sync_batch_size: Option<u32>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
pub imap_daily_quota_bytes: Option<u32>,
pub auto_sync_new_mailboxes: Option<bool>,
}
impl AccountV4 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult<Self> { pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult<Self> {
Ok(Self { Ok(Self {
@@ -181,25 +217,30 @@ impl AccountV3 {
created_by: user_id, created_by: user_id,
sync_batch_size: request.sync_batch_size, sync_batch_size: request.sync_batch_size,
date_before: request.date_before, date_before: request.date_before,
imap_daily_quota_bytes: request.imap_daily_quota_bytes,
auto_sync_new_mailboxes: request.auto_sync_new_mailboxes,
}) })
} }
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> { pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
let account = let account = async_secondary_find_impl::<AccountModel>(
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id) DB_MANAGER.meta_db(),
.await? AccountV4Key::id,
.ok_or_else(|| { account_id,
raise_error!( )
format!("Account id='{account_id}' not found"), .await?
ErrorCode::ResourceNotFound .ok_or_else(|| {
) raise_error!(
})?; format!("Account id='{account_id}' not found"),
ErrorCode::ResourceNotFound
)
})?;
Ok(account) Ok(account)
} }
/// Fetches an `AccountEntity` by its `id`. /// Fetches an `AccountEntity` by its `id`.
pub async fn get(account_id: u64) -> BichonResult<AccountModel> { pub async fn async_get(account_id: u64) -> BichonResult<AccountModel> {
let result: AccountModel = Self::find(account_id).await?.ok_or_else(|| { let result: AccountModel = Self::async_find(account_id).await?.ok_or_else(|| {
raise_error!( raise_error!(
format!("Account with ID '{account_id}' not found"), format!("Account with ID '{account_id}' not found"),
ErrorCode::ResourceNotFound ErrorCode::ResourceNotFound
@@ -208,9 +249,27 @@ impl AccountV3 {
Ok(result) Ok(result)
} }
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> { pub fn get(account_id: u64) -> BichonResult<AccountModel> {
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id) let result: AccountModel = Self::find(account_id)?.ok_or_else(|| {
.await raise_error!(
format!("Account with ID '{account_id}' not found"),
ErrorCode::ResourceNotFound
)
})?;
Ok(result)
}
pub async fn async_find(account_id: u64) -> BichonResult<Option<AccountModel>> {
async_secondary_find_impl::<AccountModel>(
DB_MANAGER.meta_db(),
AccountV4Key::id,
account_id,
)
.await
}
pub fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV4Key::id, account_id)
} }
pub async fn create_account( pub async fn create_account(
@@ -258,7 +317,7 @@ impl AccountV3 {
request: AccountUpdateRequest, request: AccountUpdateRequest,
validate: bool, validate: bool,
) -> BichonResult<()> { ) -> BichonResult<()> {
let account = AccountModel::get(account_id).await?; let account = AccountModel::async_get(account_id).await?;
if validate { if validate {
request.validate_update_request(&account)?; request.validate_update_request(&account)?;
} }
@@ -273,7 +332,7 @@ impl AccountV3 {
} }
pub async fn delete(account_id: u64) -> BichonResult<()> { pub async fn delete(account_id: u64) -> BichonResult<()> {
let account = Self::get(account_id).await?; let account = Self::async_get(account_id).await?;
if let Err(error) = Self::cleanup_account_resources_sequential(&account).await { if let Err(error) = Self::cleanup_account_resources_sequential(&account).await {
tracing::error!( tracing::error!(
"[CLEANUP_ACCOUNT_ERROR] Account {}: failed to cleanup resources: {:#?}", "[CLEANUP_ACCOUNT_ERROR] Account {}: failed to cleanup resources: {:#?}",
@@ -287,7 +346,7 @@ impl AccountV3 {
async fn delete_account(account_id: u64) -> BichonResult<()> { async fn delete_account(account_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move|rw|{ delete_impl(DB_MANAGER.meta_db(), move|rw|{
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? rw.get().secondary::<AccountModel>(AccountV4Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound)) .ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound))
}).await }).await
} }
@@ -317,7 +376,7 @@ impl AccountV3 {
sync_folders: Vec<String>, sync_folders: Vec<String>,
) -> BichonResult<()> { ) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| { update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? rw.get().secondary::<AccountModel>(AccountV4Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound)) .ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{ }, |current|{
let mut updated = current.clone(); let mut updated = current.clone();
@@ -332,7 +391,7 @@ impl AccountV3 {
known_folders: BTreeSet<String>, known_folders: BTreeSet<String>,
) -> BichonResult<()> { ) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| { update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? rw.get().secondary::<AccountModel>(AccountV4Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound)) .ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{ }, |current|{
let mut updated = current.clone(); let mut updated = current.clone();
@@ -347,7 +406,7 @@ impl AccountV3 {
capabilities: Vec<String>, capabilities: Vec<String>,
) -> BichonResult<()> { ) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| { update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? rw.get().secondary::<AccountModel>(AccountV4Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound)) .ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{ }, |current|{
let mut updated = current.clone(); let mut updated = current.clone();
@@ -378,7 +437,7 @@ impl AccountV3 {
} }
pub async fn count() -> BichonResult<usize> { pub async fn count() -> BichonResult<usize> {
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id) count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV4Key::id)
.await .await
} }
@@ -483,6 +542,13 @@ impl AccountV3 {
new.pgp_key = Some(pgp_key); new.pgp_key = Some(pgp_key);
} }
if let Some(imap_daily_quota_bytes) = request.imap_daily_quota_bytes {
new.imap_daily_quota_bytes = Some(imap_daily_quota_bytes);
}
if let Some(auto_sync_new_mailboxes) = request.auto_sync_new_mailboxes {
new.auto_sync_new_mailboxes = Some(auto_sync_new_mailboxes);
}
new.updated_at = utc_now!(); new.updated_at = utc_now!();
Ok(new) Ok(new)
} }
@@ -584,3 +650,59 @@ impl From<AccountV2> for AccountV3 {
} }
} }
} }
impl From<AccountV4> for AccountV3 {
fn from(value: AccountV4) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
date_before: value.date_before,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
sync_batch_size: value.sync_batch_size,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
created_by: value.created_by,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
}
}
}
impl From<AccountV3> for AccountV4 {
fn from(value: AccountV3) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
date_before: value.date_before,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
sync_batch_size: value.sync_batch_size,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
created_by: value.created_by,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
imap_daily_quota_bytes: None,
auto_sync_new_mailboxes: None,
}
}
}
+4
View File
@@ -44,6 +44,8 @@ pub struct AccountCreateRequest {
pub use_proxy: Option<u64>, pub use_proxy: Option<u64>,
pub use_dangerous: bool, pub use_dangerous: bool,
pub pgp_key: Option<String>, pub pgp_key: Option<String>,
pub imap_daily_quota_bytes: Option<u32>,
pub auto_sync_new_mailboxes: Option<bool>,
} }
impl AccountCreateRequest { impl AccountCreateRequest {
@@ -155,6 +157,8 @@ pub struct AccountUpdateRequest {
pub use_dangerous: Option<bool>, pub use_dangerous: Option<bool>,
pub pgp_key: Option<String>, pub pgp_key: Option<String>,
pub imap_daily_quota_bytes: Option<u32>,
pub auto_sync_new_mailboxes: Option<bool>,
} }
impl AccountUpdateRequest { impl AccountUpdateRequest {
+2
View File
@@ -54,6 +54,7 @@ pub struct AccountResp {
pub use_proxy: Option<u64>, pub use_proxy: Option<u64>,
pub use_dangerous: bool, pub use_dangerous: bool,
pub pgp_key: Option<String>, pub pgp_key: Option<String>,
pub imap_daily_quota_bytes: Option<u32>,
} }
impl AccountResp { impl AccountResp {
@@ -86,6 +87,7 @@ impl AccountResp {
use_proxy: account.use_proxy, use_proxy: account.use_proxy,
use_dangerous: account.use_dangerous, use_dangerous: account.use_dangerous,
pgp_key: account.pgp_key, pgp_key: account.pgp_key,
imap_daily_quota_bytes: account.imap_daily_quota_bytes,
} }
} }
} }
+18 -22
View File
@@ -20,8 +20,9 @@ use crate::{
decode_mailbox_name, encode_mailbox_name, decode_mailbox_name, encode_mailbox_name,
modules::{ modules::{
database::{ database::{
async_find_impl, batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl, async_filter_by_secondary_key_impl, async_find_impl, batch_delete_impl,
filter_by_secondary_key_impl, manager::DB_MANAGER, batch_insert_impl, batch_upsert_impl, delete_impl, filter_by_secondary_key_impl,
manager::DB_MANAGER,
}, },
error::{code::ErrorCode, BichonResult}, error::{code::ErrorCode, BichonResult},
}, },
@@ -71,24 +72,6 @@ impl MailBox {
encode_mailbox_name!(&self.name) encode_mailbox_name!(&self.name)
} }
// pub async fn batch_delete(mailboxes: Vec<MailBox>) -> BichonResult<()> {
// batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| {
// let mut to_deleted = Vec::new();
// for mailbox in mailboxes {
// let retrived = rw
// .get()
// .primary::<MailBox>(mailbox.id)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// if let Some(retrived) = retrived {
// to_deleted.push(retrived);
// }
// }
// Ok(to_deleted)
// })
// .await?;
// Ok(())
// }
pub async fn get(id: u64) -> BichonResult<MailBox> { pub async fn get(id: u64) -> BichonResult<MailBox> {
let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?; let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
Ok(result.ok_or_else(|| { Ok(result.ok_or_else(|| {
@@ -110,8 +93,21 @@ impl MailBox {
} }
pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> { pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
filter_by_secondary_key_impl(DB_MANAGER.envelope_db(), MailBoxKey::account_id, account_id) async_filter_by_secondary_key_impl(
.await DB_MANAGER.envelope_db(),
MailBoxKey::account_id,
account_id,
)
.await
}
pub fn find_mailbox(account_id: u64, mailbox_id: u64) -> BichonResult<Option<MailBox>> {
let all: Vec<MailBox> = filter_by_secondary_key_impl(
DB_MANAGER.envelope_db(),
MailBoxKey::account_id,
account_id,
)?;
Ok(all.into_iter().find(|m| m.id == mailbox_id))
} }
pub async fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> { pub async fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
+1 -1
View File
@@ -62,7 +62,7 @@ pub async fn get_sync_folders(
mailboxes.iter().map(|(m, _)| m.name.clone()).collect(), mailboxes.iter().map(|(m, _)| m.name.clone()).collect(),
) )
.await?; .await?;
let account = AccountModel::get(account.id).await?; let account = AccountModel::async_get(account.id).await?;
let subscribed = &account.sync_folders.unwrap_or_default(); let subscribed = &account.sync_folders.unwrap_or_default();
let is_noselect = |mailbox: &MailBox| { let is_noselect = |mailbox: &MailBox| {
mailbox mailbox
+1 -1
View File
@@ -54,7 +54,7 @@ impl AccountSyncTask {
let task = move |param: Option<u64>| { let task = move |param: Option<u64>| {
let account_id = param.unwrap(); let account_id = param.unwrap();
Box::pin(async move { Box::pin(async move {
let account = AccountModel::get(account_id).await.ok(); let account = AccountModel::async_get(account_id).await.ok();
match account { match account {
Some(account) => { Some(account) => {
if !account.enabled { if !account.enabled {
+1 -4
View File
@@ -16,12 +16,9 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::sync::LazyLock; use std::sync::LazyLock;
use crate::modules::{ use crate::modules::{context::Initialize, error::BichonResult, utils::shutdown::shutdown_signal};
context::Initialize, error::BichonResult, utils::shutdown::shutdown_signal,
};
use tokio::sync::broadcast; use tokio::sync::broadcast;
pub static SIGNAL_MANAGER: LazyLock<SignalManager> = LazyLock::new(SignalManager::new); pub static SIGNAL_MANAGER: LazyLock<SignalManager> = LazyLock::new(SignalManager::new);
+5 -34
View File
@@ -20,7 +20,6 @@ use crate::modules::users::permissions::Permission;
use poem_openapi::Object; use poem_openapi::Object;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
use tantivy::{schema::Value, TantivyDocument};
use crate::{ use crate::{
bichon_version, bichon_version,
@@ -28,7 +27,7 @@ use crate::{
account::migration::AccountModel, account::migration::AccountModel,
common::auth::ClientContext, common::auth::ClientContext,
error::{code::ErrorCode, BichonResult}, error::{code::ErrorCode, BichonResult},
indexer::{manager::ENVELOPE_INDEX_MANAGER, schema::SchemaTools}, indexer::manager::ENVELOPE_INDEX_MANAGER,
settings::dir::DATA_DIR_MANAGER, settings::dir::DATA_DIR_MANAGER,
utils::get_total_size, utils::get_total_size,
}, },
@@ -65,11 +64,11 @@ impl DashboardStats {
}; };
let mut stat = ENVELOPE_INDEX_MANAGER let mut stat = ENVELOPE_INDEX_MANAGER
.get_dashboard_stats(&authorized_ids) .get_dashboard_stats(authorized_ids.clone())
.await?; .await?;
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER stat.top_largest_emails = ENVELOPE_INDEX_MANAGER
.top_10_largest_emails(&authorized_ids) .top_10_largest_emails(authorized_ids.clone())
.await?; .await?;
stat.account_count = if has_all_accounts { stat.account_count = if has_all_accounts {
@@ -78,7 +77,7 @@ impl DashboardStats {
authorized_ids.as_ref().map(|ids| ids.len()).unwrap_or(0) authorized_ids.as_ref().map(|ids| ids.len()).unwrap_or(0)
}; };
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails(&authorized_ids)?; stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails(authorized_ids).await?;
if has_all_accounts { if has_all_accounts {
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir) stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
@@ -114,33 +113,5 @@ pub struct Group {
pub struct LargestEmail { pub struct LargestEmail {
pub subject: String, // Email subject pub subject: String, // Email subject
pub size_bytes: u64, // Email size in bytes pub size_bytes: u64, // Email size in bytes
} pub id: u64,
impl LargestEmail {
pub fn from_tantivy_doc(document: &TantivyDocument) -> BichonResult<Self> {
let fields = SchemaTools::envelope_fields();
let value = document.get_first(fields.f_size).ok_or_else(|| {
raise_error!(
"miss 'size' field in tantivy document".into(),
ErrorCode::InternalError
)
})?;
let size_bytes = value.as_u64().ok_or_else(|| {
raise_error!("'size' field is not a u64".into(), ErrorCode::InternalError)
})?;
let value = document.get_first(fields.f_subject).ok_or_else(|| {
raise_error!("'subject' field not found".into(), ErrorCode::InternalError)
})?;
let subject = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
raise_error!(
"'subject' field is not a string".into(),
ErrorCode::InternalError
)
})?;
let envelope = LargestEmail {
subject,
size_bytes,
};
Ok(envelope)
}
} }
+40 -42
View File
@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::{AccountV1, AccountV2, AccountV3}; use crate::modules::account::migration::{AccountV1, AccountV2, AccountV3, AccountV4};
use crate::modules::autoconfig::CachedMailSettings; use crate::modules::autoconfig::CachedMailSettings;
use crate::modules::error::code::ErrorCode; use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult; use crate::modules::error::BichonResult;
@@ -67,6 +67,7 @@ impl ModelsAdapter {
self.register_model::<AccountV1>(); self.register_model::<AccountV1>();
self.register_model::<AccountV2>(); self.register_model::<AccountV2>();
self.register_model::<AccountV3>(); self.register_model::<AccountV3>();
self.register_model::<AccountV4>();
self.register_model::<OAuth2>(); self.register_model::<OAuth2>();
self.register_model::<OAuth2PendingEntity>(); self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>(); self.register_model::<OAuth2AccessToken>();
@@ -188,30 +189,6 @@ pub async fn update_impl<T: ToInput + Clone + std::fmt::Debug + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} }
// pub async fn batch_update_impl<T: ToInput + Clone + std::fmt::Debug + Send + 'static>(
// database: &Arc<Database<'static>>,
// filter: impl FnOnce(&RwTransaction) -> RustMailerResult<Vec<T>> + Send + 'static,
// updated: impl FnOnce(&Vec<T>) -> RustMailerResult<Vec<(T, T)>> + Send + 'static,
// ) -> RustMailerResult<Vec<T>> {
// let db = database.clone();
// tokio::task::spawn_blocking(move || {
// let rw = db
// .rw_transaction()
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// let targets = filter(&rw)?;
// let tuples = updated(&targets)?;
// for (old, updated) in tuples {
// rw.update(old, updated)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// }
// rw.commit()
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Ok(targets)
// })
// .await
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// }
pub async fn async_find_impl<T: ToInput + Clone + Send + 'static>( pub async fn async_find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>, database: &Arc<Database<'static>>,
key: impl ToKey + Send + 'static, key: impl ToKey + Send + 'static,
@@ -231,21 +208,6 @@ pub async fn async_find_impl<T: ToInput + Clone + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} }
// pub fn find_impl<T: ToInput + Clone + Send + 'static>(
// database: &Arc<Database<'static>>,
// key: &str,
// ) -> BichonResult<Option<T>> {
// let db = database.clone();
// let r_transaction = db
// .r_transaction()
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// let entity: Option<T> = r_transaction
// .get()
// .primary(key)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Ok(entity)
// }
pub async fn delete_impl<T: ToInput + Clone + Send + 'static>( pub async fn delete_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>, database: &Arc<Database<'static>>,
delete: impl FnOnce(&RwTransaction) -> BichonResult<T> + Send + 'static, delete: impl FnOnce(&RwTransaction) -> BichonResult<T> + Send + 'static,
@@ -423,7 +385,7 @@ pub async fn paginate_query_primary_scan_all_impl<
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} }
pub async fn filter_by_secondary_key_impl<T: ToInput + Clone + Send + 'static>( pub async fn async_filter_by_secondary_key_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>, database: &Arc<Database<'static>>,
key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static, key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static,
start_with: impl ToKey + Send + 'static, start_with: impl ToKey + Send + 'static,
@@ -447,6 +409,26 @@ pub async fn filter_by_secondary_key_impl<T: ToInput + Clone + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} }
pub fn filter_by_secondary_key_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static,
start_with: impl ToKey + Send + 'static,
) -> BichonResult<Vec<T>> {
let db = database.clone();
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entities: Vec<T> = r_transaction
.scan()
.secondary(key_def)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(start_with)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entities)
}
pub async fn count_by_unique_secondary_key_impl<T: ToInput + Clone + Send + 'static>( pub async fn count_by_unique_secondary_key_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>, database: &Arc<Database<'static>>,
key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static, key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static,
@@ -469,7 +451,7 @@ pub async fn count_by_unique_secondary_key_impl<T: ToInput + Clone + Send + 'sta
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} }
pub async fn secondary_find_impl<T: ToInput + Clone + Send + 'static>( pub async fn async_secondary_find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>, database: &Arc<Database<'static>>,
key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static, key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static,
key: impl ToKey + Send + 'static, key: impl ToKey + Send + 'static,
@@ -491,6 +473,22 @@ pub async fn secondary_find_impl<T: ToInput + Clone + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} }
pub fn secondary_find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static,
key: impl ToKey + Send + 'static,
) -> BichonResult<Option<T>> {
let db = database.clone();
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entities: Option<T> = r_transaction
.get()
.secondary(key_def, key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entities)
}
#[derive(Debug)] #[derive(Debug)]
pub struct Paginated<T> { pub struct Paginated<T> {
pub page: Option<u64>, pub page: Option<u64>,
+240
View File
@@ -0,0 +1,240 @@
use arrow::array::{BooleanArray, Int32Array, Int64Array, ListBuilder, StringBuilder, UInt64Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use std::sync::Arc;
use crate::modules::indexer::envelope::Envelope;
pub const DEFAULT_SHARD_ID: u64 = 0;
pub fn build_record_batch(items: &[Envelope]) -> RecordBatch {
let capacity = items.len();
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::UInt64, false),
Field::new("account_id", DataType::UInt64, false),
Field::new("mailbox_id", DataType::UInt64, false),
Field::new("uid", DataType::UInt64, false),
Field::new("subject", DataType::Utf8, true),
Field::new("body", DataType::Utf8, true),
Field::new("sender", DataType::Utf8, true),
Field::new(
"recipients",
DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
true,
),
Field::new(
"cc",
DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
true,
),
Field::new(
"bcc",
DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
true,
),
Field::new("sent_at", DataType::Int64, true),
Field::new("received_at", DataType::Int64, true),
Field::new("size_bytes", DataType::UInt64, true),
Field::new("thread_id", DataType::UInt64, true),
Field::new("message_id", DataType::Utf8, true),
Field::new("has_attachment", DataType::Boolean, false),
Field::new("attachment_count", DataType::Int32, false),
Field::new(
"tags",
DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
true,
),
Field::new("shard_id", DataType::UInt64, false),
]));
let mut id_b = UInt64Array::builder(capacity);
let mut account_id_b = UInt64Array::builder(capacity);
let mut mailbox_id_b = UInt64Array::builder(capacity);
let mut uid_b = UInt64Array::builder(capacity);
let mut subject_b = StringBuilder::with_capacity(capacity, capacity * 20);
let mut body_b = StringBuilder::with_capacity(capacity, capacity * 100);
let mut from_b = StringBuilder::with_capacity(capacity, capacity * 20);
let mut to_b = ListBuilder::new(StringBuilder::new());
let mut cc_b = ListBuilder::new(StringBuilder::new());
let mut bcc_b = ListBuilder::new(StringBuilder::new());
let mut date_b = Int64Array::builder(capacity);
let mut internal_date_b = Int64Array::builder(capacity);
let mut size_b = UInt64Array::builder(capacity);
let mut thread_id_b = UInt64Array::builder(capacity);
let mut msg_id_b = StringBuilder::with_capacity(capacity, capacity * 30);
let mut has_att_b = BooleanArray::builder(capacity);
let mut att_count_b = Int32Array::builder(capacity);
let mut tags_b = ListBuilder::new(StringBuilder::new());
let mut shard_id_b = UInt64Array::builder(capacity);
for e in items {
id_b.append_value(e.id);
account_id_b.append_value(e.account_id);
mailbox_id_b.append_value(e.mailbox_id);
uid_b.append_value(e.uid as u64);
subject_b.append_value(&e.subject);
body_b.append_value(&e.text);
from_b.append_value(&e.from);
for addr in &e.to {
to_b.values().append_value(addr);
}
to_b.append(true);
for addr in &e.cc {
cc_b.values().append_value(addr);
}
cc_b.append(true);
for addr in &e.bcc {
bcc_b.values().append_value(addr);
}
bcc_b.append(true);
date_b.append_value(e.date);
internal_date_b.append_value(e.internal_date);
size_b.append_value(e.size as u64);
thread_id_b.append_value(e.thread_id);
msg_id_b.append_value(&e.message_id);
has_att_b.append_value(e.attachment_count > 0);
att_count_b.append_value(e.attachment_count as i32);
tags_b.append(true);
shard_id_b.append_value(DEFAULT_SHARD_ID);
}
RecordBatch::try_new(
schema,
vec![
Arc::new(id_b.finish()),
Arc::new(account_id_b.finish()),
Arc::new(mailbox_id_b.finish()),
Arc::new(uid_b.finish()),
Arc::new(subject_b.finish()),
Arc::new(body_b.finish()),
Arc::new(from_b.finish()),
Arc::new(to_b.finish()),
Arc::new(cc_b.finish()),
Arc::new(bcc_b.finish()),
Arc::new(date_b.finish()),
Arc::new(internal_date_b.finish()),
Arc::new(size_b.finish()),
Arc::new(thread_id_b.finish()),
Arc::new(msg_id_b.finish()),
Arc::new(has_att_b.finish()),
Arc::new(att_count_b.finish()),
Arc::new(tags_b.finish()),
Arc::new(shard_id_b.finish()),
],
)
.expect("Failed to build RecordBatch")
}
#[cfg(test)]
mod integration_tests {
use super::*;
use duckdb::{Connection, Result};
#[test]
fn test_envelope_ingestion_and_query() -> Result<()> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS envelopes (
-- internal id (tantivy f_id)
id UBIGINT NOT NULL,
-- account / mailbox / uid
account_id UBIGINT NOT NULL,
mailbox_id UBIGINT NOT NULL,
uid UBIGINT NOT NULL,
-- headers / content
subject TEXT,
body TEXT,
sender TEXT,
recipients VARCHAR[],
cc VARCHAR[],
bcc VARCHAR[],
-- dates
sent_at BIGINT,
received_at BIGINT,
-- size
size_bytes UBIGINT,
-- thread
thread_id UBIGINT,
-- message-id
message_id TEXT,
-- attachment summary
has_attachment BOOLEAN NOT NULL,
attachment_count INTEGER NOT NULL CHECK (attachment_count >= 0),
tags VARCHAR[],
shard_id UBIGINT NOT NULL
);
"#,
)?;
let items = vec![Envelope {
id: 101,
account_id: 1,
mailbox_id: 1,
uid: 50,
subject: "Testing Arrow".to_string(),
text: "Content".to_string(),
from: "sender@test.com".to_string(),
to: vec!["user1@test.com".to_string(), "user2@test.com".to_string()],
cc: vec!["manager@test.com".to_string()],
bcc: vec![],
date: 1000,
internal_date: 1001,
size: 2048,
thread_id: 1,
message_id: "id123".to_string(),
attachment_count: 1,
tags: None,
account_email: None,
mailbox_name: None,
}];
{
let batch = build_record_batch(&items);
let mut appender = conn.appender("envelopes")?;
appender.append_record_batch(batch)?;
appender.flush()?;
}
let mut stmt = conn.prepare("SELECT subject, size_bytes FROM envelopes WHERE id = 101")?;
let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? {
let subject: String = row.get(0)?;
let size: u64 = row.get(1)?;
assert_eq!(subject, "Testing Arrow");
assert_eq!(size, 2048);
}
let mut stmt = conn.prepare(
"SELECT count(*) FROM envelopes WHERE list_contains(\"recipients\", 'user2@test.com')",
)?;
let count: i64 = stmt.query_row([], |r| r.get(0))?;
assert_eq!(count, 1);
let has_att: bool = conn.query_row(
"SELECT has_attachment FROM envelopes WHERE id = 101",
[],
|r| r.get(0),
)?;
assert!(has_att);
println!("Integration test for ingestion and query passed!");
Ok(())
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
-- =========================
-- envelopes (main table)
-- =========================
CREATE TABLE IF NOT EXISTS envelopes (
-- internal id (tantivy f_id)
id UBIGINT NOT NULL,
-- account / mailbox / uid
account_id UBIGINT NOT NULL,
mailbox_id UBIGINT NOT NULL,
uid UBIGINT NOT NULL,
-- headers / content
subject TEXT,
body TEXT,
sender TEXT,
recipients VARCHAR[],
cc VARCHAR[],
bcc VARCHAR[],
-- dates
sent_at BIGINT,
received_at BIGINT,
-- size
size_bytes UBIGINT,
-- thread
thread_id UBIGINT,
-- message-id
message_id TEXT,
-- attachment summary
has_attachment BOOLEAN NOT NULL,
attachment_count INTEGER NOT NULL CHECK (attachment_count >= 0),
tags VARCHAR[],
shard_id UBIGINT NOT NULL
);
CREATE INDEX idx_env_mailbox_sent ON envelopes(account_id, mailbox_id, sent_at);
-- =========================
-- envelope_attachments
--
-- Normalized attachment metadata for fast filtering and UI display
-- =========================
CREATE TABLE IF NOT EXISTS envelope_attachments (
-- Reference to envelopes.id
envelope_id UBIGINT NOT NULL,
account_id UBIGINT NOT NULL,
mailbox_id UBIGINT NOT NULL,
-- Original attachment filename (for display)
filename TEXT NOT NULL,
-- Normalized file extension (lowercase, without dot)
extension TEXT NOT NULL,
-- Extension category (document / image / archive / ...)
ext_category TEXT NOT NULL,
content_type TEXT NOT NULL,
-- Attachment size in bytes
-- 0 if unknown
size_bytes UBIGINT NOT NULL,
shard_id UBIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_attachments_mailbox_id ON envelope_attachments (mailbox_id);
+3
View File
@@ -0,0 +1,3 @@
pub mod build;
pub mod init;
pub mod refinery;
+74
View File
@@ -0,0 +1,74 @@
use chrono::{DateTime, Utc};
use refinery::{error::WrapMigrationError, Migration};
use refinery_core::{
traits::sync::{Query, Transaction},
Migrate,
};
use std::{ops::DerefMut, time::SystemTime};
pub struct DuckDBConnection<T: DerefMut<Target = duckdb::Connection>>(pub T);
impl<T: DerefMut<Target = duckdb::Connection>> From<T> for DuckDBConnection<T> {
fn from(conn: T) -> Self {
Self(conn)
}
}
impl<Conn: DerefMut<Target = duckdb::Connection>> Transaction for DuckDBConnection<Conn> {
type Error = duckdb::Error;
fn execute<'a, T: Iterator<Item = &'a str>>(
&mut self,
mut queries: T,
) -> std::result::Result<usize, Self::Error> {
let transaction = self.0.transaction()?;
let count = queries.try_fold(0, |count, query| {
transaction.execute_batch(query)?;
Ok::<_, Self::Error>(count + 1)
})?;
transaction.commit()?;
Ok(count)
}
}
impl<T: DerefMut<Target = duckdb::Connection>> Query<Vec<Migration>> for DuckDBConnection<T> {
fn query(&mut self, query: &str) -> Result<Vec<Migration>, Self::Error> {
let mut stmt = self.0.prepare(query)?;
let applied: Vec<Migration> = stmt
.query_map([], |row| {
let version = row.get(0)?;
let name: String = row.get(1)?;
let applied_on: DateTime<Utc> = row.get(2)?;
let checksum: u64 = row.get(3)?;
let applied_on: SystemTime = applied_on.into();
Ok(Migration::applied(
version,
name,
applied_on.into(),
checksum,
))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(applied)
}
}
impl<T: DerefMut<Target = duckdb::Connection>> Migrate for DuckDBConnection<T> {
fn assert_migrations_table(
&mut self,
migration_table_name: &str,
) -> std::result::Result<usize, refinery::Error> {
let query = format!(
"CREATE TABLE IF NOT EXISTS {migration_table_name} (
version INT primary key,
name TEXT not null,
applied_on TIMESTAMP not null,
checksum TEXT not null
)"
);
self.execute(std::iter::once(query.as_str()))
.migration_err("failed to create or verify DuckDB migrations table", None)?;
Ok(0)
}
}
+88 -135
View File
@@ -20,124 +20,55 @@ use crate::modules::common::AddrVec;
use crate::modules::envelope::utils::normalize_subject; use crate::modules::envelope::utils::normalize_subject;
use crate::modules::error::code::ErrorCode; use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult; use crate::modules::error::BichonResult;
use crate::modules::utils::create_hash; use crate::modules::message::content::AttachmentInfo;
use crate::modules::utils::create_hash2;
use crate::modules::utils::html::extract_text; use crate::modules::utils::html::extract_text;
use crate::{calculate_hash, raise_error, utc_now}; use crate::{calculate_hash, raise_error, utc_now};
use crate::{id, modules::indexer::envelope::Envelope}; use crate::{id, modules::indexer::envelope::Envelope};
use async_imap::types::Fetch; use async_imap::types::Fetch;
use mail_parser::{HeaderName, Message, MessageParser, MimeHeaders}; use mail_parser::{Address, HeaderName, Message, MessageParser, MimeHeaders};
pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> BichonResult<Envelope> { pub fn extract_envelope(
fetch: &Fetch,
account_id: u64,
mailbox_id: u64,
) -> BichonResult<(Envelope, Vec<AttachmentInfo>)> {
let internal_date = fetch let internal_date = fetch
.internal_date() .internal_date()
.map(|d| d.timestamp_millis()) .map(|d| d.timestamp_millis())
.unwrap_or(0); .unwrap_or(0);
let uid = fetch.uid.unwrap_or(0); let uid = fetch.uid.unwrap_or(0);
let body = fetch let body = fetch
.body() .body()
.ok_or_else(|| raise_error!("No body available".into(), ErrorCode::InternalError))?; .ok_or_else(|| raise_error!("No body available".into(), ErrorCode::InternalError))?;
let size = fetch.size.unwrap_or(body.len() as u32); let size = fetch.size.unwrap_or(body.len() as u32);
let message = MessageParser::new().parse(body).ok_or_else(|| {
raise_error!(
"Email header parse result is not available".into(),
ErrorCode::InternalError
)
})?;
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) { extract_envelope_core(body, uid, size, internal_date, account_id, mailbox_id)
text
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
extract_text(html)
} else {
String::new()
};
let message_id = message
.message_id()
.map(String::from)
.unwrap_or(generate_message_id());
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let mut subject = message.subject().map(String::from).unwrap_or_default();
if subject.contains('\u{FFFD}') {
subject = normalize_subject(message.header_raw(HeaderName::Subject));
}
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let cc: Option<Vec<String>> = message.cc().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let to: Option<Vec<String>> = message.to().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let from = message
.from()
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
.and_then(|add| add.address)
.unwrap_or_else(|| "unknown".to_string());
let attachments: Vec<String> = message
.attachments()
.filter(|att| {
let disp = att.content_disposition();
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false);
let has_filename = att.attachment_name().is_some();
has_filename && !is_inline
})
.filter_map(|att| att.attachment_name())
.map(|name| name.to_string())
.collect();
let envelope = Envelope {
id: create_hash(account_id, &message_id),
message_id,
account_id,
mailbox_id,
uid,
subject,
text,
from,
to: to.unwrap_or_default(),
cc: cc.unwrap_or_default(),
bcc: bcc.unwrap_or_default(),
date,
internal_date,
size,
thread_id,
attachments,
tags: None,
account_email: None,
mailbox_name: None,
};
Ok(envelope)
} }
pub fn extract_envelope_from_eml( pub fn extract_envelope_from_eml(
body: &[u8], body: &[u8],
account_id: u64, account_id: u64,
mailbox_id: u64, mailbox_id: u64,
) -> BichonResult<Envelope> { ) -> BichonResult<(Envelope, Vec<AttachmentInfo>)> {
let uid = 0; extract_envelope_core(body, 0, body.len() as u32, 0, account_id, mailbox_id).map(
let size = body.len() as u32; |(mut env, att)| {
if env.internal_date == 0 {
env.internal_date = env.date;
}
(env, att)
},
)
}
fn extract_envelope_core(
body: &[u8],
uid: u32,
size: u32,
internal_date: i64,
account_id: u64,
mailbox_id: u64,
) -> BichonResult<(Envelope, Vec<AttachmentInfo>)> {
let message = MessageParser::new().parse(body).ok_or_else(|| { let message = MessageParser::new().parse(body).ok_or_else(|| {
raise_error!( raise_error!(
"Email header parse result is not available".into(), "Email header parse result is not available".into(),
@@ -156,7 +87,8 @@ pub fn extract_envelope_from_eml(
let message_id = message let message_id = message
.message_id() .message_id()
.map(String::from) .map(String::from)
.unwrap_or(generate_message_id()); .unwrap_or_else(generate_message_id);
let in_reply_to = message.in_reply_to().as_text().map(String::from); let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message); let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id); let thread_id = compute_thread_id(in_reply_to, references, &message_id);
@@ -167,46 +99,66 @@ pub fn extract_envelope_from_eml(
} }
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0); let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
AddrVec::from(addr) let parse_addrs = |addrs: Option<&Address<'_>>| {
.0 addrs
.into_iter() .map(|addr| {
.filter_map(|a| a.address) AddrVec::from(addr)
.collect() .0
}); .into_iter()
let cc: Option<Vec<String>> = message.cc().map(|addr| { .filter_map(|a| a.address)
AddrVec::from(addr) .collect()
.0 })
.into_iter() .unwrap_or_default()
.filter_map(|a| a.address) };
.collect()
}); let bcc = parse_addrs(message.bcc());
let to: Option<Vec<String>> = message.to().map(|addr| { let cc = parse_addrs(message.cc());
AddrVec::from(addr) let to = parse_addrs(message.to());
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let from = message let from = message
.from() .from()
.and_then(|addr| AddrVec::from(addr).0.into_iter().next()) .and_then(|addr| AddrVec::from(addr).0.into_iter().next())
.and_then(|add| add.address) .and_then(|add| add.address)
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
let attachments: Vec<AttachmentInfo> = message
let attachments: Vec<String> = message
.attachments() .attachments()
.filter(|att| { .filter_map(|attachment| {
let disp = att.content_disposition(); let content_id = attachment.content_id().map(Into::into);
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false); let inline = attachment
let has_filename = att.attachment_name().is_some(); .content_disposition()
has_filename && !is_inline .map(|d| d.is_inline())
.unwrap_or(false);
if inline && content_id.is_some() {
return None;
}
let file_type = attachment
.content_type()
.map(|ct| {
format!(
"{}/{}",
ct.c_type.as_ref(),
ct.c_subtype.as_deref().unwrap_or("")
)
})
.unwrap_or_else(|| "application/octet-stream".to_string());
Some(AttachmentInfo {
filename: attachment
.attachment_name()
.map(|name| name.to_string())
.unwrap_or_default(),
size: attachment.contents().len(),
inline,
file_type,
content_id,
})
}) })
.filter_map(|att| att.attachment_name())
.map(|name| name.to_string())
.collect(); .collect();
let envelope = Envelope { let envelope = Envelope {
id: create_hash(account_id, &message_id), id: create_hash2(account_id, mailbox_id, &message_id),
message_id, message_id,
account_id, account_id,
mailbox_id, mailbox_id,
@@ -214,19 +166,20 @@ pub fn extract_envelope_from_eml(
subject, subject,
text, text,
from, from,
to: to.unwrap_or_default(), to,
cc: cc.unwrap_or_default(), cc,
bcc: bcc.unwrap_or_default(), bcc,
date, date,
internal_date: date, internal_date,
size, size,
thread_id, thread_id,
attachments, attachment_count: attachments.len(),
tags: None, tags: None,
account_email: None, account_email: None,
mailbox_name: None, mailbox_name: None,
}; };
Ok(envelope)
Ok((envelope, attachments))
} }
pub fn compute_thread_id( pub fn compute_thread_id(
+8 -4
View File
@@ -25,6 +25,7 @@ use crate::modules::error::code::ErrorCode;
use crate::modules::imap::session::SessionStream; use crate::modules::imap::session::SessionStream;
use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER}; use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
use crate::modules::indexer::schema::SchemaTools; use crate::modules::indexer::schema::SchemaTools;
use crate::modules::utils::create_hash;
use crate::modules::{error::BichonResult, imap::manager::ImapConnectionManager}; use crate::modules::{error::BichonResult, imap::manager::ImapConnectionManager};
use crate::raise_error; use crate::raise_error;
use async_imap::types::Name; use async_imap::types::Name;
@@ -207,13 +208,15 @@ impl ImapExecutor {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))? .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
{ {
let envelope = extract_envelope(&fetch, account_id, mailbox_id)?; let envelope = extract_envelope(&fetch, account_id, mailbox_id)?;
let eml_id = create_hash(account_id, &envelope.0.message_id);
ENVELOPE_INDEX_MANAGER ENVELOPE_INDEX_MANAGER
.add_document(envelope.id, envelope.to_document(mailbox_id)?) .add_document(envelope.0.id, envelope)
.await; .await;
let body = fetch.body().ok_or_else(|| { let body = fetch.body().ok_or_else(|| {
raise_error!("missing a body".into(), ErrorCode::ImapUnexpectedResult) raise_error!("missing a body".into(), ErrorCode::ImapUnexpectedResult)
})?; })?;
EML_INDEX_MANAGER.add_document( envelope.id, doc!(fields.f_id => envelope.id, fields.f_account_id => account_id, fields.f_mailbox_id => mailbox_id, fields.f_eml => body)).await; EML_INDEX_MANAGER.add_document( eml_id, doc!(fields.f_id => eml_id, fields.f_account_id => account_id, fields.f_mailbox_id => mailbox_id, fields.f_eml => body)).await;
count += 1; count += 1;
} }
Ok(count) Ok(count)
@@ -242,13 +245,14 @@ impl ImapExecutor {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))? .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
{ {
let envelope = extract_envelope(&fetch, account_id, mailbox_id)?; let envelope = extract_envelope(&fetch, account_id, mailbox_id)?;
let eml_id = create_hash(account_id, &envelope.0.message_id);
ENVELOPE_INDEX_MANAGER ENVELOPE_INDEX_MANAGER
.add_document(envelope.id, envelope.to_document(mailbox_id)?) .add_document(envelope.0.id, envelope)
.await; .await;
let body = fetch.body().ok_or_else(|| { let body = fetch.body().ok_or_else(|| {
raise_error!("missing a body".into(), ErrorCode::ImapUnexpectedResult) raise_error!("missing a body".into(), ErrorCode::ImapUnexpectedResult)
})?; })?;
EML_INDEX_MANAGER.add_document( envelope.id, doc!(fields.f_id => envelope.id, fields.f_account_id => account_id, fields.f_mailbox_id => mailbox_id, fields.f_eml => body)).await; EML_INDEX_MANAGER.add_document( eml_id, doc!(fields.f_id => eml_id, fields.f_account_id => account_id, fields.f_mailbox_id => mailbox_id, fields.f_eml => body)).await;
} }
Ok(()) Ok(())
} }
+1 -1
View File
@@ -94,7 +94,7 @@ impl ImapConnectionManager {
} }
pub async fn build(account_id: u64) -> BichonResult<Session<Box<dyn SessionStream>>> { pub async fn build(account_id: u64) -> BichonResult<Session<Box<dyn SessionStream>>> {
let account = AccountModel::get(account_id).await?; let account = AccountModel::async_get(account_id).await?;
let client = match Self::create_client(&account).await { let client = match Self::create_client(&account).await {
Ok(client) => client, Ok(client) => client,
Err(error) => { Err(error) => {
+24 -10
View File
@@ -16,13 +16,11 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use mail_parser::{parsers::MessageStream, HeaderName, MessageParser}; use mail_parser::{parsers::MessageStream, MessageParser, MimeHeaders};
use crate::{ use crate::{
base64_encode_url_safe, base64_encode_url_safe,
modules::{ modules::{account::entity::Encryption, imap::client::Client},
account::entity::Encryption, envelope::utils::normalize_subject, imap::client::Client,
},
}; };
#[tokio::test] #[tokio::test]
@@ -109,13 +107,29 @@ R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
#[tokio::test] #[tokio::test]
async fn test44() { async fn test44() {
let path = r"C:\Users\polly\Downloads\test222.eml"; let path = r"C:\Users\polly\Downloads\test333.eml";
let input = std::fs::read(path).unwrap(); let input = std::fs::read(path).unwrap();
let message = MessageParser::default().parse(&input).unwrap(); let message = MessageParser::default().parse(&input).unwrap();
let subject = message.subject().unwrap(); for attachment in message.attachments() {
println!("Subject: {}", subject); let content_type = attachment.content_type().unwrap();
if subject.contains('\u{FFFD}') { let filename = attachment
let subject = normalize_subject(message.header_raw(HeaderName::Subject)); .attachment_name()
println!("Subject: {}", subject); .map(|name| name.to_string())
.unwrap_or_else(|| "unknown".to_string());
let disposition = attachment.content_disposition();
let file_type = format!(
"{}/{}",
content_type.c_type.as_ref(),
content_type.c_subtype.as_deref().unwrap_or("")
);
let inline = disposition.map(|d| d.is_inline()).unwrap_or(false);
println!(
"filename: {}, file_type: {}, inline: {}",
filename, file_type, inline
);
} }
} }
+4 -4
View File
@@ -129,16 +129,16 @@ impl ImportEmls {
continue; continue;
} }
}; };
let eml_id = create_hash(account_id, &envelope.0.message_id);
ENVELOPE_INDEX_MANAGER ENVELOPE_INDEX_MANAGER
.add_document(envelope.id, envelope.to_document(mailbox_id).unwrap()) .add_document(envelope.0.id, envelope)
.await; .await;
EML_INDEX_MANAGER EML_INDEX_MANAGER
.add_document( .add_document(
envelope.id, eml_id,
doc!( doc!(
fields.f_id => envelope.id, fields.f_id => eml_id,
fields.f_account_id => account_id, fields.f_account_id => account_id,
fields.f_mailbox_id => mailbox_id, fields.f_mailbox_id => mailbox_id,
fields.f_eml => decoded fields.f_eml => decoded
+60 -181
View File
@@ -16,18 +16,11 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::HashSet; use duckdb::types::Value;
use crate::modules::account::migration::AccountModel;
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::error::code::ErrorCode;
use crate::modules::utils::create_hash;
use crate::modules::{error::BichonResult, indexer::schema::SchemaTools};
use crate::raise_error;
use poem_openapi::Object; use poem_openapi::Object;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tantivy::schema::Facet;
use tantivy::{doc, schema::Value, TantivyDocument}; use crate::modules::{account::migration::AccountModel, cache::imap::mailbox::MailBox};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct Envelope { pub struct Envelope {
@@ -48,186 +41,72 @@ pub struct Envelope {
pub internal_date: i64, pub internal_date: i64,
pub size: u32, pub size: u32,
pub thread_id: u64, pub thread_id: u64,
pub attachments: Vec<String>, pub attachment_count: usize,
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
} }
fn extract_u64_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
) -> BichonResult<u64> {
let value = document.get_first(field).ok_or_else(|| {
raise_error!(
format!("miss '{}' field in tantivy document", stringify!(field)),
ErrorCode::InternalError
)
})?;
value.as_u64().ok_or_else(|| {
raise_error!(
format!("'{}' field is not a u64", stringify!(field)),
ErrorCode::InternalError
)
})
}
fn extract_i64_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
) -> BichonResult<i64> {
let value = document.get_first(field).ok_or_else(|| {
raise_error!(
format!("miss '{}' field in tantivy document", stringify!(field)),
ErrorCode::InternalError
)
})?;
value.as_i64().ok_or_else(|| {
raise_error!(
format!("'{}' field is not a i64", stringify!(field)),
ErrorCode::InternalError
)
})
}
fn extract_string_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
) -> BichonResult<String> {
let value = document.get_first(field).ok_or_else(|| {
raise_error!(
format!("'{}' field not found", stringify!(field)),
ErrorCode::InternalError
)
})?;
value.as_str().map(|s| s.to_string()).ok_or_else(|| {
raise_error!(
format!("'{}' field is not a string", stringify!(field)),
ErrorCode::InternalError
)
})
}
fn extract_vec_string_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
) -> BichonResult<Vec<String>> {
let value = document
.get_all(field)
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
Ok(value)
}
impl Envelope { impl Envelope {
pub fn to_document(&self, mailbox_id: u64) -> BichonResult<TantivyDocument> { pub fn from_row(row: &duckdb::Row) -> duckdb::Result<Self> {
let fields = SchemaTools::envelope_fields(); let get_list = |col_name: &str| -> Vec<String> {
let mut doc = doc!(); row.get::<_, Value>(col_name)
doc.add_u64(fields.f_id, self.id); .map(|v| {
doc.add_text(fields.f_message_id, &self.message_id); if let Value::List(inner_list) = v {
doc.add_u64(fields.f_account_id, self.account_id); inner_list
doc.add_u64(fields.f_mailbox_id, mailbox_id); .into_iter()
doc.add_u64(fields.f_uid, self.uid as u64); .filter_map(|item| {
doc.add_text(fields.f_subject, &self.subject); if let Value::Text(s) = item {
doc.add_text(fields.f_text, &self.text); Some(s)
doc.add_text(fields.f_from, &self.from); } else {
for to in &self.to { None
doc.add_text(fields.f_to, to); }
} })
for cc in &self.cc { .collect()
doc.add_text(fields.f_cc, cc); } else {
} vec![]
for bcc in &self.bcc { }
doc.add_text(fields.f_bcc, bcc); })
} .unwrap_or_default()
doc.add_i64(fields.f_date, self.date); };
doc.add_i64(fields.f_internal_date, self.internal_date); let account_id = row.get("account_id")?;
doc.add_u64(fields.f_size, self.size as u64); let mailbox_id = row.get("mailbox_id")?;
doc.add_u64(fields.f_thread_id, self.thread_id); let email = match AccountModel::get(account_id) {
for att in &self.attachments { Ok(account) => account.email,
doc.add_text(fields.f_attachments, att); Err(_) => "unknown".to_string(),
}
doc.add_bool(fields.f_has_attachment, self.attachments.len() > 0);
Ok(doc)
}
pub async fn from_tantivy_doc(doc: &TantivyDocument) -> BichonResult<Self> {
let fields = SchemaTools::envelope_fields();
let account_id = extract_u64_field(doc, fields.f_account_id)?;
let message_id = extract_string_field(doc, fields.f_message_id)?;
let mailbox_id = extract_u64_field(doc, fields.f_mailbox_id)?;
let id = create_hash(account_id, &message_id);
let full_text = extract_string_field(doc, fields.f_text)?;
// Take up to the first 500 characters as a preview;
let preview = if full_text.chars().count() > 500 {
full_text.chars().take(500).collect::<String>() + "..."
} else {
full_text
}; };
let tags: Vec<String> = doc let mailbox_name = MailBox::find_mailbox(account_id, mailbox_id)
.get_all(fields.f_tags) .ok()
.filter_map(|value| value.as_facet()) .and_then(|m| m)
.map(|facet_encoded_str| { .map(|m| m.name)
Facet::from_encoded(facet_encoded_str.as_bytes().to_vec()) .unwrap_or_else(|| "unknown".to_string());
.ok()
.map(|facet| facet.to_string())
})
.flatten()
.collect();
let account_email = AccountModel::find(account_id).await?.map(|a| a.email);
let mailboxes = MailBox::list_all(account_id).await?; Ok(Self {
let mailbox_name = mailboxes id: row.get("id")?,
.iter() message_id: row.get("message_id").unwrap_or_default(),
.find(|m| m.id == mailbox_id)
.map(|m| m.name.clone());
let envelope = Envelope {
id,
account_id, account_id,
account_email, account_email: Some(email),
mailbox_id, mailbox_id,
mailbox_name, mailbox_name: Some(mailbox_name),
message_id: extract_string_field(doc, fields.f_message_id)?, uid: row.get::<_, u64>("uid")? as u32,
uid: extract_u64_field(doc, fields.f_uid)? as u32, subject: row.get("subject").unwrap_or_default(),
subject: extract_string_field(doc, fields.f_subject)?, text: row.get("body").unwrap_or_default(),
text: preview, from: row.get("sender").unwrap_or_default(),
from: extract_string_field(doc, fields.f_from)?, to: get_list("recipients"),
to: extract_vec_string_field(doc, fields.f_to)?, cc: get_list("cc"),
cc: extract_vec_string_field(doc, fields.f_cc)?, bcc: get_list("bcc"),
bcc: extract_vec_string_field(doc, fields.f_bcc)?, date: row.get("sent_at").unwrap_or(0),
date: extract_i64_field(doc, fields.f_date)?, internal_date: row.get("received_at").unwrap_or(0),
internal_date: extract_i64_field(doc, fields.f_internal_date)?, size: row.get::<_, u64>("size_bytes")? as u32,
size: extract_u64_field(doc, fields.f_size)? as u32, thread_id: row.get("thread_id").unwrap_or(0),
thread_id: extract_u64_field(doc, fields.f_thread_id)?, attachment_count: row.get::<_, i32>("attachment_count")? as usize,
attachments: extract_vec_string_field(doc, fields.f_attachments)?, tags: {
tags: Some(tags), let t = get_list("tags");
}; if t.is_empty() {
Ok(envelope) None
} } else {
} Some(t)
pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
let fields = SchemaTools::envelope_fields();
let mut all_contacts = HashSet::new();
if let Ok(from_val) = extract_string_field(doc, fields.f_from) {
if !from_val.is_empty() {
all_contacts.insert(from_val);
}
}
let multi_fields = [fields.f_to, fields.f_cc, fields.f_bcc];
for field in multi_fields {
if let Ok(vals) = extract_vec_string_field(doc, field) {
for v in vals {
if !v.is_empty() {
all_contacts.insert(v);
} }
} },
} })
} }
Ok(all_contacts)
} }
File diff suppressed because it is too large Load Diff
+84 -8
View File
@@ -16,11 +16,11 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::base64_encode; use crate::base64_encode;
use crate::modules::account::migration::AccountModel; use crate::modules::account::migration::AccountModel;
use crate::modules::error::code::ErrorCode; use crate::modules::error::code::ErrorCode;
use crate::modules::indexer::manager::EML_INDEX_MANAGER; use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
use crate::modules::utils::create_hash;
use crate::{modules::error::BichonResult, raise_error}; use crate::{modules::error::BichonResult, raise_error};
use mail_parser::{MessageParser, MimeHeaders}; use mail_parser::{MessageParser, MimeHeaders};
@@ -45,6 +45,72 @@ pub struct AttachmentInfo {
pub content_id: Option<String>, pub content_id: Option<String>,
} }
impl AttachmentInfo {
pub fn get_extension(&self) -> String {
std::path::Path::new(&self.filename)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_lowercase())
.unwrap_or_default()
}
pub fn get_category(&self) -> &'static str {
let ext = self.get_extension();
let category = match ext.as_str() {
"doc" | "docx" | "pdf" | "rtf" | "odt" | "pages" | "pptx" | "ppt" => Some("document"),
"xls" | "xlsx" | "ods" | "numbers" | "csv" => Some("spreadsheet"),
"ical" | "ics" | "vcs" | "ifb" | "icalendar" => Some("event"),
"txt" | "log" | "md" => Some("text"),
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "tiff" | "avif" | "heic" | "heif" | "webp" => {
Some("image")
}
"mp4" | "mkv" | "mov" | "avi" | "webm" => Some("video"),
"wav" | "mp3" | "aac" | "ogg" | "wma" | "flac" | "aiff" => Some("audio"),
"psd" | "eps" | "svg" | "cdr" | "ai" => Some("graphics_2d"),
"stl" | "obj" | "3mf" | "amf" | "f3d" | "sldprt" | "stp" | "step" | "dwg" | "x_t"
| "x_b" | "sat" | "ipt" => Some("graphics_3d"),
"c" | "h" | "html" | "css" | "js" | "ts" | "vue" | "tsx" | "svelte" | "py" | "java"
| "cs" | "go" | "rb" | "php" | "swift" | "rs" | "r" | "jl" | "lua" | "sql" => {
Some("code")
}
"tsv" | "xml" | "json" | "yml" | "yaml" | "toml" | "env" | "ini" => Some("data"),
"ps1" | "sh" | "bat" | "cmd" | "exe" | "msi" | "dmg" | "pkg" | "deb" | "rpm" => {
Some("executable")
}
"zip" | "gz" | "tgz" | "7z" | "rar" | "tar" | "bz2" | "zst" | "xz" | "iso" | "img" => {
Some("archive")
}
_ => None,
};
if let Some(cat) = category {
return cat;
}
let mime = self.file_type.to_lowercase();
if mime.starts_with("image/") {
return "image";
}
if mime.starts_with("video/") {
return "video";
}
if mime.starts_with("audio/") {
return "audio";
}
if mime.starts_with("text/") {
return "text";
}
if mime.contains("compressed") || mime.contains("zip") || mime.contains("archive") {
return "archive";
}
if mime.contains("pdf") || mime.contains("msword") || mime.contains("officedocument") {
return "document";
}
"other"
}
}
/// Represents the content of an email message in both plain text and HTML formats. /// Represents the content of an email message in both plain text and HTML formats.
/// ///
/// This struct contains optional fields for plain text and HTML versions of /// This struct contains optional fields for plain text and HTML versions of
@@ -64,13 +130,23 @@ pub struct FullMessageContent {
pub attachments: Option<Vec<AttachmentInfo>>, pub attachments: Option<Vec<AttachmentInfo>>,
} }
pub async fn retrieve_email_content( pub async fn retrieve_email_content(account_id: u64, id: u64) -> BichonResult<FullMessageContent> {
account_id: u64,
id: u64,
) -> BichonResult<FullMessageContent> {
AccountModel::check_account_exists(account_id).await?; AccountModel::check_account_exists(account_id).await?;
let envelope = ENVELOPE_INDEX_MANAGER
.get_envelope_by_id(account_id, id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Email record not found: account_id={} id={}",
account_id, id
),
ErrorCode::ResourceNotFound
)
})?;
let eml_id = create_hash(account_id, &envelope.message_id);
let eml = EML_INDEX_MANAGER let eml = EML_INDEX_MANAGER
.get(account_id, id) .get(account_id, eml_id)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
raise_error!( raise_error!(
@@ -133,7 +209,7 @@ pub async fn retrieve_email_content(
attachments.push(AttachmentInfo { attachments.push(AttachmentInfo {
filename, filename,
size: attachment.len(), size: attachment.contents().len(),
inline, inline,
file_type, file_type,
content_id: attachment.content_id().map(Into::into), content_id: attachment.content_id().map(Into::into),
+1 -2
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::BichonResult; use crate::modules::error::BichonResult;
use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER}; use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
use std::collections::HashMap; use std::collections::HashMap;
@@ -26,6 +25,6 @@ pub async fn delete_messages_impl(request: HashMap<u64, Vec<u64>>) -> BichonResu
.delete_email_multi_account(&request) .delete_email_multi_account(&request)
.await?; .await?;
ENVELOPE_INDEX_MANAGER ENVELOPE_INDEX_MANAGER
.delete_envelopes_multi_account(&request) .delete_envelopes_multi_account(request)
.await .await
} }
+3 -3
View File
@@ -39,14 +39,14 @@ pub struct SearchFilter {
pub bcc: Option<String>, pub bcc: Option<String>,
pub since: Option<i64>, pub since: Option<i64>,
pub before: Option<i64>, pub before: Option<i64>,
pub account_ids: Option<Vec<u64>>, pub account_ids: Option<HashSet<u64>>,
pub mailbox_ids: Option<Vec<u64>>, pub mailbox_ids: Option<HashSet<u64>>,
pub min_size: Option<u64>, pub min_size: Option<u64>,
pub max_size: Option<u64>, pub max_size: Option<u64>,
pub message_id: Option<String>, pub message_id: Option<String>,
pub has_attachment: Option<bool>, pub has_attachment: Option<bool>,
pub attachment_name: Option<String>, pub attachment_name: Option<String>,
pub tags: Option<Vec<String>>, pub tags: Option<HashSet<String>>,
} }
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Enum)] #[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
+1
View File
@@ -24,6 +24,7 @@ pub mod common;
pub mod context; pub mod context;
pub mod dashboard; pub mod dashboard;
pub mod database; pub mod database;
pub mod duckdb;
pub mod envelope; pub mod envelope;
pub mod error; pub mod error;
pub mod imap; pub mod imap;
+2 -2
View File
@@ -21,7 +21,7 @@ use crate::{
modules::{ modules::{
database::{ database::{
delete_impl, insert_impl, manager::DB_MANAGER, paginate_query_primary_scan_all_impl, delete_impl, insert_impl, manager::DB_MANAGER, paginate_query_primary_scan_all_impl,
secondary_find_impl, update_impl, async_secondary_find_impl, update_impl,
}, },
error::{code::ErrorCode, BichonResult}, error::{code::ErrorCode, BichonResult},
rest::response::DataPage, rest::response::DataPage,
@@ -133,7 +133,7 @@ impl OAuth2 {
} }
pub async fn get(id: u64) -> BichonResult<Option<OAuth2>> { pub async fn get(id: u64) -> BichonResult<Option<OAuth2>> {
secondary_find_impl(DB_MANAGER.meta_db(), OAuth2Key::id, id).await async_secondary_find_impl(DB_MANAGER.meta_db(), OAuth2Key::id, id).await
} }
pub async fn delete(id: u64) -> BichonResult<()> { pub async fn delete(id: u64) -> BichonResult<()> {
+1 -1
View File
@@ -58,7 +58,7 @@ impl AccountApi {
context context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS) .require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?; .await?;
Ok(Json(AccountModel::get(account_id).await?)) Ok(Json(AccountModel::async_get(account_id).await?))
} }
/// Delete an account by ID - WARNING: This permanently removes the account and all associated resources /// Delete an account by ID - WARNING: This permanently removes the account and all associated resources
+11 -11
View File
@@ -34,6 +34,7 @@ use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult; use crate::modules::rest::ApiResult;
use crate::modules::rest::ErrorCode; use crate::modules::rest::ErrorCode;
use crate::modules::users::permissions::Permission; use crate::modules::users::permissions::Permission;
use crate::modules::utils::validate_tag;
use crate::raise_error; use crate::raise_error;
use poem::Body; use poem::Body;
use poem_openapi::param::{Path, Query}; use poem_openapi::param::{Path, Query};
@@ -41,7 +42,6 @@ use poem_openapi::payload::{Attachment, AttachmentType, Json};
use poem_openapi::OpenApi; use poem_openapi::OpenApi;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use tantivy::schema::Facet;
pub struct MessageApi; pub struct MessageApi;
@@ -147,7 +147,7 @@ impl MessageApi {
/// Fetches the content of a specific email. /// Fetches the content of a specific email.
#[oai( #[oai(
path = "/message-content/:account_id/:message_id", path = "/message-content/:account_id/:envelope_id",
method = "get", method = "get",
operation_id = "fetch_message_content" operation_id = "fetch_message_content"
)] )]
@@ -156,7 +156,7 @@ impl MessageApi {
/// The ID of the account. /// The ID of the account.
account_id: Path<u64>, account_id: Path<u64>,
/// The ID of the message to fetch. /// The ID of the message to fetch.
message_id: Path<u64>, envelope_id: Path<u64>,
context: ClientContext, context: ClientContext,
) -> ApiResult<Json<FullMessageContent>> { ) -> ApiResult<Json<FullMessageContent>> {
let account_id = account_id.0; let account_id = account_id.0;
@@ -164,13 +164,13 @@ impl MessageApi {
.require_permission(Some(account_id), Permission::DATA_READ) .require_permission(Some(account_id), Permission::DATA_READ)
.await?; .await?;
Ok(Json( Ok(Json(
retrieve_email_content(account_id, message_id.0).await?, retrieve_email_content(account_id, envelope_id.0).await?,
)) ))
} }
/// Retrieves the envelope (metadata) of a specific message. /// Retrieves the envelope (metadata) of a specific message.
#[oai( #[oai(
path = "/envelope/:account_id/:message_id", path = "/envelope/:account_id/:envelope_id",
method = "get", method = "get",
operation_id = "get_envelope" operation_id = "get_envelope"
)] )]
@@ -179,7 +179,7 @@ impl MessageApi {
/// The ID of the account. /// The ID of the account.
account_id: Path<u64>, account_id: Path<u64>,
/// The ID of the message. /// The ID of the message.
message_id: Path<u64>, envelope_id: Path<u64>,
context: ClientContext, context: ClientContext,
) -> ApiResult<Json<Envelope>> { ) -> ApiResult<Json<Envelope>> {
let account_id = account_id.0; let account_id = account_id.0;
@@ -187,13 +187,13 @@ impl MessageApi {
.require_permission(Some(account_id), Permission::DATA_READ) .require_permission(Some(account_id), Permission::DATA_READ)
.await?; .await?;
let envelope = ENVELOPE_INDEX_MANAGER let envelope = ENVELOPE_INDEX_MANAGER
.get_envelope_by_id(account_id, message_id.0) .get_envelope_by_id(account_id, envelope_id.0)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
raise_error!( raise_error!(
format!( format!(
"Envelope not found: account_id={} message_id={}", "Envelope not found: account_id={} envelope_id={}",
account_id, message_id.0 account_id, envelope_id.0
), ),
ErrorCode::ResourceNotFound ErrorCode::ResourceNotFound
) )
@@ -308,8 +308,8 @@ impl MessageApi {
) -> ApiResult<()> { ) -> ApiResult<()> {
let req = req.0; let req = req.0;
for tag in &req.tags { for tag in &req.tags {
Facet::from_text(tag) validate_tag(tag)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?; .map_err(|e| raise_error!(format!("{}", e), ErrorCode::InvalidParameter))?;
} }
for account_id in req.updates.keys() { for account_id in req.updates.keys() {
+15
View File
@@ -247,6 +247,21 @@ pub struct Settings {
value_parser = clap::value_parser!(u16).range(1..) value_parser = clap::value_parser!(u16).range(1..)
)] )]
pub bichon_sync_concurrency: Option<u16>, pub bichon_sync_concurrency: Option<u16>,
#[clap(
long,
env,
help = "Number of DuckDB execution threads (default: auto)",
value_parser = clap::value_parser!(u16).range(1..)
)]
pub bichon_duckdb_threads: Option<i64>,
#[clap(
long,
env,
help = "Maximum memory DuckDB may use (e.g. 512MB, 2GB, 80%)"
)]
pub bichon_duckdb_max_memory: Option<String>,
} }
impl Settings { impl Settings {
+3 -3
View File
@@ -21,7 +21,7 @@ use std::collections::HashMap;
use super::error::code::ErrorCode; use super::error::code::ErrorCode;
use crate::modules::database::manager::DB_MANAGER; use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{ use crate::modules::database::{
async_find_impl, delete_impl, filter_by_secondary_key_impl, with_transaction, async_find_impl, delete_impl, async_filter_by_secondary_key_impl, with_transaction,
}; };
use crate::modules::database::{insert_impl, list_all_impl, update_impl}; use crate::modules::database::{insert_impl, list_all_impl, update_impl};
use crate::modules::settings::cli::SETTINGS; use crate::modules::settings::cli::SETTINGS;
@@ -154,7 +154,7 @@ impl AccessTokenModel {
} }
pub async fn get_user_webui_token(user_id: u64) -> BichonResult<Option<AccessTokenModel>> { pub async fn get_user_webui_token(user_id: u64) -> BichonResult<Option<AccessTokenModel>> {
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>( let tokens = async_filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(), DB_MANAGER.meta_db(),
AccessTokenModelKey::user_id, AccessTokenModelKey::user_id,
user_id, user_id,
@@ -167,7 +167,7 @@ impl AccessTokenModel {
} }
pub async fn get_user_api_tokens(user_id: u64) -> BichonResult<Vec<AccessTokenModel>> { pub async fn get_user_api_tokens(user_id: u64) -> BichonResult<Vec<AccessTokenModel>> {
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>( let tokens = async_filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(), DB_MANAGER.meta_db(),
AccessTokenModelKey::user_id, AccessTokenModelKey::user_id,
user_id, user_id,
+7 -7
View File
@@ -21,7 +21,7 @@ use crate::{
modules::{ modules::{
database::{ database::{
async_find_impl, batch_delete_impl, delete_impl, list_all_impl, manager::DB_MANAGER, async_find_impl, batch_delete_impl, delete_impl, list_all_impl, manager::DB_MANAGER,
secondary_find_impl, update_impl, with_transaction, async_secondary_find_impl, update_impl, with_transaction,
}, },
error::{code::ErrorCode, BichonResult}, error::{code::ErrorCode, BichonResult},
token::{AccessTokenModel, AccessTokenModelKey, TokenType}, token::{AccessTokenModel, AccessTokenModelKey, TokenType},
@@ -282,7 +282,7 @@ impl BichonUserV2 {
username: String, username: String,
password: String, password: String,
) -> BichonResult<LoginResult> { ) -> BichonResult<LoginResult> {
let user_option = secondary_find_impl::<UserModel>( let user_option = async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(), DB_MANAGER.meta_db(),
BichonUserV2Key::username, BichonUserV2Key::username,
username.clone(), username.clone(),
@@ -292,7 +292,7 @@ impl BichonUserV2 {
let user = match user_option { let user = match user_option {
Some(u) => u, Some(u) => u,
None => { None => {
match secondary_find_impl::<UserModel>( match async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(), DB_MANAGER.meta_db(),
BichonUserV2Key::email, BichonUserV2Key::email,
username, username,
@@ -366,7 +366,7 @@ impl BichonUserV2 {
pub async fn check_username_conflict(username: &str) -> BichonResult<()> { pub async fn check_username_conflict(username: &str) -> BichonResult<()> {
// Check username duplicate // Check username duplicate
if secondary_find_impl::<UserModel>( if async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(), DB_MANAGER.meta_db(),
BichonUserV2Key::username, BichonUserV2Key::username,
username.to_string(), username.to_string(),
@@ -385,7 +385,7 @@ impl BichonUserV2 {
pub async fn check_email_conflict(email: &str) -> BichonResult<()> { pub async fn check_email_conflict(email: &str) -> BichonResult<()> {
// Check email duplicate // Check email duplicate
if secondary_find_impl::<UserModel>( if async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(), DB_MANAGER.meta_db(),
BichonUserV2Key::email, BichonUserV2Key::email,
email.to_string(), email.to_string(),
@@ -520,7 +520,7 @@ impl BichonUserV2 {
} }
if let Some(username) = &request.username { if let Some(username) = &request.username {
let user_option = secondary_find_impl::<UserModel>( let user_option = async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(), DB_MANAGER.meta_db(),
BichonUserV2Key::username, BichonUserV2Key::username,
username.to_string(), username.to_string(),
@@ -538,7 +538,7 @@ impl BichonUserV2 {
} }
if let Some(email) = &request.email { if let Some(email) = &request.email {
let user_option = secondary_find_impl::<UserModel>( let user_option = async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(), DB_MANAGER.meta_db(),
BichonUserV2Key::email, BichonUserV2Key::email,
email.to_string(), email.to_string(),
+2 -2
View File
@@ -284,7 +284,7 @@ impl UserCreateRequest {
} }
for (aid, rid) in &self.account_access_map { for (aid, rid) in &self.account_access_map {
if AccountModel::find(*aid).await?.is_none() { if AccountModel::async_find(*aid).await?.is_none() {
return Err(raise_error!( return Err(raise_error!(
format!("Account {} not found", aid), format!("Account {} not found", aid),
ErrorCode::InvalidParameter ErrorCode::InvalidParameter
@@ -404,7 +404,7 @@ impl UserUpdateRequest {
if let Some(account_access_map) = &self.account_access_map { if let Some(account_access_map) = &self.account_access_map {
for (aid, rid) in account_access_map { for (aid, rid) in account_access_map {
if AccountModel::find(*aid).await?.is_none() { if AccountModel::async_find(*aid).await?.is_none() {
return Err(raise_error!( return Err(raise_error!(
format!("Account {} not found", aid), format!("Account {} not found", aid),
ErrorCode::InvalidParameter ErrorCode::InvalidParameter
+40 -6
View File
@@ -21,7 +21,7 @@ use std::{fs, io, path::PathBuf};
use crate::modules::error::BichonResult; use crate::modules::error::BichonResult;
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use base64::{engine::general_purpose, Engine}; use base64::{engine::general_purpose, Engine};
use rand::{rng, Rng}; use rand::{rng, RngExt};
use super::error::code::ErrorCode; use super::error::code::ErrorCode;
@@ -277,14 +277,23 @@ pub fn hash(s: &str) -> u64 {
} }
pub fn create_hash(account_id: u64, field: &str) -> u64 { pub fn create_hash(account_id: u64, field: &str) -> u64 {
// Construct a buffer of bytes from account_id and mailbox_name
let mut buffer = Vec::new(); let mut buffer = Vec::new();
buffer.extend_from_slice(&account_id.to_le_bytes()); // Convert u64 to bytes buffer.extend_from_slice(&account_id.to_le_bytes());
buffer.push(b':'); // Separator buffer.push(b':'); // Separator
buffer.extend_from_slice(field.as_bytes()); // Add mailbox name buffer.extend_from_slice(field.as_bytes());
// Create a Cursor for the buffer let mut cursor = std::io::Cursor::new(buffer);
let hash = murmur3::murmur3_x64_128(&mut cursor, 0).unwrap();
(hash & 0x1F_FFFF_FFFF_FFFF) as u64
}
pub fn create_hash2(account_id: u64, field1: u64, field2: &str) -> u64 {
let mut buffer = Vec::new();
buffer.extend_from_slice(&account_id.to_le_bytes());
buffer.push(b':'); // Separator
buffer.extend_from_slice(&field1.to_le_bytes());
buffer.push(b':'); // Separator
buffer.extend_from_slice(field2.as_bytes());
let mut cursor = std::io::Cursor::new(buffer); let mut cursor = std::io::Cursor::new(buffer);
// Compute the 128-bit Murmur3 hash and cast to u64
let hash = murmur3::murmur3_x64_128(&mut cursor, 0).unwrap(); let hash = murmur3::murmur3_x64_128(&mut cursor, 0).unwrap();
(hash & 0x1F_FFFF_FFFF_FFFF) as u64 (hash & 0x1F_FFFF_FFFF_FFFF) as u64
} }
@@ -335,3 +344,28 @@ pub fn decode_avatar_bytes(base64_str: &str) -> BichonResult<Vec<u8>> {
Ok(bytes) Ok(bytes)
} }
pub fn validate_tag(tag: &str) -> Result<(), String> {
if tag.is_empty() {
return Err("Tag cannot be empty".to_string());
}
const INVALID: &[char] = &[
'\'', '"', '`', ';', ',', '(', ')', '[', ']', '{', '}', '<', '>',
];
let mut found = Vec::new();
for c in tag.chars() {
if INVALID.contains(&c) && !found.contains(&c) {
found.push(c);
}
}
if !found.is_empty() {
let chars: String = found.iter().collect();
return Err(format!("Tag contains invalid characters: {}", chars));
}
Ok(())
}
+2 -9
View File
@@ -38,13 +38,6 @@ pub(crate) async fn establish_tcp_connection_with_timeout(
) -> BichonResult<Pin<Box<TimeoutStream<TcpStream>>>> { ) -> BichonResult<Pin<Box<TimeoutStream<TcpStream>>>> {
// Establish the TCP connection with a timeout // Establish the TCP connection with a timeout
let tcp_stream = connect_with_optional_proxy(use_proxy, address).await?; let tcp_stream = connect_with_optional_proxy(use_proxy, address).await?;
// // Disable Nagle's algorithm for more efficient network communication
// tcp_stream
// .set_nodelay(true)
// .map_err(|e| raise_error!(e.to_string(), ErrorCode::NetworkError))?;
// Wrap the TCP stream in a TimeoutStream for timeout management
let mut timeout_stream = TimeoutStream::new(tcp_stream); let mut timeout_stream = TimeoutStream::new(tcp_stream);
// Set read and write timeouts // Set read and write timeouts
@@ -140,7 +133,7 @@ async fn connect_with_optional_proxy(
) )
})? })?
.map(|s| s.into_inner()) .map(|s| s.into_inner())
.map_err(|e| raise_error!(e.to_string(), ErrorCode::NetworkError)); .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::NetworkError));
} }
// Fallback to direct TCP connection // Fallback to direct TCP connection
timeout(TIMEOUT, TcpStream::connect(address)) timeout(TIMEOUT, TcpStream::connect(address))
@@ -160,5 +153,5 @@ async fn connect_with_optional_proxy(
ErrorCode::ConnectionTimeout ErrorCode::ConnectionTimeout
) )
})? })?
.map_err(|e| raise_error!(e.to_string(), ErrorCode::NetworkError)) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::NetworkError))
} }
+1 -1
View File
@@ -77,7 +77,7 @@ async fn establish_rustls_stream(
let tls_stream = tls_connector let tls_stream = tls_connector
.connect(server_name, stream) .connect(server_name, stream)
.await .await
.map_err(|e| raise_error!(e.to_string(), ErrorCode::NetworkError))?; .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::NetworkError))?;
Ok(tls_stream) Ok(tls_stream)
} }
+1 -1
View File
@@ -43,6 +43,6 @@ export interface EmailEnvelope {
internal_date: number; internal_date: number;
size: number; size: number;
thread_id: number, thread_id: number,
attachments: string[]; attachment_count: number;
tags: string[]; tags: string[];
} }
@@ -23,7 +23,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { UserAuthForm } from './components/user-auth-form' import { UserAuthForm } from './user-auth-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { AuthLayout } from './auth-layout' import { AuthLayout } from './auth-layout'
@@ -1,63 +0,0 @@
//
// Copyright (c) 2025 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/>.
import { IconLogout } from '@tabler/icons-react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
handleConfirm: () => void
}
export function LogoutConfirmDialog({ open, onOpenChange, handleConfirm }: Props) {
const { t } = useTranslation()
const handleLogout = () => {
handleConfirm();
onOpenChange(false);
};
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
handleConfirm={handleLogout}
className="max-w-md"
title={
<span className='text-destructive'>
<IconLogout
className='mr-1 inline-block stroke-destructive'
size={18}
/>{' '}
{t('auth.logout')}
</span>
}
desc={
<p>
{t('auth.areYouSureYouWantToLogOut')}
<br />
{t('auth.youWillNeedToLogInAgain')}
</p>
}
confirmText={t('auth.logout')}
cancelBtnText={t('common.cancel')}
/>
)
}
+3
View File
@@ -29,6 +29,7 @@ import { get_dashboard_stats, TimeBucket } from '@/api/system/api';
import { Main } from '@/components/layout/main'; import { Main } from '@/components/layout/main';
import { FixedHeader } from '@/components/layout/fixed-header'; import { FixedHeader } from '@/components/layout/fixed-header';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getToken } from '@/stores/authStore';
interface DailyActivity { interface DailyActivity {
date: string; date: string;
@@ -102,8 +103,10 @@ const EmptyTable = ({ title }: { title: string }) => (
); );
export default function MailArchiveDashboard() { export default function MailArchiveDashboard() {
const token = getToken();
const { data: stats, isLoading } = useQuery({ const { data: stats, isLoading } = useQuery({
queryKey: ['dashboard-stats'], queryKey: ['dashboard-stats'],
enabled: !!token,
queryFn: get_dashboard_stats, queryFn: get_dashboard_stats,
}); });
@@ -118,7 +118,7 @@ export function MailList({
)} )}
{items.map((item, index) => { {items.map((item, index) => {
const hasAttachments = item.attachments && item.attachments.length > 0 const hasAttachments = item.attachment_count > 0
const isSelected = currentEnvelope?.id === item.id const isSelected = currentEnvelope?.id === item.id
const isChecked = hasSelected(item.id) const isChecked = hasSelected(item.id)
return ( return (
@@ -171,7 +171,7 @@ export function MailList({
{hasAttachments && ( {hasAttachments && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Paperclip className="h-3 w-3" /> <Paperclip className="h-3 w-3" />
<span>{item.attachments?.length}</span> <span>{item.attachment_count}</span>
</div> </div>
)} )}
<span className="hidden md:inline">{formatBytes(item.size)}</span> <span className="hidden md:inline">{formatBytes(item.size)}</span>
+1 -1
View File
@@ -207,7 +207,7 @@ export function MailListTable({
{ {
id: "attachment_count", id: "attachment_count",
header: () => <Paperclip size={16} />, header: () => <Paperclip size={16} />,
cell: ({ row }) => <span className='text-xs'>{(row.original.attachments ?? []).length}</span>, cell: ({ row }) => <span className='text-xs'>{row.original.attachment_count}</span>,
meta: { className: 'text-left text-xs' }, meta: { className: 'text-left text-xs' },
minSize: 40, minSize: 40,
maxSize: 40 maxSize: 40
+2 -2
View File
@@ -154,7 +154,7 @@ export function MailList({
)} )}
{items.map((item, index) => { {items.map((item, index) => {
const hasAttachments = item.attachments && item.attachments.length > 0 const hasAttachments = item.attachment_count > 0
const isSelectedRow = currentEnvelope?.id === item.id const isSelectedRow = currentEnvelope?.id === item.id
const isChecked = hasSelected(item.account_id, item.id) const isChecked = hasSelected(item.account_id, item.id)
@@ -209,7 +209,7 @@ export function MailList({
{hasAttachments && ( {hasAttachments && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Paperclip className="h-3 w-3" /> <Paperclip className="h-3 w-3" />
<span>{item.attachments?.length}</span> <span>{item.attachment_count}</span>
</div> </div>
)} )}
+4
View File
@@ -21,10 +21,14 @@ import { useQuery } from '@tanstack/react-query'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import { get_current_user, User } from '@/api/users/api' import { get_current_user, User } from '@/api/users/api'
import { useMemo } from 'react' import { useMemo } from 'react'
import { getToken } from '@/stores/authStore'
export function useCurrentUser() { export function useCurrentUser() {
const token = getToken();
const query = useQuery<User | null, AxiosError>({ const query = useQuery<User | null, AxiosError>({
queryKey: ['current-user'], queryKey: ['current-user'],
enabled: !!token,
queryFn: get_current_user, queryFn: get_current_user,
retry: false, retry: false,
}) })
+4 -20
View File
@@ -88,28 +88,12 @@ export function validateTag(facetPath: string) {
}; };
} }
if (!facetPath.startsWith('/')) { const invalidChars = /['"`;,()[\]{}<>]/;
if (invalidChars.test(facetPath)) {
return { return {
valid: false, valid: false,
error: "Tag path must start with '/'" error: "Tag path contains invalid characters"
};
}
let escaped = false;
for (let i = 1; i < facetPath.length; i++) {
const char = facetPath[i];
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
}
}
if (escaped) {
return {
valid: false,
error: "Tag path has unmatched escape character at the end"
}; };
} }
+1 -1
View File
@@ -18,7 +18,7 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import SignIn from '@/features/auth/sign-in' import SignIn from '@/features/auth'
export const Route = createFileRoute('/(auth)/sign-in')({ export const Route = createFileRoute('/(auth)/sign-in')({
component: SignIn, component: SignIn,