refactor: replace native_db with memdb and add tests

This commit is contained in:
rustmailer
2026-05-14 02:29:23 +08:00
parent 0abaa66a40
commit 5406c4322c
102 changed files with 7225 additions and 2735 deletions

164
Cargo.lock generated
View File

@@ -308,7 +308,7 @@ dependencies = [
"imap-proto",
"log",
"nom 7.1.3",
"pin-project",
"pin-project 1.1.11",
"pin-utils",
"self_cell",
"stop-token",
@@ -465,6 +465,13 @@ dependencies = [
"console",
"dialoguer",
"indicatif",
"itertools",
"memdb",
"native_db",
"native_model",
"serde",
"serde_json",
"snafu",
"tokio",
]
@@ -518,9 +525,8 @@ dependencies = [
"lru 0.18.0",
"mail-parser",
"mail-send",
"memdb",
"murmur3",
"native_db",
"native_model",
"num_cpus",
"oauth2",
"poem-openapi",
@@ -570,6 +576,7 @@ dependencies = [
"serde",
"serde_json",
"snafu",
"tempfile",
"timeago",
"tokio",
"tracing",
@@ -605,6 +612,15 @@ dependencies = [
"serde",
]
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -1378,6 +1394,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0"
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"objc2",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -1794,6 +1820,18 @@ dependencies = [
"slab",
]
[[package]]
name = "futures_codec"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce54d63f8b0c75023ed920d46fd71d0cbbb830b0ee012726b5b4f506fb6dea5b"
dependencies = [
"bytes 0.5.6",
"futures",
"memchr",
"pin-project 0.4.30",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1911,7 +1949,7 @@ dependencies = [
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.4",
"rand 0.9.2",
"smallvec",
"spinning_top 0.3.0",
"web-time",
@@ -2052,7 +2090,7 @@ dependencies = [
"idna 1.1.0",
"ipnet",
"once_cell",
"rand 0.9.4",
"rand 0.9.2",
"ring",
"rustls",
"rustls-pki-types",
@@ -2078,7 +2116,7 @@ dependencies = [
"moka",
"once_cell",
"parking_lot",
"rand 0.9.4",
"rand 0.9.2",
"resolv-conf",
"rustls",
"smallvec",
@@ -2909,7 +2947,7 @@ dependencies = [
"mail-auth",
"mail-builder",
"md5",
"rand 0.9.4",
"rand 0.9.2",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
@@ -2965,6 +3003,18 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memdb"
version = "0.1.0"
dependencies = [
"rand 0.9.2",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "memmap2"
version = "0.9.10"
@@ -3277,6 +3327,15 @@ dependencies = [
"url",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@@ -3284,6 +3343,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"objc2",
]
[[package]]
@@ -3296,6 +3373,17 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-open-directory"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d"
dependencies = [
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "oem_cp"
version = "1.3.0"
@@ -3523,13 +3611,33 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ef0f924a5ee7ea9cbcea77529dba45f8a9ba9f622419fe3386ca581a3ae9d5a"
dependencies = [
"pin-project-internal 0.4.30",
]
[[package]]
name = "pin-project"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
dependencies = [
"pin-project-internal",
"pin-project-internal 1.1.11",
]
[[package]]
name = "pin-project-internal"
version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "851c8d0ce9bebe43790dedfc86614c23494ac9f423dd618d3a61fc693eafe61e"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
@@ -3605,6 +3713,7 @@ dependencies = [
"serde_urlencoded",
"serde_yaml",
"smallvec",
"sse-codec",
"sync_wrapper",
"tempfile",
"thiserror 2.0.18",
@@ -3852,7 +3961,7 @@ dependencies = [
"bytes 1.11.1",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -3931,9 +4040,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.4"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
@@ -4053,9 +4162,9 @@ dependencies = [
[[package]]
name = "rcgen"
version = "0.14.7"
version = "0.14.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e"
checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
dependencies = [
"pem",
"ring",
@@ -4800,6 +4909,18 @@ dependencies = [
"lock_api",
]
[[package]]
name = "sse-codec"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84a59f811350c44b4a037aabeb72dc6a9591fc22aa95a036db9a96297c58085a"
dependencies = [
"bytes 0.5.6",
"futures-io",
"futures_codec",
"memchr",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -4985,15 +5106,16 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.38.4"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f"
checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"objc2-open-directory",
"windows",
]
@@ -5335,9 +5457,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.1"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes 1.11.1",
"libc",
@@ -5412,6 +5534,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes 1.11.1",
"futures-core",
"futures-io",
"futures-sink",
"pin-project-lite",
"tokio",
@@ -5574,7 +5697,7 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
dependencies = [
"pin-project",
"pin-project 1.1.11",
"tracing",
]
@@ -6592,10 +6715,11 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[package]]
name = "yasna"
version = "0.5.2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec",
"time 0.3.47",
]

View File

@@ -1,6 +1,12 @@
[workspace]
members = ["crates/core", "crates/server", "crates/cli", "crates/admin"]
members = [
"crates/memdb",
"crates/core",
"crates/server",
"crates/cli",
"crates/admin",
]
resolver = "2"
@@ -12,13 +18,12 @@ edition = "2021"
chrono = "0.4.44"
clap = { version = "4.6.1", features = ["derive", "env"] }
mimalloc = "0.1.50"
native_db = "0.8.2"
memdb = { path = "crates/memdb" }
itertools = "0.14.0"
native_model = "0.4.20"
ring = { version = "0.17.14", features = ["std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tokio = { version = "1.52.1", features = ["full"] }
tokio = { version = "1.52.3", features = ["full"] }
tracing = "0.1.44"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
@@ -47,7 +52,7 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [
timeago = "0.6.0"
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
url = { version = "2.5.8", features = ["serde"] }
sysinfo = "0.38.4"
sysinfo = "0.39.1"
num_cpus = "1.17.0"
rand = "0.10.1"
encoding_rs = "0.8.35"
@@ -77,7 +82,7 @@ bytes = "1.11.1"
dialoguer = "0.12.0"
console = "0.16.3"
mail-send = "0.6.0"
rcgen = "0.14.7"
rcgen = "0.14.8"
rustls-pemfile = "2.2.0"
blake3 = "1.8.5"
uuid = { version = "1.23.1", features = ["v4", "serde"] }

View File

@@ -10,3 +10,11 @@ tokio.workspace = true
dialoguer.workspace = true
console.workspace = true
indicatif.workspace = true
native_db = "0.8.2"
native_model = "0.4.20"
serde.workspace = true
serde_json.workspace = true
itertools.workspace = true
snafu.workspace = true
memdb.workspace = true

View File

@@ -21,6 +21,7 @@ use dialoguer::{theme::ColorfulTheme, Select};
use crate::{migrate::handle_migration, reset::handle_reset_password};
pub mod meta;
pub mod migrate;
pub mod reset;
@@ -34,7 +35,7 @@ async fn main() {
let main_options = vec![
"Reset Admin Password",
"Migrate Legacy v0.x Storage to v1.0",
"Migrate Legacy v0.3.7 Storage to v1.0.0",
"Exit",
];

910
crates/admin/src/meta.rs Normal file
View File

@@ -0,0 +1,910 @@
use std::{
collections::{BTreeMap, BTreeSet},
path::PathBuf,
sync::{Arc, LazyLock},
};
use bichon_core::{
account::{
entity::ImapConfig,
migration::{AccountModel, AccountType},
since::{DateSince, RelativeDate},
},
autoconfig::entity::MailServerConfig,
cache::imap::mailbox::Attribute,
database::batch_insert_impl,
error::{code::ErrorCode, BichonError, BichonResult},
raise_error,
token::TokenType,
users::{acl::AccessControl, role::RoleType},
};
use console::style;
use itertools::Itertools;
use memdb::{Durability, MemDb};
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000;
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[native_model(id = 3, version = 1)]
#[native_db]
pub struct CachedMailSettings {
#[primary_key]
pub domain: String,
pub config: MailServerConfig,
pub created_at: i64,
}
impl From<CachedMailSettings> for bichon_core::autoconfig::CachedMailSettings {
fn from(value: CachedMailSettings) -> Self {
Self {
domain: value.domain,
config: value.config,
created_at: value.created_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 4, version = 1)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV1 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub use_proxy: Option<u64>,
}
impl AccountV1 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 4, version = 2, from = AccountV1)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV2 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV2 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 4, version = 3, from = AccountV2)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV3 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
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>,
}
impl AccountV3 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
impl From<AccountV1> for AccountV2 {
fn from(value: AccountV1) -> 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,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: false,
pgp_key: None,
}
}
}
impl From<AccountV2> for AccountV1 {
fn from(value: AccountV2) -> 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,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
}
}
}
impl From<AccountV3> for AccountV2 {
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,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
}
}
}
impl From<AccountV2> for AccountV3 {
fn from(value: AccountV2) -> 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,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
created_by: DEFAULT_ADMIN_USER_ID,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
sync_batch_size: None,
date_before: None,
}
}
}
impl From<AccountV3> for AccountModel {
fn from(value: AccountV3) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
account_name: None,
login_name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
date_before: value.date_before,
folder_limit: value.folder_limit,
download_folders: value.sync_folders,
account_type: value.account_type,
download_interval_min: value.sync_interval_min,
download_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_quota_window: None,
imap_quota_bytes: None,
auto_download_new_mailboxes: None,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 5, version = 1)]
#[native_db(primary_key(pk -> String))]
pub struct OAuth2 {
/// A unique identifier for the OAuth2 configuration.
#[secondary_key(unique)]
pub id: u64,
/// A description of what this configuration is used for.
pub description: Option<String>,
/// The client ID used for authenticating the application with the OAuth2 provider.
pub client_id: String,
/// The client secret used in conjunction with the client ID.
///
/// Users should provide a plaintext secret.
/// The server will encrypt it using AES-256-GCM and securely store it.
/// The plaintext secret is never stored, so users must ensure it is valid for OAuth2 authentication.
pub client_secret: String,
/// The URL to redirect users to for OAuth2 authorization.
pub auth_url: String,
/// The URL to exchange authorization codes for access tokens.
pub token_url: String,
/// The URI where the OAuth2 provider will redirect to after authorization.
pub redirect_uri: String,
/// The scopes of access that are being requested (e.g., email, profile).
pub scopes: Option<Vec<String>>,
/// Any additional parameters to include in the OAuth2 requests (e.g., access_type, prompt).
pub extra_params: Option<BTreeMap<String, String>>,
/// Indicates whether this configuration is enabled or disabled.
pub enabled: bool,
/// route OAuth through proxy (when direct access is blocked)
pub use_proxy: Option<u64>,
/// The timestamp when the configuration was created, in milliseconds since the Unix epoch.
pub created_at: i64,
/// The timestamp when the configuration was last updated, in milliseconds since the Unix epoch.
pub updated_at: i64,
}
impl OAuth2 {
fn pk(&self) -> String {
format!("{}_{}", &self.created_at, &self.id)
}
}
impl From<OAuth2> for bichon_core::oauth2::entity::OAuth2 {
fn from(value: OAuth2) -> Self {
Self {
id: value.id,
description: value.description,
client_id: value.client_id,
client_secret: value.client_secret,
auth_url: value.auth_url,
token_url: value.token_url,
redirect_uri: value.redirect_uri,
scopes: value.scopes,
extra_params: value.extra_params,
enabled: value.enabled,
use_proxy: value.use_proxy,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 6, version = 1)]
#[native_db]
pub struct OAuth2PendingEntity {
/// Unique identifier for the OAuth2 request record
pub oauth2_id: u64,
pub account_id: u64,
/// CSRF protection state parameter used to verify the integrity of the authorization request
#[primary_key]
pub state: String,
/// PKCE code verifier used in the authorization code exchange process to ensure security
pub code_verifier: String,
/// Timestamp when the OAuth2 request was created, used to determine request expiration
pub created_at: i64,
}
impl From<OAuth2PendingEntity> for bichon_core::oauth2::pending::OAuth2PendingEntity {
fn from(value: OAuth2PendingEntity) -> Self {
Self {
oauth2_id: value.oauth2_id,
account_id: value.account_id,
state: value.state,
code_verifier: value.code_verifier,
created_at: value.created_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 7, version = 1)]
#[native_db]
pub struct OAuth2AccessToken {
/// The ID of the account associated with this access token.
#[primary_key]
pub account_id: u64,
/// The id of the OAuth2 configuration associated with this access token.
#[secondary_key]
pub oauth2_id: u64,
/// The OAuth2 access token used to authenticate requests to the provider.
pub access_token: Option<String>,
/// The OAuth2 refresh token used to obtain new access tokens.
pub refresh_token: Option<String>,
/// The timestamp when the token record was created, in milliseconds since the Unix epoch.
pub created_at: i64,
/// The timestamp when the token record was last updated, in milliseconds since the Unix epoch.
pub updated_at: i64,
}
impl From<OAuth2AccessToken> for bichon_core::oauth2::token::OAuth2AccessToken {
fn from(value: OAuth2AccessToken) -> Self {
Self {
account_id: value.account_id,
oauth2_id: value.oauth2_id,
access_token: value.access_token,
refresh_token: value.refresh_token,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 8, version = 1)]
#[native_db]
pub struct Proxy {
/// The unique identifier for this proxy configuration.
#[primary_key]
pub id: u64,
/// The proxy URL (e.g., socks5://127.0.0.1:1080) used to route network requests.
pub url: String,
/// The creation timestamp of this record, represented as milliseconds since the Unix epoch.
pub created_at: i64,
/// The last update timestamp of this record, represented as milliseconds since the Unix epoch.
pub updated_at: i64,
}
impl From<Proxy> for bichon_core::settings::proxy::Proxy {
fn from(value: Proxy) -> Self {
Self {
id: value.id,
url: value.url,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 9, version = 1)]
#[native_db]
pub struct UserRole {
#[primary_key]
pub id: u64,
pub name: String,
pub description: Option<String>,
pub permissions: BTreeSet<String>,
pub is_builtin: bool,
pub created_at: i64,
pub role_type: RoleType,
pub updated_at: i64,
}
impl From<UserRole> for bichon_core::users::role::UserRole {
fn from(value: UserRole) -> Self {
Self {
id: value.id,
name: value.name,
description: value.description,
permissions: value.permissions,
is_builtin: value.is_builtin,
created_at: value.created_at,
role_type: value.role_type,
updated_at: value.updated_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 10, version = 1)]
#[native_db]
pub struct BichonUser {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 10, version = 2, from = BichonUser)]
#[native_db]
pub struct BichonUserV2 {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl From<BichonUserV2> for BichonUser {
fn from(value: BichonUserV2) -> Self {
BichonUser {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
}
}
}
impl From<BichonUser> for BichonUserV2 {
fn from(value: BichonUser) -> Self {
BichonUserV2 {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
theme: None,
language: None,
}
}
}
impl From<BichonUserV2> for bichon_core::users::BichonUserV2 {
fn from(value: BichonUserV2) -> Self {
Self {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
theme: value.theme,
language: value.language,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[native_model(id = 11, version = 1)]
#[native_db]
pub struct AccessTokenModel {
/// The ID of the user who owns this token
#[secondary_key]
pub user_id: u64,
/// The unique token string used for authentication
#[primary_key]
pub token: String,
/// An optional name of the token.
pub name: Option<String>,
/// Token type: WebUI or API
pub token_type: TokenType,
/// The timestamp (in milliseconds since epoch) when the token was created.
pub created_at: i64,
/// The timestamp (in milliseconds since epoch) when the token was last updated.
pub updated_at: i64,
/// The timestamp (in milliseconds since epoch) when the token expires.
/// None means the token does not expire (this applies only to API tokens).
pub expire_at: Option<i64>,
/// The timestamp (in milliseconds since epoch) when the token was last used.
pub last_access_at: i64,
}
impl From<AccessTokenModel> for bichon_core::token::AccessTokenModel {
fn from(value: AccessTokenModel) -> Self {
Self {
user_id: value.user_id,
token: value.token,
name: value.name,
token_type: value.token_type,
created_at: value.created_at,
updated_at: value.updated_at,
expire_at: value.expire_at,
last_access_at: value.last_access_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 1, version = 1)]
#[native_db]
pub struct MailBox {
/// The unique identifier for the mailbox
#[primary_key]
pub id: u64,
/// The ID of the account associated with the mailbox
#[secondary_key]
pub account_id: u64,
/// The unique, decoded, human-readable name of the mailbox (e.g., "INBOX", "Sent Items").
/// This is the decoded name as presented to users, derived from the IMAP server's mailbox name
/// (e.g., after decoding UTF-7 or other encodings per RFC 3501).
pub name: String,
/// Optional delimiter used to separate mailbox names in a hierarchy (e.g., "/" or ".").
/// Used in IMAP to structure nested mailboxes (e.g., "INBOX/Archive").
pub delimiter: Option<String>,
/// List of attributes associated with the mailbox (e.g., `\NoSelect`, `\Deleted`).
/// These indicate special properties, such as whether the mailbox can hold messages.
pub attributes: Vec<Attribute>,
/// The number of messages that currently exist in the mailbox.
pub exists: u32,
/// Optional number of unseen messages in the mailbox (i.e., messages without the `\Seen` flag).
pub unseen: Option<u32>,
/// The next unique identifier (UID) that will be assigned to a new message in the mailbox.
/// If `None`, the IMAP server has not provided this information.
pub uid_next: Option<u32>,
/// The validity identifier for UIDs in this mailbox, used to ensure UID consistency across sessions.
/// If `None`, the IMAP server has not provided this information.
pub uid_validity: Option<u32>,
}
impl From<MailBox> for bichon_core::cache::imap::mailbox::MailBox {
fn from(value: MailBox) -> Self {
Self {
id: value.id,
account_id: value.account_id,
name: value.name,
delimiter: value.delimiter,
attributes: value.attributes,
exists: value.exists,
unseen: value.unseen,
uid_next: value.uid_next,
uid_validity: value.uid_validity,
}
}
}
pub static META_MODELS: LazyLock<Models> = LazyLock::new(|| {
let mut adapter = ModelsAdapter::new();
adapter.register_metadata_models();
adapter.models
});
pub static MAILBOX_MODELS: LazyLock<Models> = LazyLock::new(|| {
let mut adapter = ModelsAdapter::new();
adapter.register_model::<MailBox>();
adapter.models
});
pub struct ModelsAdapter {
pub models: Models,
}
impl ModelsAdapter {
pub fn new() -> Self {
ModelsAdapter {
models: Models::new(),
}
}
pub fn register_model<T: ToInput>(&mut self) {
self.models.define::<T>().expect("failed to define model ");
}
pub fn register_metadata_models(&mut self) {
self.register_model::<CachedMailSettings>();
self.register_model::<AccountV1>();
self.register_model::<AccountV2>();
self.register_model::<AccountV3>();
self.register_model::<OAuth2>();
self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>();
self.register_model::<Proxy>();
self.register_model::<UserRole>();
self.register_model::<BichonUser>();
self.register_model::<BichonUserV2>();
self.register_model::<AccessTokenModel>();
}
}
fn init_meta_database(root_path: &PathBuf) -> BichonResult<Arc<Database<'static>>> {
let mut database = Builder::new()
.set_cache_size(134217728)
.create(&META_MODELS, root_path.join("meta.db"))
.map_err(handle_database_error)?;
let rw = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<AccountV3>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<BichonUserV2>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
database
.compact()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Arc::new(database))
}
fn init_evenlope_database(root_path: &PathBuf) -> BichonResult<Arc<Database<'static>>> {
let mut database = Builder::new()
.set_cache_size(1073741824)
.create(&MAILBOX_MODELS, root_path.join("mailbox.db"))
.map_err(handle_database_error)?;
let rw = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
database
.compact()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Arc::new(database))
}
fn handle_database_error(error: native_db::db_type::Error) -> BichonError {
raise_error!(
format!("Failed to create database: {:?}", error),
ErrorCode::InternalError
)
}
pub fn list_all_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
) -> BichonResult<Vec<T>> {
let r_transaction = database
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entities: Vec<T> = r_transaction
.scan()
.primary()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entities)
}
pub fn migrate_metadata(root_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
// Pre-flight: verify old metadata databases exist
let meta_db_path = root_path.join("meta.db");
if !meta_db_path.exists() {
return Err(format!(
"Legacy metadata database not found at '{}'. \
Make sure the root directory points to a valid v0.3.7 installation.",
meta_db_path.display()
)
.into());
}
let mailbox_db_path = root_path.join("mailbox.db");
if !mailbox_db_path.exists() {
return Err(format!(
"Legacy mailbox database not found at '{}'. \
Make sure the root directory points to a valid v0.3.7 installation.",
mailbox_db_path.display()
)
.into());
}
// Initialize legacy database connections
let meta_db = init_meta_database(root_path)
.map_err(|e| format!("Failed to initialize legacy metadata database: {}", e))?;
let envelope_db = init_evenlope_database(root_path)
.map_err(|e| format!("Failed to initialize legacy envelope database: {}", e))?;
// Prepare new database directory
let db_path = root_path.join("memdb");
if !db_path.exists() {
std::fs::create_dir_all(&db_path)?;
}
// Open new database (disable full durability for faster bulk writes)
let db = MemDb::open_with(&db_path, Durability::Off)
.map_err(|e| format!("Failed to open new memdb database: {}", e))?;
println!(
"{}",
style("Step 1: Migrating Metadata Entities...")
.bold()
.cyan()
);
// Migration helper macro to reduce boilerplate
macro_rules! migrate_collection {
($name:expr, $old_type:ty, $new_type:ty, $source_db:expr) => {
print!(" > {:<25} ", $name);
let items = list_all_impl::<$old_type>($source_db)?;
let count = items.len();
let converted: Vec<$new_type> = items.into_iter().map(|a| a.into()).collect();
batch_insert_impl(&db, converted)?;
println!("{} ({} items)", style("done").green(), count);
};
}
// --- Migrate each entity type ---
migrate_collection!(
"Mail Settings",
CachedMailSettings,
bichon_core::autoconfig::CachedMailSettings,
&meta_db
);
migrate_collection!("Accounts", AccountV3, AccountModel, &meta_db);
migrate_collection!(
"OAuth2 Entities",
OAuth2,
bichon_core::oauth2::entity::OAuth2,
&meta_db
);
migrate_collection!(
"OAuth2 Pending",
OAuth2PendingEntity,
bichon_core::oauth2::pending::OAuth2PendingEntity,
&meta_db
);
migrate_collection!(
"OAuth2 Access Tokens",
OAuth2AccessToken,
bichon_core::oauth2::token::OAuth2AccessToken,
&meta_db
);
migrate_collection!(
"Proxy Settings",
Proxy,
bichon_core::settings::proxy::Proxy,
&meta_db
);
migrate_collection!(
"User Roles",
UserRole,
bichon_core::users::role::UserRole,
&meta_db
);
migrate_collection!(
"Users",
BichonUserV2,
bichon_core::users::BichonUserV2,
&meta_db
);
migrate_collection!(
"Access Tokens",
AccessTokenModel,
bichon_core::token::AccessTokenModel,
&meta_db
);
// Mailboxes (from envelope_db)
migrate_collection!(
"Mailboxes",
MailBox,
bichon_core::cache::imap::mailbox::MailBox,
&envelope_db
);
// Persist and finish
db.snapshot()
.map_err(|e| format!("Snapshot save failed: {}", e))?;
println!(
"{}",
style("Metadata migration completed successfully.")
.green()
.bold()
);
Ok(())
}

View File

@@ -11,7 +11,7 @@ use indicatif::{ProgressBar, ProgressStyle};
pub fn handle_migration(theme: &ColorfulTheme) {
println!(
"\n{}",
style("MIGRATION: Bichon v0.x Storage Architecture → v1.0")
style("MIGRATION: Bichon v0.3.7 Storage Architecture → v1.0.0")
.bold()
.yellow()
);
@@ -19,8 +19,8 @@ pub fn handle_migration(theme: &ColorfulTheme) {
println!(
"{}",
style(
"This tool migrates data from the legacy v0.x Tantivy-based storage \
architecture (used in versions 0.0.1 through 0.3.7) to the new v1.0 \
"This tool migrates data from the legacy v0.3.7 Tantivy-based storage \
architecture to the new v1.0.0 \
separated index and Fjall-backed storage format."
)
.dim()
@@ -29,10 +29,10 @@ pub fn handle_migration(theme: &ColorfulTheme) {
println!(
"{}",
style(
"Legacy v0.x architecture:\n\
"Legacy v0.3.7 architecture:\n\
• envelope metadata stored in Tantivy\n\
• message data stored in Tantivy\n\n\
New v1.0 architecture:\n\
New v1.0.0 architecture:\n\
• mail indexes stored in Tantivy\n\
• attachment indexes stored in Tantivy\n\
• raw message data stored in Fjall\n\
@@ -223,7 +223,29 @@ pub fn handle_migration(theme: &ColorfulTheme) {
return;
}
println!("\n{} Migrating...", style("").yellow());
// Step 1: Migrate metadata (meta.db + mailbox.db → memdb)
match crate::meta::migrate_metadata(&root_path) {
Ok(()) => {}
Err(e) => {
eprintln!(
"\n{} Metadata migration failed:\n{}",
style("").red().bold(),
style(e).red()
);
eprintln!(
"{}",
style("Aborting migration. No changes have been made to Tantivy data.")
.yellow()
);
return;
}
}
println!(
"\n{} {}",
style("").yellow(),
style("Step 2: Migrating email index and blob data...").cyan()
);
let pb = ProgressBar::new(0);
pb.set_style(ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}")

View File

@@ -1,8 +1,7 @@
use std::path::{Path, PathBuf};
use bichon_core::{
admin::meta::{find_admin, init_meta_database, update_admin_password},
error::BichonError,
admin::meta::{find_admin, open_database, update_admin_password},
utils::encrypt::internal_decrypt_string,
};
use console::{style, Emoji};
@@ -19,9 +18,9 @@ pub fn handle_reset_password(theme: &ColorfulTheme) {
if !path.exists() {
return Err("Directory does not exist.");
}
let has_metadata = path.join("meta.db").exists();
if !has_metadata {
return Err("Invalid directory: 'meta.db' not found.");
let memdb_dir = path.join("memdb");
if !memdb_dir.exists() || !memdb_dir.is_dir() {
return Err("Invalid directory: 'memdb' data directory not found.");
}
Ok(())
})
@@ -29,39 +28,11 @@ pub fn handle_reset_password(theme: &ColorfulTheme) {
.unwrap();
let root_path = PathBuf::from(&root_dir_str);
let database = match init_meta_database(&root_path.join("meta.db")) {
Ok(database) => database,
Err(e) => match e {
BichonError::Generic {
message,
location,
code,
} => {
if message.contains("RedbDatabaseError(DatabaseAlreadyOpen") {
println!("\n{}", style("ERROR: Database is locked.").red().bold());
println!(
"{}",
style("The Bichon service is likely still running.").yellow()
);
println!(
"Since the database cannot be shared between multiple instances, \n\
you must {} the Bichon service before proceeding.",
style("STOP").underlined().bold()
);
std::process::exit(1);
} else {
eprintln!(
"\n{} (Code: {:#?})\nLocation: {}\nMessage: {}",
style("A database error occurred:").red().bold(),
code,
location,
message
);
std::process::exit(1);
}
}
},
};
let database = open_database(&root_path.join("memdb")).unwrap_or_else(|e| {
eprintln!("\n{} Failed to open database.", style("ERROR:").red().bold());
eprintln!("Details: {:?}", e);
std::process::exit(1);
});
let admin = find_admin(&database);

View File

@@ -19,9 +19,8 @@ poem-openapi = { version = "5.1.16", features = [
], optional = true }
chrono.workspace = true
clap.workspace = true
native_db.workspace = true
memdb.workspace = true
itertools.workspace = true
native_model.workspace = true
ring.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -24,7 +24,7 @@ use crate::{
{
account::migration::AccountModel,
common::auth::ClientContext,
database::{manager::DB_MANAGER, with_transaction},
database::{manager::DB_MANAGER, with_transaction, MemDbModel},
error::{code::ErrorCode, BichonResult},
users::{
permissions::Permission,
@@ -43,8 +43,8 @@ pub struct BatchAccountRoleRequest {
}
impl BatchAccountRoleRequest {
pub async fn validate_existence(&self) -> BichonResult<()> {
let role = UserRole::find(self.role_id).await?.ok_or_else(|| {
pub fn validate_existence(&self) -> BichonResult<()> {
let role = UserRole::find(self.role_id)?.ok_or_else(|| {
raise_error!(
format!("Role ID {} not found", self.role_id),
ErrorCode::ResourceNotFound
@@ -59,7 +59,7 @@ impl BatchAccountRoleRequest {
}
for id in &self.account_ids {
let exists = AccountModel::async_find(*id).await?; // Assuming an exists helper
let exists = AccountModel::find(*id)?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("Account ID {} not found", id),
@@ -69,7 +69,7 @@ impl BatchAccountRoleRequest {
}
for id in &self.user_ids {
let exists = UserModel::find(*id).await?; // Assuming an exists helper
let exists = UserModel::find(*id)?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("User ID {} not found", id),
@@ -81,44 +81,38 @@ impl BatchAccountRoleRequest {
Ok(())
}
async fn grant_batch_account_access(
fn grant_batch_account_access(
account_ids: Vec<u64>,
user_ids: Vec<u64>,
role_id: u64,
) -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
with_transaction(DB_MANAGER.db(), move |txn| {
let mut txn = txn;
for &uid in &user_ids {
// Fetch the current user record from the database
let user = rw
.get()
.primary::<UserModel>(uid)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", uid),
ErrorCode::ResourceNotFound
)
})?;
let db = DB_MANAGER.db();
let coll = db.collection(UserModel::collection());
let key = uid.to_string();
let user: UserModel = coll
.get_required(&key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut updated_user = user.clone();
// Apply the role to each specified account_id
for &aid in &account_ids {
updated_user.account_access_map.insert(aid, role_id);
}
updated_user.updated_at = utc_now!();
// Save the updated user back to the database within the transaction
rw.update(user, updated_user)
txn = txn
.upsert(UserModel::collection(), key, &updated_user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
Ok(txn)
})
.await
}
pub async fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
pub fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
for account_id in &self.account_ids {
// Get the user's specific access for this account
let assigned_role_id =
@@ -134,7 +128,7 @@ impl BatchAccountRoleRequest {
})?;
// Fetch the role definition from the database
let user_scoped_role = UserRole::find(*assigned_role_id).await?.ok_or_else(|| {
let user_scoped_role = UserRole::find(*assigned_role_id)?.ok_or_else(|| {
raise_error!(
"Assigned account role no longer exists".into(),
ErrorCode::InternalError
@@ -156,6 +150,6 @@ impl BatchAccountRoleRequest {
// This is where you'd compare target_role.permissions vs manager's perms
}
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id).await
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id)
}
}

View File

@@ -16,8 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use tracing::info;
@@ -25,36 +23,28 @@ use tracing::info;
use crate::{
account::{
entity::ImapConfig,
payload::{AccountCreateRequest, AccountUpdateRequest, MinimalAccount},
since::{DateSince, RelativeDate},
state::DownloadState,
},
cache::imap::mailbox::MailBox,
cache::imap::{mailbox::MailBox, task::SYNC_TASKS},
common::paginated::DataPage,
database::{list_all_impl, secondary_find_impl, with_transaction},
context::controller::DOWNLOAD_CONTROLLER,
database::{
count_impl, delete_impl, find_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
paginate_impl, update_impl, MemDbModel,
},
encrypt,
error::BichonResult,
error::{code::ErrorCode, BichonResult},
id,
oauth2::token::OAuth2AccessToken,
raise_error,
store::tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel, DEFAULT_ADMIN_USER_ID},
users::{payload::UserUpdateRequest, role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel},
utc_now,
};
use crate::account::payload::AccountCreateRequest;
use crate::account::payload::AccountUpdateRequest;
use crate::account::payload::MinimalAccount;
use crate::cache::imap::task::SYNC_TASKS;
use crate::context::controller::DOWNLOAD_CONTROLLER;
use crate::database::count_by_unique_secondary_key_impl;
use crate::database::delete_impl;
use crate::database::manager::DB_MANAGER;
use crate::database::{
async_secondary_find_impl, paginate_query_primary_scan_all_impl, update_impl,
};
use crate::error::code::ErrorCode;
use crate::id;
use crate::oauth2::token::OAuth2AccessToken;
use crate::raise_error;
pub type AccountModel = AccountV4;
pub type AccountModel = Account;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
@@ -74,102 +64,9 @@ pub enum QuotaWindow {
Monthly,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 4, version = 1)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV1 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub use_proxy: Option<u64>,
}
impl AccountV1 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 4, version = 2, from = AccountV1)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV2 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV2 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 4, version = 3, from = AccountV2)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV3 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
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>,
}
impl AccountV3 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
#[native_model(id = 4, version = 4, from = AccountV3)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV4 {
#[secondary_key(unique)]
pub struct Account {
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
@@ -200,11 +97,16 @@ pub struct AccountV4 {
pub auto_download_new_mailboxes: Option<bool>,
}
impl AccountV4 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
impl MemDbModel for Account {
fn collection() -> &'static str {
"accounts"
}
fn key(&self) -> String {
self.id.to_string()
}
}
impl Account {
pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult<Self> {
Ok(Self {
id: id!(64),
@@ -234,31 +136,8 @@ impl AccountV4 {
})
}
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
let account = async_secondary_find_impl::<AccountModel>(
DB_MANAGER.meta_db(),
AccountV4Key::id,
account_id,
)
.await?
.ok_or_else(|| {
raise_error!(
format!("Account id='{account_id}' not found"),
ErrorCode::ResourceNotFound
)
})?;
Ok(account)
}
/// Fetches an `AccountEntity` by its `id`.
pub async fn async_get(account_id: u64) -> BichonResult<AccountModel> {
let result: AccountModel = Self::async_find(account_id).await?.ok_or_else(|| {
raise_error!(
format!("Account with ID '{account_id}' not found"),
ErrorCode::ResourceNotFound
)
})?;
Ok(result)
pub fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
Self::get(account_id)
}
pub fn get(account_id: u64) -> BichonResult<AccountModel> {
@@ -271,17 +150,9 @@ impl AccountV4 {
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)
let result = find_impl::<AccountModel>(DB_MANAGER.db(), &account_id.to_string())?;
Ok(result)
}
pub async fn create_account(
@@ -290,31 +161,36 @@ impl AccountV4 {
) -> BichonResult<AccountModel> {
let entity = request.create_entity(user_id)?;
let cloned = entity.clone();
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let account_id = entity.id;
rw.insert::<AccountModel>(entity)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let user = rw
.get()
.primary::<UserModel>(user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", user_id),
ErrorCode::ResourceNotFound
)
})?;
let mut updated = user.clone();
updated
.account_access_map
.insert(account_id, DEFAULT_ACCOUNT_MANAGER_ROLE_ID);
updated.updated_at = utc_now!();
rw.update(user, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
// Insert account into memdb
insert_impl(DB_MANAGER.db(), entity)?;
// Update user's account_access_map
let user = UserModel::find(user_id)?.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", user_id),
ErrorCode::ResourceNotFound
)
})?;
let mut updated_map = user.account_access_map.clone();
updated_map.insert(cloned.id, DEFAULT_ACCOUNT_MANAGER_ROLE_ID);
UserModel::update(
user_id,
UserUpdateRequest {
username: None,
email: None,
password: None,
avatar_base64: None,
global_roles: None,
account_access_map: Some(updated_map),
acl: None,
description: None,
theme: None,
language: None,
},
)?;
if matches!(cloned.account_type, AccountType::IMAP) {
DOWNLOAD_CONTROLLER
@@ -324,27 +200,26 @@ impl AccountV4 {
Ok(cloned)
}
pub async fn update(
pub fn update(
account_id: u64,
request: AccountUpdateRequest,
validate: bool,
) -> BichonResult<()> {
let account = AccountModel::async_get(account_id).await?;
let account = AccountModel::get(account_id)?;
if validate {
request.validate_update_request(&account)?;
}
update_impl(
DB_MANAGER.meta_db(),
move |_| Ok(account),
move |current| Self::apply_update_fields(current, request),
)
.await?;
DB_MANAGER.db(),
&account_id.to_string(),
move |current: Account| Self::apply_update_fields(&current, request),
)?;
Ok(())
}
pub async fn delete(account_id: u64) -> BichonResult<()> {
let account = Self::async_get(account_id).await?;
let account = Self::get(account_id)?;
if let Err(error) = Self::cleanup_account_resources_sequential(&account).await {
tracing::error!(
"[CLEANUP_ACCOUNT_ERROR] Account {}: failed to cleanup resources: {:#?}",
@@ -356,84 +231,81 @@ impl AccountV4 {
Ok(())
}
async fn delete_account(account_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move|rw|{
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))
}).await
fn delete_account(account: &AccountModel) -> BichonResult<()> {
delete_impl::<AccountModel>(DB_MANAGER.db(), &account.id.to_string())
}
async fn cleanup_account_resources_sequential(account: &AccountModel) -> BichonResult<()> {
if matches!(account.account_type, AccountType::IMAP) {
SYNC_TASKS.stop(account.id).await?;
DownloadState::delete(account.id).await?;
DownloadState::delete(account.id)?;
}
OAuth2AccessToken::try_delete(account.id).await?;
UserModel::cleanup_account(account.id).await?;
MailBox::clean(account.id).await?;
OAuth2AccessToken::try_delete(account.id)?;
UserModel::cleanup_account(account.id)?;
MailBox::clean(account.id)?;
ENVELOPE_MANAGER
.delete_account_envelopes(account.id)
.await?;
ATTACHMENT_MANAGER
.delete_account_attachments(account.id)
.await?;
Self::delete_account(account.id).await?;
Self::delete_account(account)?;
info!("Sequential cleanup completed for account: {}", account.id);
Ok(())
}
pub async fn update_download_folders(
pub fn update_download_folders(
account_id: u64,
download_folders: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
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 download folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
updated.download_folders = Some(download_folders);
Ok(updated)
}).await?;
update_impl(
DB_MANAGER.db(),
&account_id.to_string(),
move |current: Account| {
let mut updated = current.clone();
updated.download_folders = Some(download_folders);
Ok(updated)
},
)?;
Ok(())
}
pub async fn update_known_folders(
pub fn update_known_folders(
account_id: u64,
known_folders: BTreeSet<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
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))
}, |current|{
let mut updated = current.clone();
updated.known_folders = Some(known_folders);
Ok(updated)
}).await?;
update_impl(
DB_MANAGER.db(),
&account_id.to_string(),
move |current: Account| {
let mut updated = current.clone();
updated.known_folders = Some(known_folders);
Ok(updated)
},
)?;
Ok(())
}
pub async fn update_capabilities(
account_id: u64,
capabilities: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
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))
}, |current|{
let mut updated = current.clone();
updated.capabilities = Some(capabilities);
Ok(updated)
}).await?;
pub fn update_capabilities(account_id: u64, capabilities: Vec<String>) -> BichonResult<()> {
update_impl(
DB_MANAGER.db(),
&account_id.to_string(),
move |current: Account| {
let mut updated = current.clone();
updated.capabilities = Some(capabilities);
Ok(updated)
},
)?;
Ok(())
}
/// Retrieves a list of all `AccountEntity` instances.
pub async fn list_all() -> BichonResult<Vec<AccountModel>> {
list_all_impl(DB_MANAGER.meta_db()).await
pub fn list_all() -> BichonResult<Vec<AccountModel>> {
list_all_impl::<AccountModel>(DB_MANAGER.db())
}
pub async fn find_by_email(email: &str) -> BichonResult<Option<AccountModel>> {
let all: Vec<AccountModel> = list_all_impl(DB_MANAGER.meta_db()).await?;
pub fn find_by_email(email: &str) -> BichonResult<Option<AccountModel>> {
let all: Vec<AccountModel> = list_all_impl::<AccountModel>(DB_MANAGER.db())?;
let target_email = email.trim().to_lowercase();
let first_match = all
@@ -443,9 +315,8 @@ impl AccountV4 {
Ok(first_match)
}
pub async fn minimal_list(only_nosync: bool) -> BichonResult<Vec<MinimalAccount>> {
let result = list_all_impl(DB_MANAGER.meta_db())
.await?
pub fn minimal_list(only_nosync: bool) -> BichonResult<Vec<MinimalAccount>> {
let result = list_all_impl::<AccountModel>(DB_MANAGER.db())?
.into_iter()
.filter(|account: &AccountModel| {
!only_nosync || matches!(account.account_type, AccountType::NoSync)
@@ -458,19 +329,16 @@ impl AccountV4 {
Ok(result)
}
pub async fn count() -> BichonResult<usize> {
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV4Key::id)
.await
pub fn count() -> BichonResult<usize> {
count_impl::<AccountModel>(DB_MANAGER.db())
}
pub async fn paginate_list(
pub fn paginate_list(
page: Option<u64>,
page_size: Option<u64>,
desc: Option<bool>,
) -> BichonResult<DataPage<AccountModel>> {
paginate_query_primary_scan_all_impl(DB_MANAGER.meta_db(), page, page_size, desc)
.await
.map(DataPage::from)
paginate_impl::<AccountModel>(DB_MANAGER.db(), page, page_size, desc).map(DataPage::from)
}
// This method applies the updates from the request to the old account entity
@@ -571,158 +439,3 @@ impl AccountV4 {
Ok(new)
}
}
impl From<AccountV1> for AccountV2 {
fn from(value: AccountV1) -> 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,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: false,
pgp_key: None,
}
}
}
impl From<AccountV2> for AccountV1 {
fn from(value: AccountV2) -> 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,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
}
}
}
impl From<AccountV3> for AccountV2 {
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,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
}
}
}
impl From<AccountV2> for AccountV3 {
fn from(value: AccountV2) -> 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,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
created_by: DEFAULT_ADMIN_USER_ID,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
sync_batch_size: None,
date_before: None,
}
}
}
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.login_name,
capabilities: value.capabilities,
date_since: value.date_since,
date_before: value.date_before,
folder_limit: value.folder_limit,
sync_folders: value.download_folders,
account_type: value.account_type,
sync_interval_min: value.download_interval_min,
sync_batch_size: value.download_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,
account_name: None,
login_name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
date_before: value.date_before,
folder_limit: value.folder_limit,
download_folders: value.sync_folders,
account_type: value.account_type,
download_interval_min: value.sync_interval_min,
download_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_quota_window: None,
imap_quota_bytes: None,
auto_download_new_mailboxes: None,
}
}
}

View File

@@ -16,8 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@@ -28,10 +26,7 @@ pub struct MailboxBatchProgress {
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 2, version = 1)]
#[native_db]
pub struct AccountRunningState {
#[primary_key]
pub account_id: u64,
pub last_incremental_sync_start: i64,
pub last_incremental_sync_end: Option<i64>,

View File

@@ -260,16 +260,44 @@ mod test {
use crate::account::since::{DateSince, RelativeDate, Unit};
#[test]
fn test1() {
fn fixed_date_valid() {
let e = DateSince {
fixed: Some("2014-09-12".to_string()),
relative: None,
};
assert!(e.validate().is_ok());
assert!(!e.since_date().unwrap().is_empty());
}
e.validate().unwrap();
#[test]
fn fixed_date_in_future_fails() {
let e = DateSince {
fixed: Some("2099-01-01".to_string()),
relative: None,
};
assert!(e.validate().is_err());
}
println!("{}", e.since_date().unwrap());
#[test]
fn fixed_date_before_1970_fails() {
let e = DateSince {
fixed: Some("1960-01-01".to_string()),
relative: None,
};
assert!(e.validate().is_err());
}
#[test]
fn fixed_date_bad_format_fails() {
let e = DateSince {
fixed: Some("01-01-2020".to_string()),
relative: None,
};
assert!(e.validate().is_err());
}
#[test]
fn relative_date_days_valid() {
let e = DateSince {
fixed: None,
relative: Some(RelativeDate {
@@ -277,9 +305,63 @@ mod test {
value: 1,
}),
};
assert!(e.validate().is_ok());
}
e.validate().unwrap();
#[test]
fn relative_date_months_valid() {
let e = DateSince {
fixed: None,
relative: Some(RelativeDate {
unit: Unit::Months,
value: 3,
}),
};
assert!(e.validate().is_ok());
}
println!("{}", e.since_date().unwrap());
#[test]
fn relative_date_years_valid() {
let e = DateSince {
fixed: None,
relative: Some(RelativeDate {
unit: Unit::Years,
value: 1,
}),
};
assert!(e.validate().is_ok());
}
#[test]
fn relative_date_zero_value_fails() {
let e = DateSince {
fixed: None,
relative: Some(RelativeDate {
unit: Unit::Days,
value: 0,
}),
};
assert!(e.validate().is_err());
}
#[test]
fn both_fixed_and_relative_fails() {
let e = DateSince {
fixed: Some("2014-09-12".to_string()),
relative: Some(RelativeDate {
unit: Unit::Days,
value: 1,
}),
};
assert!(e.validate().is_err());
}
#[test]
fn neither_fixed_nor_relative_fails() {
let e = DateSince {
fixed: None,
relative: None,
};
assert!(e.validate().is_err());
}
}

View File

@@ -17,14 +17,10 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
raise_error, utc_now,
{
database::{async_find_impl, delete_impl, manager::DB_MANAGER, update_impl, upsert_impl},
error::{code::ErrorCode, BichonResult},
},
database::{delete_impl, find_impl, manager::DB_MANAGER, update_impl, upsert_impl, MemDbModel},
error::BichonResult,
utc_now,
};
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@@ -82,10 +78,7 @@ pub struct DownloadSession {
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
#[native_model(id = 3, version = 1)]
#[native_db]
pub struct DownloadState {
#[primary_key]
pub account_id: u64,
pub active_session: Option<DownloadSession>,
pub history: Vec<DownloadSession>,
@@ -93,6 +86,15 @@ pub struct DownloadState {
pub last_finished_at: Option<i64>,
}
impl MemDbModel for DownloadState {
fn collection() -> &'static str {
"download_states"
}
fn key(&self) -> String {
self.account_id.to_string()
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct AccountError {
@@ -122,14 +124,14 @@ impl DownloadState {
history: Default::default(),
last_finished_at: Default::default(),
};
upsert_impl(DB_MANAGER.envelope_db(), state).await
upsert_impl(DB_MANAGER.db(), state)
}
pub async fn get(account_id: u64) -> BichonResult<Option<DownloadState>> {
async_find_impl(DB_MANAGER.envelope_db(), account_id).await
pub fn get(account_id: u64) -> BichonResult<Option<DownloadState>> {
find_impl::<DownloadState>(DB_MANAGER.db(), &account_id.to_string())
}
pub async fn start_new_session(account_id: u64, trigger: TriggerType) -> BichonResult<()> {
pub fn start_new_session(account_id: u64, trigger: TriggerType) -> BichonResult<()> {
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
updated.last_trigger_at = utc_now!();
@@ -151,10 +153,9 @@ impl DownloadState {
updated.active_session = Some(new_session);
Ok(updated)
})
.await
}
pub async fn update_session_status(
pub fn update_session_status(
account_id: u64,
status: DownloadStatus,
message: Option<String>,
@@ -181,10 +182,9 @@ impl DownloadState {
}
Ok(updated)
})
.await
}
pub async fn update_folder_progress(
pub fn update_folder_progress(
account_id: u64,
folder_name: String,
planned: u64,
@@ -213,10 +213,9 @@ impl DownloadState {
}
Ok(updated)
})
.await
}
pub async fn init_folder_details(account_id: u64, folders: Vec<String>) -> BichonResult<()> {
pub fn init_folder_details(account_id: u64, folders: Vec<String>) -> BichonResult<()> {
Self::update_state(account_id, move |state| {
let mut updated = state.clone();
if let Some(ref mut session) = updated.active_session {
@@ -235,10 +234,9 @@ impl DownloadState {
}
Ok(updated)
})
.await
}
pub async fn append_session_error(account_id: u64, error: String) -> BichonResult<()> {
pub fn append_session_error(account_id: u64, error: String) -> BichonResult<()> {
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
if let Some(ref mut session) = updated.active_session {
@@ -254,53 +252,23 @@ impl DownloadState {
}
Ok(updated)
})
.await
}
async fn update_state(
fn update_state(
account_id: u64,
updater: impl FnOnce(&DownloadState) -> BichonResult<DownloadState> + Send + 'static,
updater: impl FnOnce(DownloadState) -> BichonResult<DownloadState> + Send + 'static,
) -> BichonResult<()> {
if Self::get(account_id).await?.is_some() {
update_impl(
DB_MANAGER.envelope_db(),
move |rw| {
rw.get()
.primary::<DownloadState>(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("Cannot find download info of account={}", account_id),
ErrorCode::ResourceNotFound
)
})
},
updater,
)
.await?;
if Self::get(account_id)?.is_some() {
update_impl(DB_MANAGER.db(), &account_id.to_string(), updater)?;
}
Ok(())
}
pub async fn delete(account_id: u64) -> BichonResult<()> {
if Self::get(account_id).await?.is_none() {
pub fn delete(account_id: u64) -> BichonResult<()> {
if Self::get(account_id)?.is_none() {
return Ok(());
}
delete_impl(DB_MANAGER.envelope_db(), move |rw| {
rw.get()
.primary::<DownloadState>(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"DownloadState '{}' not found during deletion process.",
account_id
),
ErrorCode::ResourceNotFound
)
})
})
.await
delete_impl::<DownloadState>(DB_MANAGER.db(), &account_id.to_string())
}
}

View File

@@ -16,74 +16,44 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{path::Path, rc::Rc};
use std::path::Path;
use native_db::{Builder, Database};
use memdb::{Durability, MemDb};
use crate::{
account::migration::AccountV3,
database::META_MODELS,
database::MemDbModel,
error::{code::ErrorCode, BichonResult},
raise_error,
token::{AccessTokenModel, AccessTokenModelKey, TokenType},
users::{UserModel, DEFAULT_ADMIN_USER_ID},
utils::encrypt::internal_encrypt_string,
};
use itertools::Itertools;
pub fn init_meta_database(path: impl AsRef<Path>) -> BichonResult<Rc<Database<'static>>> {
let database = Builder::new()
.set_cache_size(134217728)
.create(&META_MODELS, path)
.map_err(|e| {
raise_error!(
format!("Failed to open database: {:?}", e),
ErrorCode::InternalError
)
})?;
Ok(Rc::new(database))
pub fn open_database(path: impl AsRef<Path>) -> BichonResult<MemDb> {
MemDb::open_with(path, Durability::Full).map_err(|e| {
raise_error!(
format!("Failed to open database: {:?}", e),
ErrorCode::InternalError
)
})
}
pub fn list_all_accounts(database: &Rc<Database<'static>>) -> BichonResult<Vec<AccountV3>> {
let r_transaction = database
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entities: Vec<AccountV3> = r_transaction
.scan()
.primary()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entities)
}
pub fn find_admin(database: &Rc<Database<'static>>) -> BichonResult<Option<UserModel>> {
let r_transaction = database
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entity: Option<UserModel> = r_transaction
.get()
.primary(DEFAULT_ADMIN_USER_ID)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
return Ok(entity);
pub fn find_admin(db: &MemDb) -> BichonResult<Option<UserModel>> {
let key = DEFAULT_ADMIN_USER_ID.to_string();
let coll = db.collection(UserModel::collection());
coll.get(&key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub fn update_admin_password(
database: &Rc<Database<'static>>,
db: &MemDb,
password: String,
encrypt_key: &str,
) -> BichonResult<()> {
let rw_transaction = database
.rw_transaction()
let key = DEFAULT_ADMIN_USER_ID.to_string();
let coll = db.collection(UserModel::collection());
let entity: UserModel = coll
.get_required(&key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entity: UserModel = rw_transaction
.get()
.primary(DEFAULT_ADMIN_USER_ID)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!("admin is not found".into(), ErrorCode::InternalError))?;
let mut updated = entity.clone();
updated.password = Some(
@@ -91,53 +61,8 @@ pub fn update_admin_password(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?,
);
rw_transaction
.update(entity, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
return Ok(());
}
pub fn reset_webui_token(database: &Rc<Database<'static>>) -> BichonResult<()> {
let rw_transaction = database
.rw_transaction()
coll.upsert(&key, &updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let tokens: Vec<AccessTokenModel> = rw_transaction
.scan()
.secondary(AccessTokenModelKey::user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(DEFAULT_ADMIN_USER_ID)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let webui_token = tokens
.into_iter()
.find(|t| t.token_type == TokenType::WebUI);
let new_token = AccessTokenModel::new_webui_token(DEFAULT_ADMIN_USER_ID);
match webui_token {
Some(current) => {
rw_transaction
.remove(current)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.insert(new_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
None => {
rw_transaction
.insert(new_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}

View File

@@ -16,23 +16,18 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::autoconfig::entity::{MailServerConfig, ServerConfig};
use crate::error::code::ErrorCode;
use crate::{
{
account::entity::Encryption, autoconfig::CachedMailSettings, error::BichonResult,
},
raise_error,
{account::entity::Encryption, autoconfig::CachedMailSettings, error::BichonResult},
};
use autoconfig::config::{Server, ServerType};
use email_address::EmailAddress;
use std::str::FromStr;
use tracing::error;
pub async fn resolve_autoconfig(
email: impl AsRef<str>,
) -> BichonResult<Option<MailServerConfig>> {
pub async fn resolve_autoconfig(email: impl AsRef<str>) -> BichonResult<Option<MailServerConfig>> {
let email = email.as_ref();
let email_address = EmailAddress::from_str(email).map_err(|error| {
raise_error!(
@@ -43,7 +38,7 @@ pub async fn resolve_autoconfig(
let domain = email_address.domain();
// try read local cache first
if let Some(cached_entity) = CachedMailSettings::get(domain).await? {
if let Some(cached_entity) = CachedMailSettings::get(domain)? {
return Ok(Some(cached_entity.config));
}
@@ -66,12 +61,12 @@ pub async fn resolve_autoconfig(
.incoming_servers()
.into_iter()
.find(|s| matches!(s.server_type(), ServerType::Imap));
let imap_server = match imap_server {
Some(imap) => imap,
None => return Ok(None),
};
let get_encryption = |server: &Server| {
server
.security_type()
@@ -109,6 +104,6 @@ pub async fn resolve_autoconfig(
imap: imap_config,
oauth2: config.oauth2().map(|f| f.into()),
};
CachedMailSettings::add(domain.into(), result.clone()).await?;
CachedMailSettings::add(domain.into(), result.clone())?;
Ok(Some(result))
}

View File

@@ -16,16 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::database::manager::DB_MANAGER;
use crate::database::{delete_impl, async_find_impl, upsert_impl};
use crate::error::code::ErrorCode;
use crate::raise_error;
use crate::{
autoconfig::entity::MailServerConfig, error::BichonResult, utc_now,
};
use native_db::*;
use native_model::{native_model, Model};
use crate::database::{delete_impl, upsert_impl};
use crate::database::{find_impl, MemDbModel};
use crate::{autoconfig::entity::MailServerConfig, error::BichonResult, utc_now};
use serde::{Deserialize, Serialize};
pub mod entity;
@@ -36,45 +30,39 @@ mod tests;
const EXPIRE_TIME_MS: i64 = 30 * 24 * 60 * 60 * 1000;
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[native_model(id = 3, version = 1)]
#[native_db]
pub struct CachedMailSettings {
#[primary_key]
pub domain: String,
pub config: MailServerConfig,
pub created_at: i64,
}
impl MemDbModel for CachedMailSettings {
fn collection() -> &'static str {
"autoconfig"
}
fn key(&self) -> String {
self.domain.clone()
}
}
impl CachedMailSettings {
pub async fn add(domain: String, config: MailServerConfig) -> BichonResult<()> {
pub fn add(domain: String, config: MailServerConfig) -> BichonResult<()> {
Self {
domain,
config,
created_at: utc_now!(),
}
.save()
.await
}
async fn save(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
fn save(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.db(), self.to_owned())
}
pub async fn get(domain: &str) -> BichonResult<Option<CachedMailSettings>> {
if let Some(found) =
async_find_impl::<CachedMailSettings>(DB_MANAGER.meta_db(), domain.to_string()).await?
{
pub fn get(domain: &str) -> BichonResult<Option<CachedMailSettings>> {
if let Some(found) = find_impl::<CachedMailSettings>(DB_MANAGER.db(), domain)? {
if (utc_now!() - found.created_at) > EXPIRE_TIME_MS {
let domain = domain.to_string();
delete_impl(DB_MANAGER.meta_db(), |rw| {
rw.get()
.primary::<CachedMailSettings>(domain)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!("auto config cache miss".into(), ErrorCode::InternalError)
})
})
.await?;
delete_impl::<CachedMailSettings>(DB_MANAGER.db(), domain)?;
Ok(None)
} else {
Ok(Some(found))

View File

@@ -19,7 +19,7 @@
use std::collections::BTreeSet;
use crate::{
decode_mailbox_name,
decode_mailbox_name, raise_error,
{
account::migration::{AccountModel, AccountType},
cache::imap::mailbox::{AttributeEnum, MailBox},
@@ -27,7 +27,6 @@ use crate::{
imap::{executor::ImapExecutor, session::SessionStream},
mailbox::list::convert_names_to_mailboxes,
},
raise_error,
};
use async_imap::{types::Name, Session};
use tracing::{debug, info, warn};
@@ -62,7 +61,7 @@ pub async fn get_download_folders(
mailboxes.iter().map(|(m, _)| m.name.clone()).collect(),
)
.await?;
let account = AccountModel::async_get(account.id).await?;
let account = AccountModel::get(account.id)?;
let subscribed = &account.download_folders.unwrap_or_default();
let is_noselect = |mailbox: &MailBox| {
mailbox
@@ -110,7 +109,7 @@ pub async fn get_download_folders(
.iter()
.map(|n| decode_mailbox_name!(n.name().to_string()))
.collect();
AccountModel::update_download_folders(account.id, sync_folders).await?;
AccountModel::update_download_folders(account.id, sync_folders)?;
} else {
warn!(
"Account {}: No subscribed mailboxes found. This is unexpected — IMAP server should at least provide INBOX.",
@@ -131,7 +130,7 @@ pub async fn detect_mailbox_changes(
) -> BichonResult<()> {
if account.known_folders.is_none() {
// First time sync: just save without comparing
AccountModel::update_known_folders(account.id, all_names).await?;
AccountModel::update_known_folders(account.id, all_names)?;
return Ok(());
}
let known_folders = account.known_folders.clone().unwrap_or_default();
@@ -160,7 +159,7 @@ pub async fn detect_mailbox_changes(
// Note: When all subscribed folders are deleted (remaining_sync_folders empty),
// the system's default behavior is to automatically fall back to syncing
// only the default folders (INBOX and Sent) in subsequent operations
AccountModel::update_download_folders(account.id, remaining_sync_folders).await?;
AccountModel::update_download_folders(account.id, remaining_sync_folders)?;
}
info!(
@@ -179,7 +178,7 @@ pub async fn detect_mailbox_changes(
// Update known folders only if there were changes
if has_changes {
AccountModel::update_known_folders(account.id, all_names).await?;
AccountModel::update_known_folders(account.id, all_names)?;
}
Ok(())
}

View File

@@ -38,7 +38,7 @@ pub async fn decide_next_download_task(
account: &AccountModel,
trigger_type: TriggerType,
) -> BichonResult<DownloadTask> {
let state = match DownloadState::get(account.id).await? {
let state = match DownloadState::get(account.id)? {
None => {
DownloadState::init(account.id).await?;
return Ok(DownloadTask::FullFetch);
@@ -56,7 +56,7 @@ pub async fn decide_next_download_task(
};
if should_start {
DownloadState::start_new_session(account.id, trigger_type).await?;
DownloadState::start_new_session(account.id, trigger_type)?;
Ok(DownloadTask::TraceFetch)
} else {
Ok(DownloadTask::Idle)

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
raise_error,
{
account::{
migration::AccountModel,
@@ -34,7 +35,6 @@ use crate::{
imap::executor::ImapExecutor,
store::tantivy::envelope::ENVELOPE_MANAGER,
},
raise_error,
};
use std::time::Instant;
use tokio_util::sync::CancellationToken;
@@ -67,9 +67,8 @@ pub async fn fetch_and_save_by_date(
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)
.await?;
DownloadState::append_session_error(account_id, err_msg).await?;
)?;
DownloadState::append_session_error(account_id, err_msg)?;
return Err(e);
}
};
@@ -93,9 +92,8 @@ pub async fn fetch_and_save_by_date(
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)
.await?;
DownloadState::append_session_error(account_id, err_msg).await?;
)?;
DownloadState::append_session_error(account_id, err_msg)?;
return Err(e);
}
};
@@ -109,8 +107,7 @@ pub async fn fetch_and_save_by_date(
0,
FolderStatus::Success,
None,
)
.await?;
)?;
return Ok(());
}
@@ -145,8 +142,7 @@ pub async fn fetch_and_save_by_date(
0,
FolderStatus::Pending,
None,
)
.await?;
)?;
let mut current_processed = 0u64;
let mut has_error_or_cancel = false;
@@ -156,8 +152,7 @@ pub async fn fetch_and_save_by_date(
account_id,
DownloadStatus::Cancelled,
Some("User stopped or system shutdown".to_string()),
)
.await?;
)?;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
@@ -165,8 +160,7 @@ pub async fn fetch_and_save_by_date(
current_processed,
FolderStatus::Cancelled,
None,
)
.await?;
)?;
has_error_or_cancel = true;
break;
}
@@ -189,12 +183,11 @@ pub async fn fetch_and_save_by_date(
current_processed,
FolderStatus::Downloading,
None,
)
.await?;
)?;
}
Err(e) => {
let err_msg = format!("Batch {} failed: {:#?}", index, e);
DownloadState::append_session_error(account_id, err_msg.clone()).await?;
DownloadState::append_session_error(account_id, err_msg.clone())?;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
@@ -202,8 +195,7 @@ pub async fn fetch_and_save_by_date(
current_processed,
FolderStatus::Failed,
Some(err_msg),
)
.await?;
)?;
has_error_or_cancel = true;
break;
}
@@ -217,8 +209,7 @@ pub async fn fetch_and_save_by_date(
current_processed,
FolderStatus::Success,
None,
)
.await?;
)?;
}
session.logout().await.ok();
Ok(())
@@ -243,9 +234,8 @@ pub async fn fetch_and_save_full_mailbox(
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)
.await?;
DownloadState::append_session_error(account_id, err_msg).await?;
)?;
DownloadState::append_session_error(account_id, err_msg)?;
return Err(e);
}
};
@@ -261,10 +251,9 @@ pub async fn fetch_and_save_full_mailbox(
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)
.await?;
)?;
DownloadState::append_session_error(account_id, err_msg).await?;
DownloadState::append_session_error(account_id, err_msg)?;
session.logout().await.ok();
return Err(raise_error!(
format!("{:#?}", e),
@@ -307,8 +296,7 @@ pub async fn fetch_and_save_full_mailbox(
account_id,
DownloadStatus::Cancelled,
Some("User stopped or system shutdown".to_string()),
)
.await?;
)?;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
@@ -316,8 +304,7 @@ pub async fn fetch_and_save_full_mailbox(
current_processed,
FolderStatus::Cancelled,
None,
)
.await?;
)?;
has_error_or_cancel = true;
break;
}
@@ -344,12 +331,11 @@ pub async fn fetch_and_save_full_mailbox(
current_processed,
FolderStatus::Downloading,
None,
)
.await?;
)?;
}
Err(e) => {
let err_msg = format!("Batch {} failed: {:#?}", page, e);
DownloadState::append_session_error(account_id, err_msg.clone()).await?;
DownloadState::append_session_error(account_id, err_msg.clone())?;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
@@ -357,8 +343,7 @@ pub async fn fetch_and_save_full_mailbox(
current_processed,
FolderStatus::Failed,
Some(err_msg),
)
.await?;
)?;
has_error_or_cancel = true;
break;
}
@@ -373,8 +358,7 @@ pub async fn fetch_and_save_full_mailbox(
current_processed,
FolderStatus::Success,
None,
)
.await?;
)?;
}
session.logout().await.ok();
Ok(())
@@ -452,8 +436,7 @@ pub async fn reconcile_mailboxes(
DownloadState::init_folder_details(
account.id,
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
)
.await?;
)?;
for (local_mailbox, remote_mailbox) in &existing_mailboxes {
if token.is_cancelled() {
@@ -461,8 +444,7 @@ pub async fn reconcile_mailboxes(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)
.await?;
)?;
break;
}
@@ -482,9 +464,8 @@ pub async fn reconcile_mailboxes(
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)
.await?;
DownloadState::append_session_error(account_id, err_msg).await?;
)?;
DownloadState::append_session_error(account_id, err_msg)?;
continue;
}
info!(
@@ -500,8 +481,7 @@ pub async fn reconcile_mailboxes(
0,
FolderStatus::Downloading,
Some("UID validity changed, rebuilding...".into()),
)
.await?;
)?;
match &account.date_since {
Some(date_since) => {
@@ -547,7 +527,7 @@ pub async fn reconcile_mailboxes(
}
//The metadata of this mailbox must only be updated after a successful synchronization;
//otherwise, it may cause synchronization errors and result in missing emails in the local sync results.
MailBox::batch_upsert(&mailboxes_to_update).await?;
MailBox::batch_upsert(&mailboxes_to_update)?;
}
debug!(
@@ -559,7 +539,7 @@ pub async fn reconcile_mailboxes(
let missing_mailboxes = find_missing_mailboxes(local_mailboxes, remote_mailboxes);
//Mail folders that are not locally need to be downloaded.
if !missing_mailboxes.is_empty() {
MailBox::batch_insert(&missing_mailboxes).await?;
MailBox::batch_insert(&missing_mailboxes)?;
let mut has_error = false;
let mut last_err = None;
@@ -569,8 +549,7 @@ pub async fn reconcile_mailboxes(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)
.await?;
)?;
break;
}
if mailbox.exists > 0 {
@@ -650,9 +629,7 @@ async fn perform_incremental_sync(
token: CancellationToken,
) -> BichonResult<()> {
if remote_mailbox.exists > 0 {
let local_max_uid = ENVELOPE_MANAGER
.get_max_uid(account.id, local_mailbox.id)
.await?;
let local_max_uid = ENVELOPE_MANAGER.get_max_uid(account.id, local_mailbox.id)?;
match local_max_uid {
Some(max_uid) => {
let mut session = ImapExecutor::create_connection(account.id).await?;

View File

@@ -57,8 +57,7 @@ pub async fn process_imap_download(
account_id,
DownloadStatus::Failed,
Some(format!("Failed to connect to IMAP server: {}", e)),
)
.await?;
)?;
return Err(e);
}
};
@@ -67,8 +66,11 @@ pub async fn process_imap_download(
Err(err) => {
let err_msg = format!("Failed to fetch mailboxes: {}", err);
warn!(account_id = account.id, error = %err, "{}", err_msg);
DownloadState::update_session_status(account_id, DownloadStatus::Failed, Some(err_msg))
.await?;
DownloadState::update_session_status(
account_id,
DownloadStatus::Failed,
Some(err_msg),
)?;
return Ok(());
}
};
@@ -101,34 +103,27 @@ pub async fn process_imap_download(
};
match result {
Ok(_) => {
DownloadState::update_session_status(account_id, DownloadStatus::Success, None)
.await?;
DownloadState::update_session_status(account_id, DownloadStatus::Success, None)?;
}
Err(e) => {
DownloadState::update_session_status(
account_id,
DownloadStatus::Failed,
Some(format!("Email Download interrupted: {:#?}", e)),
)
.await?;
)?;
}
}
return Ok(());
}
let local_mailboxes = MailBox::list_all(account_id).await?;
let local_mailboxes = MailBox::list_all(account_id)?;
match reconcile_mailboxes(account, &remote_mailboxes, &local_mailboxes, token).await {
Ok(_) => {
DownloadState::update_session_status(account_id, DownloadStatus::Success, None).await?
}
Err(e) => {
DownloadState::update_session_status(
account_id,
DownloadStatus::Failed,
Some(format!("Email Download interrupted: {:#?}", e)),
)
.await?
}
Ok(_) => DownloadState::update_session_status(account_id, DownloadStatus::Success, None)?,
Err(e) => DownloadState::update_session_status(
account_id,
DownloadStatus::Failed,
Some(format!("Email Download interrupted: {:#?}", e)),
)?,
}
let elapsed_time = start_time.elapsed().as_secs();
debug!(

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
raise_error,
{
account::{
migration::AccountModel,
@@ -34,7 +35,6 @@ use crate::{
error::{code::ErrorCode, BichonResult},
store::tantivy::envelope::ENVELOPE_MANAGER,
},
raise_error,
};
use tokio_util::sync::CancellationToken;
@@ -45,12 +45,11 @@ pub async fn rebuild_cache(
remote_mailboxes: &[MailBox],
token: CancellationToken,
) -> BichonResult<()> {
MailBox::batch_insert(remote_mailboxes).await?;
MailBox::batch_insert(remote_mailboxes)?;
DownloadState::init_folder_details(
account.id,
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
)
.await?;
)?;
let mut has_error = false;
let mut last_err = None;
@@ -61,8 +60,7 @@ pub async fn rebuild_cache(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)
.await?;
)?;
break;
}
if mailbox.exists == 0 {
@@ -77,8 +75,7 @@ pub async fn rebuild_cache(
0,
FolderStatus::Success,
None,
)
.await?;
)?;
continue;
}
let account = account.clone();
@@ -94,9 +91,9 @@ pub async fn rebuild_cache(
continue;
}
};
match fetch_and_save_full_mailbox(&account, &mailbox, token.clone()).await {
Ok(_) => {},
Ok(_) => {}
Err(err) => {
has_error = true;
tracing::error!("Folder sync task failed: {:#?}", err);
@@ -124,12 +121,11 @@ pub async fn rebuild_cache_by_date(
direction: FetchDirection,
token: CancellationToken,
) -> BichonResult<()> {
MailBox::batch_insert(remote_mailboxes).await?;
MailBox::batch_insert(remote_mailboxes)?;
DownloadState::init_folder_details(
account.id,
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
)
.await?;
)?;
let mut has_error = false;
let mut last_err = None;
@@ -140,8 +136,7 @@ pub async fn rebuild_cache_by_date(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)
.await?;
)?;
break;
}
if mailbox.exists == 0 {
@@ -157,8 +152,7 @@ pub async fn rebuild_cache_by_date(
0,
FolderStatus::Success,
None,
)
.await?;
)?;
continue;
}
let account = account.clone();
@@ -224,8 +218,7 @@ pub async fn rebuild_mailbox_cache(
0,
FolderStatus::Success,
None,
)
.await?;
)?;
return Ok(());
}
@@ -257,8 +250,7 @@ pub async fn rebuild_mailbox_cache_by_date(
0,
FolderStatus::Success,
None,
)
.await?;
)?;
return Ok(());
}

View File

@@ -17,33 +17,24 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
decode_mailbox_name, encode_mailbox_name,
decode_mailbox_name, encode_mailbox_name, raise_error,
{
database::{
async_filter_by_secondary_key_impl, async_find_impl, batch_delete_impl,
batch_insert_impl, batch_upsert_impl, delete_impl, filter_by_secondary_key_impl,
find_impl, manager::DB_MANAGER,
batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl, filter_impl,
find_impl, manager::DB_MANAGER, MemDbModel,
},
error::{code::ErrorCode, BichonResult},
},
raise_error,
};
use async_imap::types::{Name, NameAttribute};
use itertools::Itertools;
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
#[native_model(id = 1, version = 1)]
#[native_db]
pub struct MailBox {
/// The unique identifier for the mailbox
#[primary_key]
pub id: u64,
/// The ID of the account associated with the mailbox
#[secondary_key]
pub account_id: u64,
/// The unique, decoded, human-readable name of the mailbox (e.g., "INBOX", "Sent Items").
/// This is the decoded name as presented to users, derived from the IMAP server's mailbox name
@@ -67,23 +58,22 @@ pub struct MailBox {
pub uid_validity: Option<u32>,
}
impl MemDbModel for MailBox {
fn collection() -> &'static str {
"mailboxes"
}
fn key(&self) -> String {
self.id.to_string()
}
}
impl MailBox {
pub fn encoded_name(&self) -> String {
encode_mailbox_name!(&self.name)
}
pub async fn async_get(id: u64) -> BichonResult<MailBox> {
let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
Ok(result.ok_or_else(|| {
raise_error!(
format!("mailbox {} not found", id),
ErrorCode::InternalError
)
})?)
}
pub fn get(id: u64) -> BichonResult<MailBox> {
let result = find_impl::<MailBox>(DB_MANAGER.envelope_db(), id)?;
let result = find_impl::<MailBox>(DB_MANAGER.db(), &id.to_string())?;
Ok(result.ok_or_else(|| {
raise_error!(
format!("mailbox {} not found", id),
@@ -92,55 +82,34 @@ impl MailBox {
})?)
}
pub async fn delete(id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.envelope_db(), move |rw| {
rw.get()
.primary::<MailBox>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
})
.await
pub fn delete(id: u64) -> BichonResult<()> {
delete_impl::<MailBox>(DB_MANAGER.db(), &id.to_string())
}
pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
async_filter_by_secondary_key_impl(
DB_MANAGER.envelope_db(),
MailBoxKey::account_id,
account_id,
)
.await
pub fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)
}
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,
)?;
let all = filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)?;
Ok(all.into_iter().find(|m| m.id == mailbox_id))
}
pub async fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_insert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
pub fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_insert_impl(DB_MANAGER.db(), mailboxes.to_vec())
}
pub async fn batch_upsert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_upsert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
pub fn batch_upsert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_upsert_impl(DB_MANAGER.db(), mailboxes.to_vec())
}
pub async fn clean(account_id: u64) -> BichonResult<()> {
batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| {
let mailboxes: Vec<MailBox> = rw
.scan()
.secondary::<MailBox>(MailBoxKey::account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(mailboxes)
})
.await?;
pub fn clean(account_id: u64) -> BichonResult<()> {
let mailboxes =
filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)?;
let keys: Vec<String> = mailboxes.iter().map(|m| m.id.to_string()).collect();
if !keys.is_empty() {
batch_delete_impl::<MailBox>(DB_MANAGER.db(), keys)?;
}
Ok(())
}
}

View File

@@ -16,30 +16,14 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
collections::{HashMap, HashSet},
sync::LazyLock,
};
use std::collections::{HashMap, HashSet};
use crate::{
account::{old_state::AccountRunningState, state::DownloadState},
database::ModelsAdapter,
};
use mailbox::MailBox;
use native_db::Models;
pub mod download;
pub mod mailbox;
pub mod task;
pub static MAILBOX_MODELS: LazyLock<Models> = LazyLock::new(|| {
let mut adapter = ModelsAdapter::new();
adapter.register_model::<MailBox>();
adapter.register_model::<AccountRunningState>();
adapter.register_model::<DownloadState>();
adapter.models
});
pub fn find_missing_mailboxes(
local_mailboxes: &[MailBox],
server_mailboxes: &[MailBox],

View File

@@ -99,7 +99,7 @@ impl AccountDownTask {
SYNC_TASKS.set_busy(id, false).await;
});
});
let account = AccountModel::async_get(account_id).await.ok();
let account = AccountModel::get(account_id).ok();
match account {
Some(account) => {
if !account.enabled {
@@ -115,7 +115,7 @@ impl AccountDownTask {
} else {
if let Some(imap) = &account.imap {
if let AuthType::OAuth2 = imap.auth.auth_type {
if OAuth2AccessToken::get(account.id).await?.is_none() {
if OAuth2AccessToken::get(account.id)?.is_none() {
if utc_now!() % 300_000 == 0 {
warn!("Account {}: download aborted. OAuth2 authorization not completed. Please visit the rustmailer admin page to authorize this account.", account_id);
}
@@ -133,8 +133,7 @@ impl AccountDownTask {
DownloadState::append_session_error(
account.id,
format!("error in account download task: {:#?}", e),
)
.await?;
)?;
error!(
"Failed to download mailbox data for '{}': {:?}",
account_id, e
@@ -228,7 +227,7 @@ impl AccountDownTask {
if token_clone.is_cancelled() {
return;
}
let account = match AccountModel::async_get(account_id).await {
let account = match AccountModel::get(account_id) {
Ok(acc) => acc,
Err(e) => {
error!("Failed to fetch account {}: {:?}", account_id, e);
@@ -240,7 +239,7 @@ impl AccountDownTask {
{
error!("Manual download failed for {}: {:?}", account_id, e);
let error_msg = format!("error in account download task: {:#?}", e);
let _ = DownloadState::append_session_error(account.id, error_msg).await;
let _ = DownloadState::append_session_error(account.id, error_msg);
}
});
{

View File

@@ -16,12 +16,12 @@ pub struct ClientContext {
}
impl ClientContext {
pub async fn require_any_permission(
pub fn require_any_permission(
&self,
requirements: Vec<(Option<u64>, &str)>,
) -> BichonResult<()> {
for (account_id, permission) in requirements {
if self.has_permission(account_id, permission).await {
if self.has_permission(account_id, permission) {
return Ok(());
}
}
@@ -31,18 +31,18 @@ impl ClientContext {
))
}
pub async fn check_has_permission(
pub fn check_has_permission(
user: &UserModel,
account_id: Option<u64>,
permission: &str,
) -> bool {
if user.is_admin().await {
if user.is_admin() {
return true;
}
let mut global_perms = HashSet::new();
for rid in &user.global_roles {
if let Some(role) = UserRole::find(*rid).await.ok().flatten() {
if let Some(role) = UserRole::find(*rid).ok().flatten() {
global_perms.extend(role.permissions);
}
}
@@ -53,7 +53,7 @@ impl ClientContext {
if let Some(aid) = account_id {
if let Some(role_id) = user.account_access_map.get(&aid) {
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
if let Some(role) = UserRole::find(*role_id).ok().flatten() {
if role.permissions.contains(&permission.to_string())
|| Self::check_account_logic(&role.permissions, permission)
{
@@ -66,14 +66,14 @@ impl ClientContext {
false
}
pub async fn has_permission(&self, account_id: Option<u64>, permission: &str) -> bool {
if self.user.is_admin().await {
pub fn has_permission(&self, account_id: Option<u64>, permission: &str) -> bool {
if self.user.is_admin() {
return true;
}
let mut global_perms = HashSet::new();
for rid in &self.user.global_roles {
if let Some(role) = UserRole::find(*rid).await.ok().flatten() {
if let Some(role) = UserRole::find(*rid).ok().flatten() {
global_perms.extend(role.permissions);
}
}
@@ -84,7 +84,7 @@ impl ClientContext {
if let Some(aid) = account_id {
if let Some(role_id) = self.user.account_access_map.get(&aid) {
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
if let Some(role) = UserRole::find(*role_id).ok().flatten() {
if role.permissions.contains(&permission.to_string())
|| Self::check_account_logic(&role.permissions, permission)
{
@@ -126,12 +126,12 @@ impl ClientContext {
}
}
pub async fn require_permission(
pub fn require_permission(
&self,
account_id: Option<u64>,
permission: &str,
) -> BichonResult<()> {
if self.has_permission(account_id, permission).await {
if self.has_permission(account_id, permission) {
Ok(())
} else {
Err(raise_error!(

View File

@@ -178,3 +178,83 @@ impl<T> Paginated<T> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paginate_vec_full_list_without_pagination() {
let items: Vec<i32> = (1..=10).collect();
let result = paginate_vec(&items, None, None).unwrap();
assert_eq!(result.items.len(), 10);
assert_eq!(result.total_items, 10);
assert_eq!(result.page, None);
assert_eq!(result.total_pages, None);
}
#[test]
fn paginate_vec_first_page() {
let items: Vec<i32> = (1..=25).collect();
let result = paginate_vec(&items, Some(1), Some(10)).unwrap();
assert_eq!(result.items, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
assert_eq!(result.total_items, 25);
assert_eq!(result.total_pages, Some(3));
assert_eq!(result.page, Some(1));
}
#[test]
fn paginate_vec_last_partial_page() {
let items: Vec<i32> = (1..=25).collect();
let result = paginate_vec(&items, Some(3), Some(10)).unwrap();
assert_eq!(result.items, vec![21, 22, 23, 24, 25]);
assert_eq!(result.total_items, 25);
assert_eq!(result.total_pages, Some(3));
}
#[test]
fn paginate_vec_page_beyond_range_returns_empty() {
let items: Vec<i32> = (1..=10).collect();
let result = paginate_vec(&items, Some(5), Some(10)).unwrap();
assert_eq!(result.items.len(), 0);
assert_eq!(result.total_items, 10);
}
#[test]
fn paginate_vec_empty_list() {
let items: Vec<i32> = vec![];
let result = paginate_vec(&items, Some(1), Some(10)).unwrap();
assert_eq!(result.items.len(), 0);
assert_eq!(result.total_items, 0);
assert_eq!(result.total_pages, Some(0));
}
#[test]
fn paginate_vec_zero_page_returns_error() {
let items: Vec<i32> = (1..=10).collect();
assert!(paginate_vec(&items, Some(0), Some(10)).is_err());
}
#[test]
fn paginate_vec_zero_page_size_returns_error() {
let items: Vec<i32> = (1..=10).collect();
assert!(paginate_vec(&items, Some(1), Some(0)).is_err());
}
#[test]
fn paginate_vec_single_item() {
let items = vec![42];
let result = paginate_vec(&items, Some(1), Some(10)).unwrap();
assert_eq!(result.items, vec![42]);
assert_eq!(result.total_items, 1);
assert_eq!(result.total_pages, Some(1));
}
#[test]
fn paginate_vec_exact_page_boundary() {
let items: Vec<i32> = (1..=20).collect();
let result = paginate_vec(&items, Some(2), Some(10)).unwrap();
assert_eq!(result.items, vec![11, 12, 13, 14, 15, 16, 17, 18, 19, 20]);
assert_eq!(result.total_pages, Some(2));
}
}

View File

@@ -50,7 +50,7 @@ impl BichonContext {
}
pub async fn start_account_downloader(&self) -> BichonResult<()> {
let accounts = AccountModel::list_all().await?;
let accounts = AccountModel::list_all()?;
let active_accounts: Vec<AccountModel> = accounts
.into_iter()
.filter(|a| a.enabled && matches!(a.account_type, AccountType::IMAP))

View File

@@ -62,29 +62,21 @@ pub struct DashboardStats {
impl DashboardStats {
pub async fn get(context: ClientContext) -> BichonResult<Self> {
let has_all_accounts = context
.has_permission(None, Permission::ACCOUNT_MANAGE_ALL)
.await;
let has_all_accounts = context.has_permission(None, Permission::ACCOUNT_MANAGE_ALL);
let authorized_ids: Option<HashSet<u64>> = if has_all_accounts {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
let mut stat = ENVELOPE_MANAGER
.get_dashboard_stats(&authorized_ids)
.await?;
let mut stat = ENVELOPE_MANAGER.get_dashboard_stats(&authorized_ids)?;
stat.top_largest_emails = ENVELOPE_MANAGER
.top_10_largest_emails(&authorized_ids)
.await?;
stat.top_largest_attachments = ATTACHMENT_MANAGER
.top_10_largest_attachments(&authorized_ids)
.await?;
stat.top_largest_emails = ENVELOPE_MANAGER.top_10_largest_emails(&authorized_ids)?;
stat.top_largest_attachments =
ATTACHMENT_MANAGER.top_10_largest_attachments(&authorized_ids)?;
stat.account_count = if has_all_accounts {
AccountModel::count().await?
AccountModel::count()?
} else {
authorized_ids.as_ref().map(|ids| ids.len()).unwrap_or(0)
};
@@ -196,18 +188,11 @@ impl LargestAttachment {
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_name_exact).ok_or_else(|| {
raise_error!(
"'name_exact' field not found".into(),
ErrorCode::InternalError
)
})?;
let name = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
raise_error!(
"'name_exact' field is not a string".into(),
ErrorCode::InternalError
)
})?;
let name = document
.get_first(fields.f_name_exact)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "Unknown".to_string());
let value = document.get_first(fields.f_id).ok_or_else(|| {
raise_error!(

View File

@@ -16,108 +16,33 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::account::migration::AccountModel;
use crate::cache::imap::MAILBOX_MODELS;
use crate::error::{code::ErrorCode, BichonError};
use crate::settings::cli::SETTINGS;
use crate::settings::dir::DATA_DIR_MANAGER;
use crate::users::UserModel;
use crate::{database::META_MODELS, error::BichonResult};
use crate::raise_error;
use native_db::{Builder, Database};
use std::sync::{Arc, LazyLock};
use tracing::info;
use memdb::{Durability, MemDb};
use std::sync::LazyLock;
use std::time::Duration;
pub static DB_MANAGER: LazyLock<DatabaseManager> = LazyLock::new(DatabaseManager::new);
/// Metadata database instance
pub struct DatabaseManager {
meta_db: Arc<Database<'static>>,
/// Envelope database instance
envelope_db: Arc<Database<'static>>,
db: MemDb,
}
impl DatabaseManager {
fn new() -> Self {
let meta_db = Self::init_meta_database().expect("Failed to initialize metadata database");
let envelope_db =
Self::init_evenlope_database().expect("Failed to initialize evenlope database");
DatabaseManager {
meta_db,
envelope_db,
}
let db_path = &DATA_DIR_MANAGER.memdb_dir;
std::fs::create_dir_all(db_path).expect("Failed to create memdb data directory");
let db = MemDb::open_with(db_path, Durability::Full)
.expect("Failed to open memdb database");
// Start periodic snapshot worker (every 5 minutes)
db.start_snapshot_worker(Duration::from_secs(300));
DatabaseManager { db }
}
/// Get a reference to the metadata database
pub fn meta_db(&self) -> &Arc<Database<'static>> {
&self.meta_db
}
pub fn envelope_db(&self) -> &Arc<Database<'static>> {
&self.envelope_db
}
/// Initialize metadata database with a fixed or configured file path
fn init_meta_database() -> BichonResult<Arc<Database<'static>>> {
let mut database = Builder::new()
.set_cache_size(
SETTINGS
.bichon_metadata_cache_size
.unwrap_or(134217728)
.max(67108864),
) //default 128MB
.create(&META_MODELS, DATA_DIR_MANAGER.meta_db.clone())
.map_err(Self::handle_database_error)?;
let rw = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<AccountModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<UserModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
database
.compact()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Arc::new(database))
}
fn init_evenlope_database() -> BichonResult<Arc<Database<'static>>> {
info!(
"Initializing envelope database at: {:?}",
&DATA_DIR_MANAGER.mailbox_db
);
let mut database = Builder::new()
.set_cache_size(
SETTINGS
.bichon_envelope_cache_size
.unwrap_or(134217728)
.max(67108864),
) //default 128MB
.create(&MAILBOX_MODELS, DATA_DIR_MANAGER.mailbox_db.clone())
.map_err(Self::handle_database_error)?;
let rw = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
database
.compact()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Arc::new(database))
}
fn handle_database_error(error: native_db::db_type::Error) -> BichonError {
raise_error!(
format!("Failed to create database: {:?}", error),
ErrorCode::InternalError
)
/// Get a reference to the MemDb instance.
pub fn db(&self) -> &MemDb {
&self.db
}
}

View File

@@ -16,332 +16,157 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::account::migration::{AccountV1, AccountV2, AccountV3, AccountV4};
use crate::autoconfig::CachedMailSettings;
use crate::common::paginated::Paginated;
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::oauth2::entity::OAuth2;
use crate::oauth2::pending::OAuth2PendingEntity;
use crate::oauth2::token::OAuth2AccessToken;
use crate::settings::proxy::Proxy;
use crate::settings::system::SystemSetting;
use crate::token::AccessTokenModel;
use crate::users::role::UserRole;
use crate::users::{BichonUser, BichonUserV2};
use crate::raise_error;
use db_type::{KeyOptions, ToKeyDefinition};
use itertools::Itertools;
use native_db::*;
use memdb::{MemDb, Transaction};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::sync::{Arc, LazyLock};
use transaction::RwTransaction;
pub mod manager;
pub static META_MODELS: LazyLock<Models> = LazyLock::new(|| {
let mut adapter = ModelsAdapter::new();
adapter.register_metadata_models();
adapter.models
});
pub struct ModelsAdapter {
pub models: Models,
/// Trait for models that can be stored in MemDb collections.
pub trait MemDbModel: Serialize + DeserializeOwned + Clone + Send + 'static {
/// The collection name this model is stored under.
fn collection() -> &'static str;
/// The primary key as a string for MemDb storage.
fn key(&self) -> String;
}
impl ModelsAdapter {
pub fn new() -> Self {
ModelsAdapter {
models: Models::new(),
}
// ─── Insert ───────────────────────────────────────────────────────────────
pub fn insert_impl<M: MemDbModel>(db: &MemDb, item: M) -> BichonResult<()> {
let coll = db.collection(M::collection());
let key = item.key();
coll.insert(key, &item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub fn batch_insert_impl<M: MemDbModel>(db: &MemDb, items: Vec<M>) -> BichonResult<()> {
let txn = db.transaction();
let mut txn = txn;
for item in &items {
txn = txn
.insert(M::collection(), item.key(), item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
txn.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub fn register_model<T: ToInput>(&mut self) {
self.models.define::<T>().expect("failed to define model ");
}
// ─── Upsert ────────────────────────────────────────────────────────────────
pub fn register_metadata_models(&mut self) {
//Starting from version 0.2.0, `AccessToken` is deprecated/no longer used, but its ID must not be reused, otherwise it may cause model errors.
//self.register_model::<AccessToken>();
self.register_model::<SystemSetting>();
self.register_model::<CachedMailSettings>();
self.register_model::<AccountV1>();
self.register_model::<AccountV2>();
self.register_model::<AccountV3>();
self.register_model::<AccountV4>();
self.register_model::<OAuth2>();
self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>();
self.register_model::<Proxy>();
self.register_model::<UserRole>();
self.register_model::<BichonUser>();
self.register_model::<BichonUserV2>();
self.register_model::<AccessTokenModel>();
pub fn upsert_impl<M: MemDbModel>(db: &MemDb, item: M) -> BichonResult<()> {
let coll = db.collection(M::collection());
coll.upsert(item.key(), &item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub fn batch_upsert_impl<M: MemDbModel>(db: &MemDb, items: Vec<M>) -> BichonResult<()> {
let txn = db.transaction();
let mut txn = txn;
for item in &items {
txn = txn
.upsert(M::collection(), item.key(), item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
txn.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub async fn insert_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
item: T,
) -> BichonResult<()> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.insert(item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// ─── Find ──────────────────────────────────────────────────────────────────
pub fn find_impl<M: MemDbModel>(db: &MemDb, key: &str) -> BichonResult<Option<M>> {
let coll = db.collection(M::collection());
coll.get(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub async fn batch_insert_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
batch: Vec<T>,
) -> BichonResult<()> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for item in batch {
rw_transaction
.insert(item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// ─── Filter (replaces secondary key queries) ──────────────────────────────
pub fn filter_impl<M, F>(db: &MemDb, predicate: F) -> BichonResult<Vec<M>>
where
M: MemDbModel,
F: Fn(&M) -> bool + Send + 'static,
{
let coll = db.collection(M::collection());
coll.filter(predicate)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub async fn batch_upsert_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
batch: Vec<T>,
) -> BichonResult<()> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for item in batch {
rw_transaction
.upsert(item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
// ─── Update (read-modify-write under a single spawn_blocking) ─────────────
pub async fn upsert_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
item: T,
) -> BichonResult<()> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.upsert(item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn update_impl<T: ToInput + Clone + std::fmt::Debug + Send + 'static>(
database: &Arc<Database<'static>>,
current: impl FnOnce(&RwTransaction) -> BichonResult<T> + Send + 'static,
updated: impl FnOnce(&T) -> BichonResult<T> + Send + 'static,
) -> BichonResult<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 current_item = current(&rw)?;
let updated_item = updated(&current_item)?;
rw.update(current_item, updated_item.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(updated_item)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn async_find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key: impl ToKey + Send + 'static,
) -> BichonResult<Option<T>> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
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)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub fn find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key: impl ToKey + Send + 'static,
) -> BichonResult<Option<T>> {
let db = database.clone();
let r_transaction = db
.r_transaction()
pub fn update_impl<M: MemDbModel>(
db: &MemDb,
key: &str,
update_fn: impl FnOnce(M) -> BichonResult<M> + Send + 'static,
) -> BichonResult<M> {
let coll = db.collection(M::collection());
let current: M = coll
.get_required(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entity: Option<T> = r_transaction
.get()
.primary(key)
let updated = update_fn(current)?;
coll.upsert(key, &updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entity)
Ok(updated)
}
pub async fn delete_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
delete: impl FnOnce(&RwTransaction) -> BichonResult<T> + Send + 'static,
) -> BichonResult<()> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let to_delete = delete(&rw_transaction)?;
rw_transaction
.remove::<T>(to_delete)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// ─── Delete ────────────────────────────────────────────────────────────────
pub fn delete_impl<M: MemDbModel>(db: &MemDb, key: &str) -> BichonResult<()> {
let coll = db.collection(M::collection());
let existed = coll
.delete(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if !existed {
return Err(raise_error!(
format!("{} '{}' not found for deletion", M::collection(), key),
ErrorCode::ResourceNotFound
));
}
Ok(())
}
pub async fn batch_delete_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
delete: impl FnOnce(&RwTransaction) -> BichonResult<Vec<T>> + Send + 'static,
) -> BichonResult<usize> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let to_delete = delete(&rw_transaction)?;
let delete_count = to_delete.len();
for item in to_delete {
rw_transaction
.remove(item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(delete_count)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
pub fn batch_delete_impl<M: MemDbModel>(db: &MemDb, keys: Vec<String>) -> BichonResult<usize> {
let txn = db.transaction();
let mut txn = txn;
let mut count = 0usize;
for key in &keys {
txn = txn.delete(M::collection(), key.clone());
count += 1;
}
txn.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(count)
}
pub async fn list_all_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
) -> BichonResult<Vec<T>> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entities: Vec<T> = r_transaction
.scan()
.primary()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entities)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// ─── List / Count ──────────────────────────────────────────────────────────
pub fn list_all_impl<M: MemDbModel>(db: &MemDb) -> BichonResult<Vec<M>> {
let coll = db.collection(M::collection());
coll.list_all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub async fn with_transaction(
database: &Arc<Database<'static>>,
f: impl FnOnce(&RwTransaction) -> BichonResult<()> + Send + 'static,
) -> BichonResult<()> {
let db: Arc<Database<'_>> = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
f(&rw_transaction)?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
pub fn count_impl<M: MemDbModel>(db: &MemDb) -> BichonResult<usize> {
let coll = db.collection(M::collection());
Ok(coll.count())
}
// For tables with a creation timestamp, place the creation time at the front of the primary key.
// This allows sorting by time, as the data is stored in dictionary order based on the primary key.
// If reverse sorting by time is needed, the iterator can be reversed.
pub async fn paginate_query_primary_scan_all_impl<
T: ToInput + Serialize + std::fmt::Debug + std::marker::Unpin + Send + Sync + 'static,
>(
database: &Arc<Database<'static>>,
// ─── Paginate ──────────────────────────────────────────────────────────────
pub fn paginate_impl<M: MemDbModel>(
db: &MemDb,
page: Option<u64>,
page_size: Option<u64>,
desc: Option<bool>,
) -> BichonResult<Paginated<T>> {
let db = database.clone();
) -> BichonResult<Paginated<M>> {
let coll = db.collection(M::collection());
let total_items = coll.count() as u64;
tokio::task::spawn_blocking(move || {
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let total_items = r_transaction
.len()
.primary::<T>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Validate page and page_size
let (offset, total_pages) = if let (Some(p), Some(s)) = (page, page_size) {
if p == 0 || s == 0 {
return Err(raise_error!(
"'page' and 'page_size' must be greater than 0.".into(),
ErrorCode::InvalidParameter
));
}
let (offset, total_pages) = match (page, page_size) {
(Some(p), Some(s)) if p > 0 && s > 0 => {
let offset = (p - 1) * s;
let total_pages = if total_items > 0 {
(total_items as f64 / s as f64).ceil() as u64
@@ -349,158 +174,52 @@ pub async fn paginate_query_primary_scan_all_impl<
0
};
(Some(offset), Some(total_pages))
} else {
(None, None)
};
// Handle empty result early
if let Some(offset) = offset {
if offset >= total_items {
return Ok(Paginated::new(
page,
page_size,
total_items,
total_pages,
vec![],
));
}
}
(Some(0), _) | (_, Some(0)) => {
return Err(raise_error!(
"'page' and 'page_size' must be greater than 0.".into(),
ErrorCode::InvalidParameter
));
}
_ => (None, None),
};
let scan = r_transaction
.scan()
.primary()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let iter = scan
.all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Collect items based on the reverse flag and pagination
let items: Vec<T> = match desc {
Some(true) => iter
.rev()
.skip(offset.unwrap_or(0) as usize)
.take(page_size.unwrap_or(total_items) as usize)
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?,
_ => iter
.skip(offset.unwrap_or(0) as usize)
.take(page_size.unwrap_or(total_items) as usize)
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?,
};
Ok(Paginated::new(
page,
page_size,
total_items,
total_pages,
items,
))
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn async_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();
tokio::task::spawn_blocking(move || {
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)
})
.await
.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()
let all: Vec<M> = coll
.list_all()
.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)
let items: Vec<M> = match desc {
Some(true) => {
let iter: Vec<M> = all.into_iter().rev().collect();
let skip = offset.unwrap_or(0) as usize;
let take = page_size.unwrap_or(total_items) as usize;
iter.into_iter().skip(skip).take(take).collect()
}
_ => {
let skip = offset.unwrap_or(0) as usize;
let take = page_size.unwrap_or(total_items) as usize;
all.into_iter().skip(skip).take(take).collect()
}
};
Ok(Paginated::new(
page,
page_size,
total_items,
total_pages,
items,
))
}
pub async fn count_by_unique_secondary_key_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static,
) -> BichonResult<usize> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let count = r_transaction
.scan()
.secondary::<T>(key_def)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.count();
Ok(count)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn async_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();
tokio::task::spawn_blocking(move || {
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)
})
.await
.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)
// ─── Transaction ───────────────────────────────────────────────────────────
/// Execute operations within a single atomic transaction (one WAL entry).
pub fn with_transaction(
db: &MemDb,
f: impl FnOnce(Transaction) -> BichonResult<Transaction> + Send + 'static,
) -> BichonResult<()> {
let txn = db.transaction();
let txn = f(txn)?;
txn.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}

View File

@@ -433,13 +433,13 @@ pub async fn detach_and_store_attachments(
attachment_infos
}
pub async fn reattach_eml_content(
pub fn reattach_eml_content(
account_id: u64,
envelope_id: String,
) -> BichonResult<(Envelope, Bytes)> {
let e = ENVELOPE_MANAGER
.get_envelope_by_id(account_id, &envelope_id)
.await?
?
.ok_or_else(|| {
raise_error!(
format!(

View File

@@ -90,43 +90,86 @@ pub fn normalize_subject(raw_subject: Option<&str>) -> String {
#[cfg(test)]
mod tests {
use crate::envelope::utils::merge_contiguous_encoded_words;
use crate::envelope::utils::{merge_contiguous_encoded_words, normalize_subject};
// ── merge_contiguous_encoded_words ──────────────────────────────
#[tokio::test]
async fn test3() {
#[test]
fn merge_basic_utf8_b() {
let s = "Hello =?UTF-8?B?SGVsbG8=?= =?UTF-8?B?V29ybGQ=?= !!!";
assert_eq!(
merge_contiguous_encoded_words(s),
"Hello =?UTF-8?B?SGVsbG8=V29ybGQ=?= !!!"
);
}
#[test]
fn merge_three_blocks() {
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= =?UTF-8?B?Qw==?=";
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?B?QQ==Qg==Qw==?="
);
}
#[test]
fn merge_noncontiguous_blocks() {
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= test =?UTF-8?B?Qw==?= =?UTF-8?B?RA==?=";
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?B?QQ==Qg==?= test =?UTF-8?B?Qw==RA==?="
);
}
#[test]
fn reject_different_charsets() {
let s = "=?UTF-8?B?QQ==?= =?GBK?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), s);
}
#[test]
fn reject_different_encodings() {
let s = "=?UTF-8?B?QQ==?= =?UTF-8?Q?Qg?=";
assert_eq!(merge_contiguous_encoded_words(s), s);
}
#[test]
fn merge_case_insensitive_encoding() {
let s = "=?UTF-8?b?QQ==?= =?UTF-8?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
}
#[test]
fn single_encoded_word_unchanged() {
let s = "Hello =?UTF-8?B?SGVsbG8=?= !!!";
assert_eq!(merge_contiguous_encoded_words(s), s);
}
#[test]
fn multiple_spaces_between_words() {
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
}
#[test]
fn plain_subject_line() {
let s = "Just a normal subject line";
assert_eq!(merge_contiguous_encoded_words(s), s);
}
#[test]
fn merge_quoted_printable() {
let s = "=?UTF-8?Q?Hello_?= =?UTF-8?Q?World?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?Q?Hello_World?=");
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?Q?Hello_World?="
);
}
// ── normalize_subject ───────────────────────────────────────────
#[test]
fn normalize_subject_none() {
assert_eq!(normalize_subject(None), "");
}
}

View File

@@ -18,13 +18,13 @@
use crate::account::migration::AccountModel;
use crate::account::state::{DownloadState, FolderStatus};
use crate::cache::imap::mailbox::MailBox;
use crate::cache::imap::download::flow::{generate_uid_sequence_hashset, DEFAULT_BATCH_SIZE};
use crate::cache::imap::mailbox::MailBox;
use crate::envelope::extractor::extract_envelope_and_store_it;
use crate::error::code::ErrorCode;
use crate::imap::session::SessionStream;
use crate::{error::BichonResult, imap::manager::ImapConnectionManager};
use crate::raise_error;
use crate::{error::BichonResult, imap::manager::ImapConnectionManager};
use async_imap::types::Name;
use async_imap::Session;
use futures::TryStreamExt;
@@ -106,9 +106,8 @@ impl ImapExecutor {
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)
.await?;
DownloadState::append_session_error(account.id, err_msg).await?;
)?;
DownloadState::append_session_error(account.id, err_msg)?;
return Err(e);
}
};
@@ -126,8 +125,7 @@ impl ImapExecutor {
0,
FolderStatus::Success,
Some(msg),
)
.await?;
)?;
return Ok(());
}
info!(
@@ -166,12 +164,11 @@ impl ImapExecutor {
current_processed,
FolderStatus::Downloading,
None,
)
.await?;
)?;
}
Err(e) => {
let err_msg = format!("Batch {} failed: {:#?}", index, e);
DownloadState::append_session_error(account.id, err_msg.clone()).await?;
DownloadState::append_session_error(account.id, err_msg.clone())?;
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
@@ -179,8 +176,7 @@ impl ImapExecutor {
current_processed,
FolderStatus::Failed,
Some(err_msg),
)
.await?;
)?;
has_error_or_cancel = true;
break;
}
@@ -195,8 +191,7 @@ impl ImapExecutor {
current_processed,
FolderStatus::Success,
None,
)
.await?;
)?;
}
Ok(())

View File

@@ -20,9 +20,7 @@ use crate::account::entity::AuthType;
use crate::account::migration::{AccountModel, AccountType};
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::imap::capabilities::{
capability_to_string, check_capabilities, fetch_capabilities,
};
use crate::imap::capabilities::{capability_to_string, check_capabilities, fetch_capabilities};
use crate::imap::client::Client;
use crate::imap::oauth2::OAuth2;
use crate::imap::session::SessionStream;
@@ -73,7 +71,7 @@ impl ImapConnectionManager {
})
}
AuthType::OAuth2 => {
let record = OAuth2AccessToken::get(account.id).await?;
let record = OAuth2AccessToken::get(account.id)?;
let access_token = record.and_then(|r| r.access_token).ok_or_else(|| {
raise_error!(
"Imap auth type is OAuth2, but OAuth2 authorization is not yet complete."
@@ -85,7 +83,10 @@ impl ImapConnectionManager {
.authenticate(OAuth2::new(login_name.clone(), access_token))
.await
.map_err(|e| {
error!("IMAP OAuth2 auth failed for username '{}': {}", login_name, e);
error!(
"IMAP OAuth2 auth failed for username '{}': {}",
login_name, e
);
e
})
}
@@ -93,7 +94,7 @@ impl ImapConnectionManager {
}
pub async fn build(account_id: u64) -> BichonResult<Session<Box<dyn SessionStream>>> {
let account = AccountModel::async_get(account_id).await?;
let account = AccountModel::get(account_id)?;
let client = match Self::create_client(&account).await {
Ok(client) => client,
Err(error) => {
@@ -116,7 +117,7 @@ impl ImapConnectionManager {
match fetch_capabilities(&mut session).await {
Ok(capabilities) => {
let to_save: Vec<String> = capabilities.iter().map(capability_to_string).collect();
AccountModel::update_capabilities(account_id, to_save).await?;
AccountModel::update_capabilities(account_id, to_save)?;
if let Err(error) = check_capabilities(&capabilities) {
error!("Failed to check IMAP capabilities: {:#?}", error);
return Err(error);

View File

@@ -67,7 +67,7 @@ pub struct ImportEmls;
impl ImportEmls {
pub async fn do_import(request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
let account = AccountModel::check_account_exists(request.account_id).await?;
let account = AccountModel::check_account_exists(request.account_id)?;
if !account.enabled {
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
@@ -75,7 +75,7 @@ impl ImportEmls {
let mailbox_id = match account.account_type {
AccountType::IMAP => {
let all_mailboxes = MailBox::list_all(account.id).await?;
let all_mailboxes = MailBox::list_all(account.id)?;
let mailbox = all_mailboxes.into_iter().find(|m| m.name == request.mail_folder);
match mailbox {
@@ -105,7 +105,7 @@ impl ImportEmls {
};
let mailbox_id = mailbox.id;
// Upsert the mailbox, creating it if it doesn't exist
MailBox::batch_upsert(&[mailbox]).await?;
MailBox::batch_upsert(&[mailbox])?;
mailbox_id
},
};

View File

@@ -23,11 +23,11 @@ use crate::{
};
pub async fn delete_mailbox_impl(account_id: u64, mailbox_id: u64) -> BichonResult<()> {
let mailbox = MailBox::async_get(mailbox_id).await?;
let mailbox = MailBox::get(mailbox_id)?;
let name = mailbox.name;
let delimiter = mailbox.delimiter.unwrap_or("/".to_owned());
let all_mailboxes = MailBox::list_all(account_id).await?;
let all_mailboxes = MailBox::list_all(account_id)?;
let prefix = format!("{}{}", name, delimiter);
let ids_to_delete: Vec<u64> = all_mailboxes
@@ -41,7 +41,7 @@ pub async fn delete_mailbox_impl(account_id: u64, mailbox_id: u64) -> BichonResu
}
for id in &ids_to_delete {
MailBox::delete(*id).await?;
MailBox::delete(*id)?;
}
ENVELOPE_MANAGER

View File

@@ -22,13 +22,13 @@ use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::imap::executor::ImapExecutor;
use crate::imap::session::SessionStream;
use crate::utils::create_hash;
use crate::raise_error;
use crate::utils::create_hash;
use async_imap::types::Name;
use async_imap::Session;
pub async fn get_account_mailboxes(account_id: u64, remote: bool) -> BichonResult<Vec<MailBox>> {
let account = AccountModel::check_account_exists(account_id).await?;
let account = AccountModel::check_account_exists(account_id)?;
if remote {
if matches!(account.account_type, AccountType::IMAP) {
request_imap_all_mailbox_list(account_id).await
@@ -39,7 +39,7 @@ pub async fn get_account_mailboxes(account_id: u64, remote: bool) -> BichonResul
));
}
} else {
MailBox::list_all(account_id).await
MailBox::list_all(account_id)
}
}

View File

@@ -1,12 +1,11 @@
use crate::{
encode_mailbox_name,
encode_mailbox_name, raise_error,
{
account::migration::{AccountModel, AccountType},
envelope::extractor::reattach_eml_content,
error::{code::ErrorCode, BichonResult},
imap::executor::ImapExecutor,
},
raise_error,
};
//use poem_openapi::Object;
use serde::{Deserialize, Serialize};
@@ -32,7 +31,7 @@ pub async fn restore_emails(account_id: u64, envelope_ids: Vec<String>) -> Bicho
));
}
let account = AccountModel::check_account_exists(account_id).await?;
let account = AccountModel::check_account_exists(account_id)?;
if !matches!(account.account_type, AccountType::IMAP) {
return Err(raise_error!(
"Account type is not IMAP".into(),
@@ -44,7 +43,7 @@ pub async fn restore_emails(account_id: u64, envelope_ids: Vec<String>) -> Bicho
let mut session = ImapExecutor::create_connection(account_id).await?;
for envelope_id in envelope_ids {
let result: BichonResult<()> = async {
let (envelope, eml) = reattach_eml_content(account_id, envelope_id.clone()).await?;
let (envelope, eml) = reattach_eml_content(account_id, envelope_id.clone())?;
if let Some(mailbox_name) = envelope.mailbox_name {
ImapExecutor::append(
&mut session,

View File

@@ -1,13 +1,13 @@
use std::io::Cursor;
use crate::{
raise_error,
{
dashboard::Group,
envelope::extractor::reattach_eml_content,
error::{code::ErrorCode, BichonResult},
utils::compute_content_hash,
},
raise_error,
};
use bytes::Bytes;
use mail_parser::MessageParser;
@@ -33,18 +33,15 @@ pub struct AttachmentMetadata {
pub content_types: Vec<Group>,
}
pub async fn retrieve_attachment_content(
pub fn retrieve_attachment_content(
account_id: u64,
envelope_id: String,
content_hash: &str,
) -> BichonResult<Cursor<Bytes>> {
let (_, eml) = reattach_eml_content(account_id, envelope_id).await?;
let message = MessageParser::default().parse(&eml).ok_or_else(|| {
raise_error!(
"Failed to parse EML".into(),
ErrorCode::InternalError
)
})?;
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
let message = MessageParser::default()
.parse(&eml)
.ok_or_else(|| raise_error!("Failed to parse EML".into(), ErrorCode::InternalError))?;
let attachment_content: &[u8] = message
.attachments()
@@ -59,13 +56,13 @@ pub async fn retrieve_attachment_content(
Ok(Cursor::new(Bytes::copy_from_slice(attachment_content)))
}
pub async fn retrieve_nested_attachment_content(
pub fn retrieve_nested_attachment_content(
account_id: u64,
envelope_id: String,
content_hash: &str,
nested_content_hash: &str,
) -> BichonResult<Cursor<Bytes>> {
let (_, eml) = reattach_eml_content(account_id, envelope_id).await?;
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
let parent_message = MessageParser::default().parse(&eml).ok_or_else(|| {
raise_error!(
"Failed to parse parent EML".into(),

View File

@@ -16,11 +16,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::base64_encode;
use crate::account::migration::AccountModel;
use crate::envelope::extractor::{
extract_envelope_from_nested_message, reattach_eml_content,
};
use crate::base64_encode;
use crate::envelope::extractor::{extract_envelope_from_nested_message, reattach_eml_content};
use crate::error::code::ErrorCode;
use crate::store::envelope::Envelope;
use crate::utils::compute_content_hash;
@@ -159,12 +157,12 @@ pub struct FullNestedMessageContent {
pub envelope: Envelope,
}
pub async fn retrieve_email_content(
pub fn retrieve_email_content(
account_id: u64,
envelope_id: String,
) -> BichonResult<FullMessageContent> {
AccountModel::check_account_exists(account_id).await?;
let (envelope, eml) = reattach_eml_content(account_id, envelope_id).await?;
AccountModel::check_account_exists(account_id)?;
let (envelope, eml) = reattach_eml_content(account_id, envelope_id)?;
let message = MessageParser::default().parse(&eml).ok_or_else(|| {
raise_error!(
"Failed to parse EML data — the message may be corrupted.".into(),
@@ -232,12 +230,12 @@ pub async fn retrieve_email_content(
})
}
pub async fn retrieve_nested_eml_content(
pub fn retrieve_nested_eml_content(
account_id: u64,
envelope_id: String,
content_hash: &str,
) -> BichonResult<FullNestedMessageContent> {
let (_, eml) = reattach_eml_content(account_id, envelope_id).await?;
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
let parent_message = MessageParser::default().parse(&eml).ok_or_else(|| {
raise_error!(
"Failed to parse parent EML".into(),

View File

@@ -17,17 +17,18 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
account::migration::AccountModel, common::paginated::DataPage, error::BichonResult, store::{envelope::Envelope, tantivy::envelope::ENVELOPE_MANAGER}
account::migration::AccountModel,
common::paginated::DataPage,
error::BichonResult,
store::{envelope::Envelope, tantivy::envelope::ENVELOPE_MANAGER},
};
pub async fn get_thread_messages(
pub fn get_thread_messages(
account_id: u64,
thread_id: &str,
page: u64,
page_size: u64,
) -> BichonResult<DataPage<Envelope>> {
AccountModel::check_account_exists(account_id).await?;
ENVELOPE_MANAGER
.list_thread_envelopes(account_id, thread_id, page, page_size, true)
.await
AccountModel::check_account_exists(account_id)?;
ENVELOPE_MANAGER.list_thread_envelopes(account_id, thread_id, page, page_size, true)
}

View File

@@ -94,21 +94,19 @@ impl EmailSearchRequest {
}
}
pub async fn search_messages_impl(
pub fn search_messages_impl(
accounts: Option<HashSet<u64>>,
request: EmailSearchRequest,
) -> BichonResult<DataPage<Envelope>> {
request.validate()?;
ENVELOPE_MANAGER
.search(
accounts,
request.filter,
request.page,
request.page_size,
request.desc.unwrap_or(true),
request.sort_by.unwrap_or(SortBy::DATE),
)
.await
ENVELOPE_MANAGER.search(
accounts,
request.filter,
request.page,
request.page_size,
request.desc.unwrap_or(true),
request.sort_by.unwrap_or(SortBy::DATE),
)
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
@@ -169,19 +167,17 @@ impl AttachmentSearchRequest {
}
}
pub async fn search_attachment_impl(
pub fn search_attachment_impl(
accounts: Option<HashSet<u64>>,
request: AttachmentSearchRequest,
) -> BichonResult<DataPage<AttachmentModel>> {
request.validate()?;
ATTACHMENT_MANAGER
.search(
accounts,
request.filter,
request.page,
request.page_size,
request.desc.unwrap_or(true),
request.sort_by.unwrap_or(SortBy::DATE),
)
.await
ATTACHMENT_MANAGER.search(
accounts,
request.filter,
request.page,
request.page_size,
request.desc.unwrap_or(true),
request.sort_by.unwrap_or(SortBy::DATE),
)
}

View File

@@ -19,27 +19,21 @@
use crate::{
common::paginated::DataPage,
database::{
async_secondary_find_impl, delete_impl, insert_impl, manager::DB_MANAGER,
paginate_query_primary_scan_all_impl, update_impl,
delete_impl, find_impl, insert_impl, manager::DB_MANAGER, paginate_impl, update_impl,
MemDbModel,
},
encrypt,
error::{code::ErrorCode, BichonResult},
id, raise_error, utc_now,
error::BichonResult,
id, utc_now,
};
use native_db::*;
use native_model::{native_model, Model};
//use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Represents the OAuth2 configuration for a client, including initialization and runtime values.
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
#[native_model(id = 5, version = 1)]
#[native_db(primary_key(pk -> String))]
pub struct OAuth2 {
/// A unique identifier for the OAuth2 configuration.
#[secondary_key(unique)]
pub id: u64,
/// A description of what this configuration is used for.
pub description: Option<String>,
@@ -71,11 +65,16 @@ pub struct OAuth2 {
pub updated_at: i64,
}
impl OAuth2 {
fn pk(&self) -> String {
format!("{}_{}", &self.created_at, &self.id)
impl MemDbModel for OAuth2 {
fn collection() -> &'static str {
"oauth2_configs"
}
fn key(&self) -> String {
self.id.to_string()
}
}
impl OAuth2 {
pub fn new(request: OAuth2CreateRequest) -> BichonResult<Self> {
let request = request.encrypt()?;
Ok(OAuth2 {
@@ -116,59 +115,33 @@ impl OAuth2 {
self.extra_params = None;
}
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await?;
pub fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.db(), self.to_owned())?;
Ok(())
}
pub async fn paginate_list(
pub fn paginate_list(
page: Option<u64>,
page_size: Option<u64>,
desc: Option<bool>,
) -> BichonResult<DataPage<OAuth2>> {
paginate_query_primary_scan_all_impl(DB_MANAGER.meta_db(), page, page_size, desc)
.await
.map(DataPage::from)
let paginated = paginate_impl::<OAuth2>(DB_MANAGER.db(), page, page_size, desc)?;
Ok(DataPage::from(paginated))
}
pub async fn get(id: u64) -> BichonResult<Option<OAuth2>> {
async_secondary_find_impl(DB_MANAGER.meta_db(), OAuth2Key::id, id).await
pub fn get(id: u64) -> BichonResult<Option<OAuth2>> {
let results = find_impl::<OAuth2>(DB_MANAGER.db(), &id.to_string())?;
Ok(results.into_iter().next())
}
pub async fn delete(id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.secondary::<OAuth2>(OAuth2Key::id, id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"The oauth2 entity with id={id} that you want to delete was not found."
),
ErrorCode::ResourceNotFound
)
})
})
.await
pub fn delete(id: u64) -> BichonResult<()> {
delete_impl::<OAuth2>(DB_MANAGER.db(), &id.to_string())
}
pub async fn update(id: u64, request: OAuth2UpdateRequest) -> BichonResult<()> {
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.secondary::<OAuth2>(OAuth2Key::id, id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("The oauth2 entity with id={id} that you want to modify was not found."),
ErrorCode::ResourceNotFound
)
})
},
|current| apply_update(current, request),
)
.await?;
pub fn update(id: u64, request: OAuth2UpdateRequest) -> BichonResult<()> {
update_impl(DB_MANAGER.db(), &id.to_string(), |current| {
apply_update(&current, request)
})?;
Ok(())
}

View File

@@ -16,12 +16,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::oauth2::{
entity::OAuth2, pending::OAuth2PendingEntity, token::OAuth2AccessToken,
};
use crate::oauth2::{entity::OAuth2, pending::OAuth2PendingEntity, token::OAuth2AccessToken};
use crate::settings::proxy::Proxy;
use crate::{decrypt, encrypt, raise_error};
use oauth2::{
@@ -65,9 +62,9 @@ impl OAuth2Flow {
Self { oauth2_id }
}
pub async fn authorize_url(&self, account_id: u64) -> BichonResult<String> {
pub fn authorize_url(&self, account_id: u64) -> BichonResult<String> {
// Fetch OAuth2 entity or return a custom error if not found
let entity = self.fetch_oauth2_entity().await?;
let entity = self.fetch_oauth2_entity()?;
if !entity.enabled {
return Err(raise_error!(
@@ -107,8 +104,7 @@ impl OAuth2Flow {
account_id,
csrf_state.secret(),
pkce_code_verifier.secret(),
)
.await?;
)?;
// Return the authorization URL
Ok(authorize_url.to_string())
}
@@ -119,9 +115,9 @@ impl OAuth2Flow {
code_verifier: &str,
code: &str,
) -> BichonResult<()> {
let entity = self.fetch_oauth2_entity().await?;
let entity = self.fetch_oauth2_entity()?;
let client = self.build_oauth2_client(&entity)?;
let http_client = build_http_client(entity.use_proxy).await?;
let http_client = build_http_client(entity.use_proxy)?;
let token_response = client
.exchange_code(AuthorizationCode::new(code.to_owned()))
@@ -142,13 +138,11 @@ impl OAuth2Flow {
.secret()
.to_owned();
self.save_oauth2_entity(account_id, access_token, refresh_token)
.await?;
self.save_oauth2_entity(account_id, access_token, refresh_token)?;
Ok(())
}
async fn save_oauth2_entity(
fn save_oauth2_entity(
&self,
account_id: u64,
access_token: String,
@@ -156,10 +150,10 @@ impl OAuth2Flow {
) -> BichonResult<()> {
let token =
OAuth2AccessToken::create(account_id, self.oauth2_id, access_token, refresh_token)?;
token.save_or_update().await
token.save_or_update()
}
async fn update_oauth2_entity(
fn update_oauth2_entity(
&self,
account_id: u64,
access_token: String,
@@ -170,20 +164,19 @@ impl OAuth2Flow {
encrypt!(&access_token)?,
encrypt!(&refresh_token)?,
)
.await
}
pub async fn refresh_access_token(&self, token: &OAuth2AccessToken) -> BichonResult<()> {
let entity = self.fetch_oauth2_entity().await?;
let entity = self.fetch_oauth2_entity()?;
if !entity.enabled {
OAuth2AccessToken::delete_by_oauth2_id(token.oauth2_id).await?;
OAuth2AccessToken::delete_by_oauth2_id(token.oauth2_id)?;
return Err(raise_error!(
"OAuth2 authentication is disabled for this client".into(),
ErrorCode::OAuth2ItemDisabled
));
}
let client = self.build_oauth2_client(&entity)?;
let http_client = build_http_client(entity.use_proxy).await?;
let http_client = build_http_client(entity.use_proxy)?;
let refresh_token = token.refresh_token.clone().ok_or_else(|| {
raise_error!(
@@ -218,15 +211,13 @@ impl OAuth2Flow {
.refresh_token()
.map(|r| r.secret().to_owned())
.unwrap_or_else(|| refresh_token.clone());
self.update_oauth2_entity(token.account_id, access_token, new_refresh_token)
.await?;
self.update_oauth2_entity(token.account_id, access_token, new_refresh_token)?;
Ok(())
}
// Helper function to fetch the OAuth2 entity
async fn fetch_oauth2_entity(&self) -> BichonResult<OAuth2> {
OAuth2::get(self.oauth2_id).await?.ok_or_else(|| {
fn fetch_oauth2_entity(&self) -> BichonResult<OAuth2> {
OAuth2::get(self.oauth2_id)?.ok_or_else(|| {
raise_error!(
format!("OAuth2 entity with id '{}' not found", self.oauth2_id),
ErrorCode::ResourceNotFound
@@ -254,7 +245,7 @@ impl OAuth2Flow {
}
// Helper function to save the pending OAuth2 state
async fn save_pending_oauth2_state(
fn save_pending_oauth2_state(
&self,
account_id: u64,
csrf_state: &str,
@@ -267,14 +258,13 @@ impl OAuth2Flow {
pkce_code_verifier.to_owned(),
)
.save()
.await
}
}
// Helper function to build the HTTP client
async fn build_http_client(use_proxy: Option<u64>) -> BichonResult<reqwest::Client> {
fn build_http_client(use_proxy: Option<u64>) -> BichonResult<reqwest::Client> {
if let Some(proxy_id) = use_proxy {
let proxy = Proxy::get(proxy_id).await?;
let proxy = Proxy::get(proxy_id)?;
return oauth2::reqwest::ClientBuilder::new()
.redirect(oauth2::reqwest::redirect::Policy::none())
.proxy(reqwest::Proxy::all(&proxy.url).map_err(|e| {

View File

@@ -16,33 +16,25 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
{
database::{
batch_delete_impl, delete_impl, async_find_impl, insert_impl, manager::DB_MANAGER,
},
error::{code::ErrorCode, BichonResult},
database::{
batch_delete_impl, delete_impl, find_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
MemDbModel,
},
raise_error, utc_now,
error::BichonResult,
utc_now,
};
use itertools::Itertools;
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
const EXPIRATION_DURATION_MS: i64 = 24 * 60 * 60 * 1000;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 6, version = 1)]
#[native_db]
pub struct OAuth2PendingEntity {
/// Unique identifier for the OAuth2 request record
pub oauth2_id: u64,
pub account_id: u64,
/// CSRF protection state parameter used to verify the integrity of the authorization request
#[primary_key]
pub state: String,
/// PKCE code verifier used in the authorization code exchange process to ensure security
@@ -52,13 +44,17 @@ pub struct OAuth2PendingEntity {
pub created_at: i64,
}
impl MemDbModel for OAuth2PendingEntity {
fn collection() -> &'static str {
"oauth2_pending"
}
fn key(&self) -> String {
self.state.clone()
}
}
impl OAuth2PendingEntity {
pub fn new(
oauth2_id: u64,
account_id: u64,
state: String,
code_verifier: String,
) -> Self {
pub fn new(oauth2_id: u64, account_id: u64, state: String, code_verifier: String) -> Self {
Self {
oauth2_id,
account_id,
@@ -68,66 +64,35 @@ impl OAuth2PendingEntity {
}
}
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
pub fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.db(), self.to_owned())
}
pub async fn delete(state: &str) -> BichonResult<()> {
let state = state.to_string();
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().primary::<OAuth2PendingEntity>(state.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!(
"The oauth2 pending entity with state={state} that you want to delete was not found."
), ErrorCode::ResourceNotFound))
}).await
pub fn delete(state: &str) -> BichonResult<()> {
delete_impl::<OAuth2PendingEntity>(DB_MANAGER.db(), state)
}
pub async fn clean() -> BichonResult<()> {
batch_delete_impl(DB_MANAGER.meta_db(), |rw| {
let all: Vec<OAuth2PendingEntity> = rw
.scan()
.primary()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let now = utc_now!();
let to_delete: Vec<OAuth2PendingEntity> = all
.into_iter()
.filter(|e| now - e.created_at > EXPIRATION_DURATION_MS)
.collect();
Ok(to_delete)
})
.await?;
pub fn clean() -> BichonResult<()> {
let all = list_all_impl::<OAuth2PendingEntity>(DB_MANAGER.db())?;
let now = utc_now!();
let to_delete: Vec<String> = all
.into_iter()
.filter(|e| now - e.created_at > EXPIRATION_DURATION_MS)
.map(|e| e.state)
.collect();
if !to_delete.is_empty() {
batch_delete_impl::<OAuth2PendingEntity>(DB_MANAGER.db(), to_delete)?;
}
Ok(())
}
pub async fn get(state: &str) -> BichonResult<Option<OAuth2PendingEntity>> {
let entity =
async_find_impl::<OAuth2PendingEntity>(DB_MANAGER.meta_db(), state.to_string())
.await?;
pub fn get(state: &str) -> BichonResult<Option<OAuth2PendingEntity>> {
let entity = find_impl::<OAuth2PendingEntity>(DB_MANAGER.db(), state)?;
match entity {
Some(entity) => {
let state = state.to_string();
if utc_now!() - entity.created_at > EXPIRATION_DURATION_MS {
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<OAuth2PendingEntity>(state)
.map_err(|e| {
raise_error!(format!("{:#?}", e), ErrorCode::InternalError)
})?
.ok_or_else(|| {
raise_error!(
"OAuth2 pending entity not found".into(),
ErrorCode::ResourceNotFound
)
})
})
.await?;
delete_impl::<OAuth2PendingEntity>(DB_MANAGER.db(), state)?;
return Ok(None);
}
Ok(Some(entity))

View File

@@ -38,7 +38,7 @@ impl BichonTask for OAuth2RefreshTask {
debug!("Starting OAuth2 token refresh task");
// Try to retrieve all OAuth2 access tokens
match OAuth2AccessToken::list_all().await {
match OAuth2AccessToken::list_all() {
Ok(all_tokens) => {
let need_refresh: Vec<OAuth2AccessToken> = all_tokens
.into_iter()

View File

@@ -34,7 +34,7 @@ impl BichonTask for OAuth2CleanTask {
let task = move |_: Option<u64>| {
Box::pin(async move {
OAuth2PendingEntity::clean().await?;
OAuth2PendingEntity::clean()?;
Ok(())
})
};

View File

@@ -16,36 +16,26 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
decrypt, encrypt,
{
database::{
async_find_impl, delete_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
update_impl, upsert_impl,
},
error::{code::ErrorCode, BichonResult},
oauth2::entity::OAuth2,
database::{
delete_impl, filter_impl, find_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
update_impl, upsert_impl, MemDbModel,
},
decrypt, encrypt,
error::{code::ErrorCode, BichonResult},
oauth2::entity::OAuth2,
raise_error, utc_now,
};
use native_db::*;
use native_model::{native_model, Model};
//use poem_openapi::Object;
use serde::{Deserialize, Serialize};
pub const EXTERNAL_OAUTH_APP_ID: u64 = 0;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
#[native_model(id = 7, version = 1)]
#[native_db]
pub struct OAuth2AccessToken {
/// The ID of the account associated with this access token.
#[primary_key]
pub account_id: u64,
/// The id of the OAuth2 configuration associated with this access token.
#[secondary_key]
pub oauth2_id: u64,
/// The OAuth2 access token used to authenticate requests to the provider.
pub access_token: Option<String>,
@@ -57,6 +47,15 @@ pub struct OAuth2AccessToken {
pub updated_at: i64,
}
impl MemDbModel for OAuth2AccessToken {
fn collection() -> &'static str {
"oauth2_tokens"
}
fn key(&self) -> String {
self.account_id.to_string()
}
}
impl OAuth2AccessToken {
pub fn create(
account_id: u64,
@@ -74,14 +73,14 @@ impl OAuth2AccessToken {
})
}
pub async fn upsert_external_oauth_token(
pub fn upsert_external_oauth_token(
account_id: u64,
request: ExternalOAuth2Request,
) -> BichonResult<()> {
let now = utc_now!();
request.validate().await?;
request.validate()?;
let current = Self::get(account_id).await?;
let current = Self::get(account_id)?;
match current {
Some(mut current) => {
// Update existing record
@@ -96,7 +95,7 @@ impl OAuth2AccessToken {
}
current.updated_at = now;
upsert_impl(DB_MANAGER.meta_db(), current).await?;
upsert_impl(DB_MANAGER.db(), current)?;
}
None => {
// Insert new record
@@ -116,20 +115,19 @@ impl OAuth2AccessToken {
created_at: now,
updated_at: now,
};
insert_impl(DB_MANAGER.meta_db(), entity).await?;
insert_impl(DB_MANAGER.db(), entity)?;
}
}
Ok(())
}
// This function may be called multiple times for one account, so we use upsert.
pub async fn save_or_update(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.meta_db(), self.clone()).await
pub fn save_or_update(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.db(), self.clone())
}
pub async fn get(account_id: u64) -> BichonResult<Option<OAuth2AccessToken>> {
async_find_impl::<OAuth2AccessToken>(DB_MANAGER.meta_db(), account_id)
.await?
pub fn get(account_id: u64) -> BichonResult<Option<OAuth2AccessToken>> {
find_impl::<OAuth2AccessToken>(DB_MANAGER.db(), &account_id.to_string())?
.map(|mut token| {
token.access_token = token.access_token.map(|t| decrypt!(&t)).transpose()?;
token.refresh_token = token.refresh_token.map(|t| decrypt!(&t)).transpose()?;
@@ -138,9 +136,8 @@ impl OAuth2AccessToken {
.transpose()
}
pub async fn list_all() -> BichonResult<Vec<OAuth2AccessToken>> {
list_all_impl::<OAuth2AccessToken>(DB_MANAGER.meta_db())
.await?
pub fn list_all() -> BichonResult<Vec<OAuth2AccessToken>> {
list_all_impl::<OAuth2AccessToken>(DB_MANAGER.db())?
.into_iter()
.map(|mut token| {
token.access_token = token.access_token.map(|t| decrypt!(&t)).transpose()?;
@@ -150,48 +147,40 @@ impl OAuth2AccessToken {
.collect()
}
pub async fn try_delete(account_id: u64) -> BichonResult<()> {
if Self::get(account_id).await?.is_none() {
pub fn try_delete(account_id: u64) -> BichonResult<()> {
if Self::get(account_id)?.is_none() {
return Ok(());
}
delete_impl(DB_MANAGER.meta_db(), move |rw|{
rw.get().primary::<OAuth2AccessToken>(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!(
"The oauth2 access token entity with account_id={account_id} that you want to delete was not found."
),ErrorCode::ResourceNotFound))
}).await
delete_impl::<OAuth2AccessToken>(DB_MANAGER.db(), &account_id.to_string())
}
pub async fn delete_by_oauth2_id(oauth2_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move |rw|{
rw.get().secondary::<OAuth2AccessToken>(OAuth2AccessTokenKey::oauth2_id, oauth2_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!(
"The oauth2 access token entity with oauth2_id={oauth2_id} that you want to delete was not found."
),ErrorCode::ResourceNotFound))
}).await
pub fn delete_by_oauth2_id(oauth2_id: u64) -> BichonResult<()> {
let tokens = filter_impl::<OAuth2AccessToken, _>(DB_MANAGER.db(), move |t| {
t.oauth2_id == oauth2_id
})?;
if let Some(token) = tokens.first() {
delete_impl::<OAuth2AccessToken>(DB_MANAGER.db(), &token.account_id.to_string())?;
}
Ok(())
}
pub async fn set_access_token(
pub fn set_access_token(
account_id: u64,
access_token: String,
refresh_token: String,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw|{
rw.get().primary::<OAuth2AccessToken>(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!(
"The oauth2 access token entity with account_id={account_id} that you want to modify was not found."
),ErrorCode::ResourceNotFound))
}, |current| {
let mut updated = current.clone();
updated.access_token = Some(access_token);
updated.refresh_token = Some(refresh_token);
updated.updated_at = utc_now!();
Ok(updated)
}).await?;
update_impl(
DB_MANAGER.db(),
&account_id.to_string(),
|current: OAuth2AccessToken| {
let mut updated = current.clone();
updated.access_token = Some(access_token);
updated.refresh_token = Some(refresh_token);
updated.updated_at = utc_now!();
Ok(updated)
},
)?;
Ok(())
}
}
@@ -213,7 +202,7 @@ impl ExternalOAuth2Request {
/// Ensures mutual dependency between oauth2_id and refresh_token:
/// - If `refresh_token` is provided, `oauth2_id` must also be present.
/// - If `oauth2_id` is provided, `refresh_token` must also be present.
pub async fn validate(&self) -> BichonResult<()> {
pub fn validate(&self) -> BichonResult<()> {
match (self.oauth2_id.is_some(), self.refresh_token.is_some()) {
(true, false) => {
return Err(raise_error!(
@@ -232,7 +221,7 @@ impl ExternalOAuth2Request {
// Validate that oauth2_id exists in the database if provided
if let Some(oauth2_id) = self.oauth2_id {
let oauth2 = OAuth2::get(oauth2_id).await?;
let oauth2 = OAuth2::get(oauth2_id)?;
if oauth2.is_none() {
return Err(raise_error!(
format!("OAuth2 configuration with id {} does not exist", oauth2_id),
@@ -258,12 +247,12 @@ mod tests {
"refresh_token".into(),
)
.unwrap();
token.save_or_update().await.unwrap();
let token2 = OAuth2AccessToken::get(1000u64).await.unwrap().unwrap();
token.save_or_update().unwrap();
let token2 = OAuth2AccessToken::get(1000u64).unwrap().unwrap();
assert_eq!(token2.access_token, Some("access_token".into()));
assert_eq!(token2.refresh_token, Some("refresh_token".into()));
let tokens = OAuth2AccessToken::list_all().await.unwrap();
let tokens = OAuth2AccessToken::list_all().unwrap();
assert_eq!(tokens.len(), 1);
let first = tokens.first().unwrap();

View File

@@ -328,7 +328,14 @@ pub struct Settings {
impl Settings {
pub fn init() -> Self {
let s = Self::parse();
// `cargo test` passes test-filter names and flags (e.g. --nocapture)
// as extra positional arguments. Try the full argv first; if clap
// rejects it, fall back to parsing with only the binary name so that
// the settings come entirely from environment variables.
let args: Vec<String> = std::env::args().collect();
let s = Self::try_parse_from(&args).unwrap_or_else(|_| {
Self::parse_from(std::iter::once(args[0].clone()))
});
if s.bichon_encrypt_password.is_none() && s.bichon_encrypt_password_file.is_none() {
panic!(
"One of --bichon_encrypt_password or --bichon_encrypt_password_file has to be set"

View File

@@ -25,8 +25,7 @@ use crate::{
use std::path::PathBuf;
use std::sync::LazyLock;
pub const META_FILE: &str = "meta.db";
pub const MAILBOX_FILE: &str = "mailbox.db";
const MEMDB_DIR: &str = "memdb";
const INDICES: &str = "bichon-indices";
const MAIL_METADATA: &str = "mail_metadata";
const ATTACHMENT_METADATA: &str = "attachment_metadata";
@@ -43,8 +42,7 @@ pub static DATA_DIR_MANAGER: LazyLock<DataDirManager> =
#[derive(Debug)]
pub struct DataDirManager {
pub root_dir: PathBuf,
pub meta_db: PathBuf,
pub mailbox_db: PathBuf,
pub memdb_dir: PathBuf,
pub temp_dir: PathBuf,
pub tls_cert: PathBuf,
pub tls_key: PathBuf,
@@ -84,8 +82,7 @@ impl DataDirManager {
Self {
root_dir: root_dir.clone(),
meta_db: root_dir.join(META_FILE),
mailbox_db: root_dir.join(MAILBOX_FILE),
memdb_dir: root_dir.join(MEMDB_DIR),
tls_key: root_dir.join(TLS_KEY),
tls_cert: root_dir.join(TLS_CERT),
log_dir: root_dir.join(LOG_DIR),

View File

@@ -16,31 +16,23 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use native_db::*;
use native_model::{native_model, Model};
//use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{
id,
{
database::{
async_find_impl, delete_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
update_impl,
},
error::{code::ErrorCode, BichonResult},
utils::net::parse_proxy_addr,
database::{
delete_impl, find_impl, insert_impl, list_all_impl, manager::DB_MANAGER, update_impl,
MemDbModel,
},
raise_error, utc_now,
error::{code::ErrorCode, BichonResult},
id, raise_error, utc_now,
utils::net::parse_proxy_addr,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
#[native_model(id = 8, version = 1)]
#[native_db]
pub struct Proxy {
/// The unique identifier for this proxy configuration.
#[primary_key]
pub id: u64,
/// The proxy URL (e.g., socks5://127.0.0.1:1080) used to route network requests.
@@ -53,6 +45,15 @@ pub struct Proxy {
pub updated_at: i64,
}
impl MemDbModel for Proxy {
fn collection() -> &'static str {
"proxies"
}
fn key(&self) -> String {
self.id.to_string()
}
}
impl Proxy {
/// Create a new Proxy instance with the given URL and timestamps.
pub fn new(url: String) -> Self {
@@ -64,59 +65,37 @@ impl Proxy {
}
}
pub async fn get(id: u64) -> BichonResult<Proxy> {
async_find_impl(DB_MANAGER.meta_db(), id)
.await?
.ok_or_else(|| {
raise_error!(
format!("Proxy with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
}
pub async fn list_all() -> BichonResult<Vec<Proxy>> {
list_all_impl(DB_MANAGER.meta_db()).await
}
pub async fn delete(id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<Proxy>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!("proxy missing".into(), ErrorCode::InternalError))
pub fn get(id: u64) -> BichonResult<Proxy> {
let key = id.to_string();
find_impl::<Proxy>(DB_MANAGER.db(), &key)?.ok_or_else(|| {
raise_error!(
format!("Proxy with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
.await
}
pub async fn update(id: u64, url: String) -> BichonResult<()> {
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<Proxy>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("Proxy with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
updated.url = url;
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
pub fn list_all() -> BichonResult<Vec<Proxy>> {
list_all_impl::<Proxy>(DB_MANAGER.db())
}
pub fn delete(id: u64) -> BichonResult<()> {
delete_impl::<Proxy>(DB_MANAGER.db(), &id.to_string())
}
pub fn update(id: u64, url: String) -> BichonResult<()> {
update_impl(DB_MANAGER.db(), &id.to_string(), move |current: Proxy| {
let mut updated = current.clone();
updated.url = url;
updated.updated_at = utc_now!();
Ok(updated)
})?;
Ok(())
}
pub async fn save(&self) -> BichonResult<()> {
pub fn save(&self) -> BichonResult<()> {
self.validate()?;
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
insert_impl(DB_MANAGER.db(), self.to_owned())
}
/// Validate that the URL is a valid SOCKS5 proxy URL.

View File

@@ -16,15 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[native_model(id = 2, version = 1)]
#[native_db]
pub struct SystemSetting {
#[primary_key]
pub key: String,
pub value: String,
pub created_at: i64,

View File

@@ -226,7 +226,7 @@ impl BlobManager {
}
}
pub async fn get_reader(account_id: u64, eid: String) -> BichonResult<Cursor<Bytes>> {
let (_, data) = reattach_eml_content(account_id, eid).await?;
pub fn get_reader(account_id: u64, eid: String) -> BichonResult<Cursor<Bytes>> {
let (_, data) = reattach_eml_content(account_id, eid)?;
Ok(Cursor::new(data))
}

View File

@@ -501,7 +501,7 @@ impl IndexManager {
Ok(Box::new(BooleanQuery::new(subqueries)))
}
pub async fn get_attachment_by_id(
pub fn get_attachment_by_id(
&self,
account_id: u64,
id: &str,
@@ -541,7 +541,7 @@ impl IndexManager {
}
}
pub async fn top_10_largest_attachments(
pub fn top_10_largest_attachments(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<Vec<LargestAttachment>> {
@@ -688,10 +688,7 @@ impl IndexManager {
Ok(())
}
pub async fn get_all_tags(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<Vec<TagCount>> {
pub fn get_all_tags(&self, accounts: Option<HashSet<u64>>) -> BichonResult<Vec<TagCount>> {
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
@@ -798,7 +795,7 @@ impl IndexManager {
Ok(())
}
pub async fn search(
pub fn search(
&self,
accounts: Option<HashSet<u64>>,
filter: AttachmentSearchFilter,
@@ -890,10 +887,7 @@ impl IndexManager {
Ok(self.reader.searcher())
}
pub async fn get_all_senders(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<HashSet<String>> {
pub fn get_all_senders(&self, accounts: Option<HashSet<u64>>) -> BichonResult<HashSet<String>> {
let searcher = self.create_searcher()?;
let query: Box<dyn Query> = match accounts {
@@ -923,7 +917,7 @@ impl IndexManager {
let doc: TantivyDocument = searcher
.doc(doc_address)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let contacts = extract_senders(&doc).await?;
let contacts = extract_senders(&doc)?;
for value in contacts {
contacts_set.insert(value);
}

View File

@@ -36,8 +36,8 @@ use crate::{
raise_error,
settings::dir::DATA_DIR_MANAGER,
store::{
envelope::Envelope,
blob::BLOB_MANAGER,
envelope::Envelope,
tantivy::{
fatal_commit,
fields::{
@@ -322,7 +322,7 @@ impl IndexManager {
if let Some(ref body_val) = filter.body {
let query_parser = QueryParser::for_index(&self.index, vec![f.f_body]);
let q = query_parser
.parse_query(body_val)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
@@ -518,7 +518,7 @@ impl IndexManager {
Box::new(boolean_query)
}
pub async fn get_envelope_by_id(
pub fn get_envelope_by_id(
&self,
account_id: u64,
envelope_id: &str,
@@ -558,7 +558,7 @@ impl IndexManager {
}
}
pub async fn top_10_largest_emails(
pub fn top_10_largest_emails(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<Vec<LargestEmail>> {
@@ -625,7 +625,7 @@ impl IndexManager {
}
}
pub async fn get_max_uid(&self, account_id: u64, mailbox_id: u64) -> BichonResult<Option<u64>> {
pub fn get_max_uid(&self, account_id: u64, mailbox_id: u64) -> BichonResult<Option<u64>> {
let searcher = self.create_searcher()?;
let query = self.mailbox_query(account_id, mailbox_id);
@@ -646,7 +646,7 @@ impl IndexManager {
Ok(Self::extract_max_uid(&agg_res))
}
pub async fn get_account_stats(&self, account_id: u64) -> BichonResult<AccountStats> {
pub fn get_account_stats(&self, account_id: u64) -> BichonResult<AccountStats> {
let searcher = self.create_searcher()?;
let query = self.account_query(account_id);
@@ -705,7 +705,7 @@ impl IndexManager {
pub async fn delete_account_envelopes(&self, account_id: u64) -> BichonResult<()> {
let query = self.account_query(account_id);
let (eml_content_hashes, attachments_content_hashes) =
self.collect_content_hashes(query).await?;
self.collect_content_hashes(query)?;
let query = self.account_query(account_id);
@@ -718,8 +718,7 @@ impl IndexManager {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if !eml_content_hashes.is_empty() || !attachments_content_hashes.is_empty() {
self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)
.await?;
self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)?;
}
Ok(())
}
@@ -738,7 +737,7 @@ impl IndexManager {
for mailbox_id in &mailbox_ids {
let query = self.mailbox_query(account_id, *mailbox_id);
let (eml_hashes, attachment_hashes) = self.collect_content_hashes(query).await?;
let (eml_hashes, attachment_hashes) = self.collect_content_hashes(query)?;
eml_content_hashes.extend(eml_hashes);
attachments_content_hashes.extend(attachment_hashes);
}
@@ -758,13 +757,12 @@ impl IndexManager {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if !eml_content_hashes.is_empty() || !attachments_content_hashes.is_empty() {
self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)
.await?;
self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)?;
}
Ok(())
}
async fn collect_content_hashes(
fn collect_content_hashes(
&self,
query: Box<dyn Query>,
) -> BichonResult<(HashSet<String>, HashSet<String>)> {
@@ -802,7 +800,7 @@ impl IndexManager {
Ok((eml_content_hashes, attachments_content_hashes))
}
async fn cleanup_unused_content(
fn cleanup_unused_content(
&self,
eml_content_hashes: HashSet<String>,
attachments_content_hashes: HashSet<String>,
@@ -862,7 +860,7 @@ impl IndexManager {
for eid in unique_ids {
let query = self.envelope_query(*account_id, eid);
let (eml_hashes, attachment_hashes) = self.collect_content_hashes(query).await?;
let (eml_hashes, attachment_hashes) = self.collect_content_hashes(query)?;
eml_content_hashes.extend(eml_hashes);
attachments_content_hashes.extend(attachment_hashes);
}
@@ -887,8 +885,7 @@ impl IndexManager {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if !eml_content_hashes.is_empty() || !attachments_content_hashes.is_empty() {
self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)
.await?;
self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)?;
}
Ok(())
@@ -918,10 +915,7 @@ impl IndexManager {
Ok(())
}
pub async fn get_all_tags(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<Vec<TagCount>> {
pub fn get_all_tags(&self, accounts: Option<HashSet<u64>>) -> BichonResult<Vec<TagCount>> {
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
@@ -945,7 +939,7 @@ impl IndexManager {
Ok(all_facets)
}
pub async fn get_all_contacts(
pub fn get_all_contacts(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<HashSet<String>> {
@@ -977,7 +971,7 @@ impl IndexManager {
let doc: TantivyDocument = searcher
.doc(doc_address)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let contacts = extract_contacts(&doc).await?;
let contacts = extract_contacts(&doc)?;
for value in contacts {
contacts_set.insert(value);
}
@@ -1067,7 +1061,7 @@ impl IndexManager {
Ok(())
}
pub async fn search(
pub fn search(
&self,
accounts: Option<HashSet<u64>>,
filter: EmailSearchFilter,
@@ -1159,7 +1153,7 @@ impl IndexManager {
Ok(self.reader.searcher())
}
pub async fn num_messages_in_thread(
pub fn num_messages_in_thread(
&self,
searcher: &Searcher,
account_id: u64,
@@ -1207,7 +1201,7 @@ impl IndexManager {
}
}
pub async fn list_thread_envelopes(
pub fn list_thread_envelopes(
&self,
account_id: u64,
thread_id: &str,
@@ -1218,9 +1212,7 @@ impl IndexManager {
assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0");
let searcher = self.create_searcher()?;
let total = self
.num_messages_in_thread(&searcher, account_id, thread_id)
.await?;
let total = self.num_messages_in_thread(&searcher, account_id, thread_id)?;
if total == 0 {
return Ok(DataPage {
current_page: Some(page),
@@ -1271,7 +1263,7 @@ impl IndexManager {
})
}
pub async fn get_dashboard_stats(
pub fn get_dashboard_stats(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<DashboardStats> {

View File

@@ -280,7 +280,7 @@ fn extract_vec_string_field(
Ok(value)
}
pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
pub fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
let fields = SchemaTools::email_fields();
let mut all_contacts = HashSet::new();
@@ -305,7 +305,7 @@ pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<Str
Ok(all_contacts)
}
pub async fn extract_senders(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
pub fn extract_senders(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
let fields = SchemaTools::attachment_fields();
let mut senders = HashSet::new();

View File

@@ -18,12 +18,11 @@
use std::collections::HashMap;
use super::error::code::ErrorCode;
use crate::database::manager::DB_MANAGER;
use crate::database::{
async_filter_by_secondary_key_impl, async_find_impl, delete_impl, with_transaction,
MemDbModel, delete_impl, filter_impl, find_impl, insert_impl, list_all_impl, update_impl, with_transaction
};
use crate::database::{insert_impl, list_all_impl, update_impl};
use crate::error::code::ErrorCode;
use crate::raise_error;
use crate::settings::cli::SETTINGS;
use crate::token::view::AccessTokenResp;
@@ -31,36 +30,12 @@ use crate::users::UserModel;
use crate::{
error::BichonResult, generate_token, token::payload::AccessTokenCreateRequest, utc_now,
};
use native_db::*;
use native_model::{native_model, Model};
//use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
pub mod payload;
pub mod view;
// Starting from version 0.2.0, this model is deprecated/no longer used
// #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
// #[native_model(id = 1, version = 1)]
// #[native_db]
// pub struct AccessToken {
// /// The unique token string used for authentication
// #[primary_key]
// pub token: String,
// /// A set of account information associated with the token.
// pub accounts: BTreeSet<AccountInfo>,
// /// The timestamp (in milliseconds since epoch) when the token was created.
// pub created_at: i64,
// /// The timestamp (in milliseconds since epoch) when the token was last updated.
// pub updated_at: i64,
// /// An optional description of the token's purpose or usage.
// pub description: Option<String>,
// /// The timestamp (in milliseconds since epoch) when the token was last used.
// pub last_access_at: i64,
// /// Optional access control settings
// pub acl: Option<AccessControl>,
// }
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum TokenType {
@@ -70,14 +45,10 @@ pub enum TokenType {
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
#[native_model(id = 11, version = 1)]
#[native_db]
pub struct AccessTokenModel {
/// The ID of the user who owns this token
#[secondary_key]
pub user_id: u64,
/// The unique token string used for authentication
#[primary_key]
pub token: String,
/// An optional name of the token.
pub name: Option<String>,
@@ -94,6 +65,15 @@ pub struct AccessTokenModel {
pub last_access_at: i64,
}
impl MemDbModel for AccessTokenModel {
fn collection() -> &'static str {
"tokens"
}
fn key(&self) -> String {
self.token.clone()
}
}
impl AccessTokenModel {
pub fn new_api_token(
token: String,
@@ -127,74 +107,56 @@ impl AccessTokenModel {
}
}
pub async fn reset_webui_token(user_id: u64) -> BichonResult<String> {
let old_token = Self::get_user_webui_token(user_id).await?;
pub fn reset_webui_token(user_id: u64) -> BichonResult<String> {
let old_token = Self::get_user_webui_token(user_id)?;
let new_token = Self::new_webui_token(user_id);
let new_token_str = new_token.token.clone();
match old_token {
Some(old) => {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
rw.remove(old)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.insert(new_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
with_transaction(DB_MANAGER.db(), move |txn| {
let txn = txn.delete(AccessTokenModel::collection(), old.token.clone());
txn.insert(AccessTokenModel::collection(), new_token.key(), &new_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
})?;
}
None => {
insert_impl(DB_MANAGER.meta_db(), new_token).await?;
insert_impl(DB_MANAGER.db(), new_token)?;
}
}
Ok(new_token_str)
}
pub async fn get_user_webui_token(user_id: u64) -> BichonResult<Option<AccessTokenModel>> {
let tokens = async_filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(),
AccessTokenModelKey::user_id,
user_id,
)
.await?;
pub fn get_user_webui_token(user_id: u64) -> BichonResult<Option<AccessTokenModel>> {
let tokens =
filter_impl::<AccessTokenModel, _>(DB_MANAGER.db(), move |t| t.user_id == user_id)?;
Ok(tokens
.into_iter()
.find(|t| t.token_type == TokenType::WebUI))
}
pub async fn get_user_api_tokens(user_id: u64) -> BichonResult<Vec<AccessTokenModel>> {
let tokens = async_filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(),
AccessTokenModelKey::user_id,
user_id,
)
.await?;
pub fn get_user_api_tokens(user_id: u64) -> BichonResult<Vec<AccessTokenModel>> {
let tokens =
filter_impl::<AccessTokenModel, _>(DB_MANAGER.db(), move |t| t.user_id == user_id)?;
Ok(tokens
.into_iter()
.filter(|t| t.token_type == TokenType::Api)
.collect())
}
pub async fn resolve_user_from_token(token: &str) -> BichonResult<UserModel> {
let token = token.to_string();
let token_option = async_find_impl::<AccessTokenModel>(DB_MANAGER.meta_db(), token).await?;
let token = match token_option {
Some(token) => token,
None => {
return Err(raise_error!(
pub fn resolve_user_from_token(token: &str) -> BichonResult<UserModel> {
let token_str = token.to_string();
let token_model = find_impl::<AccessTokenModel>(DB_MANAGER.db(), &token_str)?
.ok_or_else(|| {
raise_error!(
"Invalid access token provided. Please check your credentials.".into(),
ErrorCode::PermissionDenied
))
}
};
)
})?;
if matches!(token.token_type, TokenType::WebUI) {
let life = utc_now!() - token.created_at;
if matches!(token_model.token_type, TokenType::WebUI) {
let life = utc_now!() - token_model.created_at;
let max_life = SETTINGS.bichon_webui_token_expiration_hours * 60 * 60 * 1000;
if life > (max_life as i64) {
@@ -205,8 +167,8 @@ impl AccessTokenModel {
}
}
if matches!(token.token_type, TokenType::Api) {
if let Some(expire_at) = token.expire_at {
if matches!(token_model.token_type, TokenType::Api) {
if let Some(expire_at) = token_model.expire_at {
if utc_now!() > expire_at {
return Err(raise_error!(
"Your API token has expired and is no longer valid.".into(),
@@ -214,87 +176,57 @@ impl AccessTokenModel {
));
}
}
let token = token.token.clone();
update_impl(
DB_MANAGER.meta_db(),
|rw| {
rw.get()
.primary::<AccessTokenModel>(token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
"The access token does not exist or has been reset.".into(),
ErrorCode::ResourceNotFound
)
})
},
|current| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
},
)
.await?;
update_impl(DB_MANAGER.db(), &token_str, |current: AccessTokenModel| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
})?;
}
let user = UserModel::find(token.user_id)
.await?
let user = UserModel::find(token_model.user_id)
?
.ok_or_else(|| raise_error!("The user associated with this access token does not exist or may have been deleted.".into(), ErrorCode::ResourceNotFound))?;
Ok(user)
}
pub async fn create_api_token(
pub fn create_api_token(
user_id: u64,
request: AccessTokenCreateRequest,
) -> BichonResult<String> {
// Validate request parameters first
request.validate().await?;
request.validate()?;
let expire_at = request
.expire_in
.map(|hours| utc_now!() + (hours as i64) * 60 * 60 * 1000);
let token = generate_token!(128);
let access_token =
AccessTokenModel::new_api_token(token.clone(), user_id, request.name, expire_at);
insert_impl(DB_MANAGER.meta_db(), access_token).await?;
insert_impl(DB_MANAGER.db(), access_token)?;
Ok(token)
}
pub async fn delete(token: &str) -> BichonResult<()> {
let token = token.to_string();
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<AccessTokenModel>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("Token '{}' not found during deletion process.", token),
ErrorCode::ResourceNotFound
)
})
pub fn delete(token: &str) -> BichonResult<()> {
delete_impl::<AccessTokenModel>(DB_MANAGER.db(), token)
}
pub fn get_token(token: &str) -> BichonResult<AccessTokenModel> {
find_impl::<AccessTokenModel>(DB_MANAGER.db(), token)?.ok_or_else(|| {
raise_error!(
format!("Access token '{}' not found", token),
ErrorCode::ResourceNotFound
)
})
.await
}
pub async fn get_token(token: &str) -> BichonResult<AccessTokenModel> {
async_find_impl(DB_MANAGER.meta_db(), token.to_string())
.await?
.ok_or_else(|| {
raise_error!(
format!("Access token '{}' not found", token),
ErrorCode::ResourceNotFound
)
})
}
pub fn list_all_api_tokens() -> BichonResult<Vec<AccessTokenResp>> {
let users = UserModel::list_all()?;
let all = list_all_impl::<AccessTokenModel>(DB_MANAGER.db())?;
pub async fn list_all_api_tokens() -> BichonResult<Vec<AccessTokenResp>> {
let users = UserModel::list_all().await?;
let mut all = list_all_impl::<AccessTokenModel>(DB_MANAGER.meta_db()).await?;
all.retain(|t| t.token_type == TokenType::Api);
let user_map: HashMap<u64, UserModel> = users.into_iter().map(|u| (u.id, u)).collect();
let resp = all
.into_iter()
.filter(|t| t.token_type == TokenType::Api)
.map(|token| {
let user = user_map.get(&token.user_id);
AccessTokenResp {

View File

@@ -39,7 +39,7 @@ pub struct AccessTokenCreateRequest {
}
impl AccessTokenCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
pub fn validate(&self) -> BichonResult<()> {
if let Some(expire_in) = self.expire_in {
if expire_in == 0 {
return Err(raise_error!(

View File

@@ -26,7 +26,7 @@ pub struct UserManager;
impl Initialize for UserManager {
async fn initialize() -> BichonResult<()> {
UserRole::ensure_default_roles_exists().await?;
UserModel::ensure_default_admin_exists().await
UserRole::ensure_default_roles_exists()?;
UserModel::ensure_default_admin_exists()
}
}

View File

@@ -34,8 +34,8 @@ pub struct MinimalUser {
}
impl MinimalUser {
pub async fn list_all() -> BichonResult<Vec<MinimalUser>> {
let all_users = list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?;
pub fn list_all() -> BichonResult<Vec<MinimalUser>> {
let all_users = list_all_impl::<UserModel>(DB_MANAGER.db())?;
let minimal_list = all_users
.into_iter()
.map(|user| MinimalUser {

View File

@@ -17,27 +17,23 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
decrypt, encrypt, generate_token, id, raise_error, utc_now,
{
database::{
async_find_impl, async_secondary_find_impl, batch_delete_impl, delete_impl,
list_all_impl, manager::DB_MANAGER, update_impl, with_transaction,
},
error::{code::ErrorCode, BichonResult},
token::{AccessTokenModel, AccessTokenModelKey, TokenType},
users::{
acl::AccessControl,
payload::{UserCreateRequest, UserUpdateRequest},
permissions::Permission,
role::{UserRole, DEFAULT_ADMIN_ROLE_ID},
view::UserView,
},
database::{
delete_impl, filter_impl, find_impl, list_all_impl, manager::DB_MANAGER, update_impl,
with_transaction, MemDbModel,
},
decrypt, encrypt,
error::{code::ErrorCode, BichonResult},
generate_token, id, raise_error,
token::{AccessTokenModel, TokenType},
users::{
acl::AccessControl,
payload::{UserCreateRequest, UserUpdateRequest},
permissions::Permission,
role::{UserRole, DEFAULT_ADMIN_ROLE_ID},
view::UserView,
},
utc_now,
};
use itertools::Itertools;
use native_db::*;
use native_model::{native_model, Model};
//use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use tracing::warn;
@@ -64,46 +60,9 @@ pub struct LoginResult {
pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 10, version = 1)]
#[native_db]
pub struct BichonUser {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 10, version = 2, from = BichonUser)]
#[native_db]
pub struct BichonUserV2 {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
@@ -130,6 +89,15 @@ pub struct BichonUserV2 {
pub language: Option<String>,
}
impl MemDbModel for BichonUserV2 {
fn collection() -> &'static str {
"users"
}
fn key(&self) -> String {
self.id.to_string()
}
}
impl BichonUserV2 {
pub fn is_using_role(&self, role_id: u64) -> bool {
if self.global_roles.contains(&role_id) {
@@ -142,15 +110,15 @@ impl BichonUserV2 {
false
}
pub async fn list_all() -> BichonResult<Vec<UserModel>> {
Ok(list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?)
pub fn list_all() -> BichonResult<Vec<UserModel>> {
Ok(list_all_impl::<UserModel>(DB_MANAGER.db())?)
}
async fn get_all_permissions(&self) -> HashSet<String> {
fn get_all_permissions(&self) -> HashSet<String> {
let mut all_perms = HashSet::new();
for &role_id in &self.global_roles {
if let Ok(Some(role)) = UserRole::find(role_id).await {
if let Ok(Some(role)) = UserRole::find(role_id) {
for perm in role.permissions {
all_perms.insert(perm);
}
@@ -227,88 +195,77 @@ impl BichonUserV2 {
}
}
pub async fn is_admin(&self) -> bool {
self.get_all_permissions().await.contains(Permission::ROOT)
pub fn is_admin(&self) -> bool {
self.get_all_permissions().contains(Permission::ROOT)
}
pub async fn ensure_default_admin_exists() -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
pub fn ensure_default_admin_exists() -> BichonResult<()> {
let now = utc_now!();
// 1. Try to get the existing admin user
let admin = rw
.get()
.primary::<UserModel>(DEFAULT_ADMIN_USER_ID)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// 1. Try to get the existing admin user
let admin = find_impl::<UserModel>(DB_MANAGER.db(), &DEFAULT_ADMIN_USER_ID.to_string())?;
if admin.is_none() {
// 2. Insert the BichonUser with the updated schema
rw.insert(UserModel {
id: DEFAULT_ADMIN_USER_ID,
username: "admin".into(),
email: "placeholder@example.com".into(),
password: Some(encrypt!("admin@bichon")?),
if admin.is_none() {
// 2. Insert the BichonUser with the updated schema
let user = UserModel {
id: DEFAULT_ADMIN_USER_ID,
username: "admin".into(),
email: "placeholder@example.com".into(),
password: Some(encrypt!("admin@bichon")?),
// Use global_roles as defined in our new schema
global_roles: vec![DEFAULT_ADMIN_ROLE_ID],
// Use global_roles as defined in our new schema
global_roles: vec![DEFAULT_ADMIN_ROLE_ID],
// Admin usually doesn't need specific scoped access
account_access_map: BTreeMap::new(),
// Admin usually doesn't need specific scoped access
account_access_map: BTreeMap::new(),
avatar: None,
created_at: now,
updated_at: now,
description: Some("System default administrator".into()),
acl: None,
theme: None,
language: None,
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
avatar: None,
created_at: now,
updated_at: now,
description: Some("System default administrator".into()),
acl: None,
theme: None,
language: None,
};
// 3. Generate and insert an initial access token for the first-time setup
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Initial Setup Token".into()),
user_id: DEFAULT_ADMIN_USER_ID,
token_type: TokenType::WebUI,
expire_at: None, // Admin setup token usually persistent until changed
};
// 3. Generate and insert an initial access token for the first-time setup
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Initial Setup Token".into()),
user_id: DEFAULT_ADMIN_USER_ID,
token_type: TokenType::WebUI,
expire_at: None, // Admin setup token usually persistent until changed
};
rw.upsert(access_token)
with_transaction(DB_MANAGER.db(), move |txn| {
let txn = txn
.insert("users", DEFAULT_ADMIN_USER_ID.to_string(), &user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.upsert("tokens", access_token.token.clone(), &access_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
})
.await?;
Ok(txn)
})?;
}
Ok(())
}
pub async fn authenticate_user(
username: String,
password: String,
) -> BichonResult<LoginResult> {
let user_option = async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::username,
username.clone(),
)
.await?;
let user = match user_option {
pub fn authenticate_user(username: String, password: String) -> BichonResult<LoginResult> {
// Find by username
let username_for_first = username.clone();
let users = filter_impl::<UserModel, _>(DB_MANAGER.db(), move |u| {
u.username == username_for_first
})?;
let user = match users.into_iter().next() {
Some(u) => u,
None => {
match async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::email,
username,
)
.await?
{
// Fallback: find by email
let users =
filter_impl::<UserModel, _>(DB_MANAGER.db(), move |u| u.email == username)?;
match users.into_iter().next() {
Some(u) => u,
None => {
return Ok(LoginResult {
@@ -327,7 +284,7 @@ impl BichonUserV2 {
Some(encrypted_password) => {
let decrypted = decrypt!(encrypted_password)?;
if password == decrypted {
let new_token = AccessTokenModel::reset_webui_token(user.id).await?;
let new_token = AccessTokenModel::reset_webui_token(user.id)?;
Ok(LoginResult {
success: true,
error_message: None,
@@ -358,7 +315,7 @@ impl BichonUserV2 {
success: false,
error_message: Some(
format!(
"User '{}' has no password set. Please try logging in with an alternative method (e.g., OAuth/SSO).",
"User '{}' has no password set. Please try logging in with an alternative method (e.g., OAuth/SSO).",
user.username
)
),
@@ -370,20 +327,16 @@ impl BichonUserV2 {
}
}
pub async fn find(user_id: u64) -> BichonResult<Option<UserModel>> {
async_find_impl(DB_MANAGER.meta_db(), user_id).await
pub fn find(user_id: u64) -> BichonResult<Option<UserModel>> {
find_impl::<UserModel>(DB_MANAGER.db(), &user_id.to_string())
}
pub async fn check_username_conflict(username: &str) -> BichonResult<()> {
// Check username duplicate
if async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::username,
username.to_string(),
)
.await?
.is_some()
{
pub fn check_username_conflict(username: &str) -> BichonResult<()> {
let username_clone = username.to_string();
let users =
filter_impl::<UserModel, _>(DB_MANAGER.db(), move |u| u.username == username_clone)?;
if users.into_iter().next().is_some() {
return Err(raise_error!(
format!("Username '{}' is already taken.", username).into(),
ErrorCode::AlreadyExists
@@ -393,16 +346,11 @@ impl BichonUserV2 {
Ok(())
}
pub async fn check_email_conflict(email: &str) -> BichonResult<()> {
// Check email duplicate
if async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::email,
email.to_string(),
)
.await?
.is_some()
{
pub fn check_email_conflict(email: &str) -> BichonResult<()> {
let email_clone = email.to_string();
let users = filter_impl::<UserModel, _>(DB_MANAGER.db(), move |u| u.email == email_clone)?;
if users.into_iter().next().is_some() {
return Err(raise_error!(
format!("Email '{}' is already registered.", email).into(),
ErrorCode::AlreadyExists
@@ -412,10 +360,10 @@ impl BichonUserV2 {
Ok(())
}
pub async fn create(request: UserCreateRequest) -> BichonResult<UserModel> {
request.validate().await?;
Self::check_username_conflict(&request.username).await?;
Self::check_email_conflict(&request.email).await?;
pub fn create(request: UserCreateRequest) -> BichonResult<UserModel> {
request.validate()?;
Self::check_username_conflict(&request.username)?;
Self::check_email_conflict(&request.email)?;
let password_hash = Some(encrypt!(&request.password)?);
let now = utc_now!();
@@ -439,37 +387,31 @@ impl BichonUserV2 {
let user_clone = user.clone();
// 4. Atomic transaction for User and Initial Token
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let user_id = user.id;
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Default WebUI Token".into()),
user_id: user.id,
token_type: TokenType::WebUI,
expire_at: None,
};
// Insert User
rw.insert(user)
with_transaction(DB_MANAGER.db(), move |txn| {
let txn = txn
.insert("users", user.key(), &user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.insert("tokens", access_token.token.clone(), &access_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Create initial WebUI access token
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Default WebUI Token".into()),
user_id,
token_type: TokenType::WebUI,
expire_at: None,
};
rw.insert(access_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
Ok(txn)
})?;
Ok(user_clone)
}
//delete user
pub async fn remove(id: u64) -> BichonResult<()> {
pub fn remove(id: u64) -> BichonResult<()> {
if DEFAULT_ADMIN_USER_ID == id {
return Err(raise_error!(
format!("The default admin user (id={}) cannot be removed", id),
@@ -477,36 +419,37 @@ impl BichonUserV2 {
));
}
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<UserModel>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("The User with id={id} that you want to delete was not found."),
ErrorCode::ResourceNotFound
)
})
})
.await?;
delete_impl::<UserModel>(DB_MANAGER.db(), &id.to_string())?;
// Find and delete tokens belonging to this user
let uid = id;
let coll = DB_MANAGER.db().collection("tokens");
let all_tokens: Vec<AccessTokenModel> = coll
.list_all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let token_keys: Vec<String> = all_tokens
.into_iter()
.filter(|t| t.user_id == uid)
.map(|t| t.token)
.collect();
if !token_keys.is_empty() {
with_transaction(DB_MANAGER.db(), move |txn| {
let mut txn = txn;
for key in token_keys {
txn = txn.delete("tokens", key);
}
Ok(txn)
})?;
}
batch_delete_impl(DB_MANAGER.meta_db(), move |rw| {
let tokens: Vec<AccessTokenModel> = rw
.scan()
.secondary::<AccessTokenModel>(AccessTokenModelKey::user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(tokens)
})
.await?;
Ok(())
}
pub async fn update(id: u64, request: UserUpdateRequest) -> BichonResult<()> {
let _ = &request.validate().await?;
pub fn update(id: u64, request: UserUpdateRequest) -> BichonResult<()> {
let _ = &request.validate()?;
let password_changed = request.password.is_some();
let is_default_admin = id == DEFAULT_ADMIN_USER_ID;
@@ -530,14 +473,12 @@ impl BichonUserV2 {
}
if let Some(username) = &request.username {
let user_option = async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::username,
username.to_string(),
)
.await?;
let username_clone = username.clone();
let users = filter_impl::<UserModel, _>(DB_MANAGER.db(), move |u| {
u.username == username_clone
})?;
if let Some(u) = user_option {
if let Some(u) = users.into_iter().next() {
if u.id != id {
return Err(raise_error!(
format!("Username '{}' is already taken.", username).into(),
@@ -548,14 +489,11 @@ impl BichonUserV2 {
}
if let Some(email) = &request.email {
let user_option = async_secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::email,
email.to_string(),
)
.await?;
let email_clone = email.clone();
let users =
filter_impl::<UserModel, _>(DB_MANAGER.db(), move |u| u.email == email_clone)?;
if let Some(u) = user_option {
if let Some(u) = users.into_iter().next() {
if u.id != id {
return Err(raise_error!(
format!("Email '{}' is already registered.", email).into(),
@@ -565,74 +503,59 @@ impl BichonUserV2 {
}
}
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<UserModel>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(username) = request.username {
updated.username = username;
}
if let Some(email) = request.email {
updated.email = email;
}
if let Some(desc) = request.description {
updated.description = Some(desc);
}
if let Some(password) = request.password {
updated.password = Some(encrypt!(&password)?);
}
update_impl::<UserModel>(DB_MANAGER.db(), &id.to_string(), move |current| {
let mut updated = current.clone();
if let Some(username) = request.username {
updated.username = username;
}
if let Some(email) = request.email {
updated.email = email;
}
if let Some(desc) = request.description {
updated.description = Some(desc);
}
if let Some(password) = request.password {
updated.password = Some(encrypt!(&password)?);
}
if let Some(global_roles) = request.global_roles {
updated.global_roles = global_roles;
}
if let Some(global_roles) = request.global_roles {
updated.global_roles = global_roles;
}
if let Some(acl) = request.acl {
updated.acl = Some(acl);
}
if let Some(acl) = request.acl {
updated.acl = Some(acl);
}
if let Some(account_access_map) = request.account_access_map {
updated.account_access_map = account_access_map;
}
if let Some(account_access_map) = request.account_access_map {
updated.account_access_map = account_access_map;
}
if let Some(avatar_base64) = request.avatar_base64 {
updated.avatar = Some(avatar_base64);
}
if let Some(avatar_base64) = request.avatar_base64 {
updated.avatar = Some(avatar_base64);
}
if let Some(theme) = request.theme {
updated.theme = Some(theme);
}
if let Some(theme) = request.theme {
updated.theme = Some(theme);
}
if let Some(language) = request.language {
updated.language = Some(language);
}
if let Some(language) = request.language {
updated.language = Some(language);
}
updated.updated_at = utc_now!();
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
Ok(updated)
})?;
if password_changed {
AccessTokenModel::reset_webui_token(id).await?;
AccessTokenModel::reset_webui_token(id)?;
}
Ok(())
}
async fn list_authorized_users(account_id: u64) -> BichonResult<Vec<UserModel>> {
let all = Self::list_all().await?;
fn list_authorized_users(account_id: u64) -> BichonResult<Vec<UserModel>> {
let all = Self::list_all()?;
let result: Vec<UserModel> = all
.into_iter()
.filter(|e| e.account_access_map.contains_key(&account_id))
@@ -640,76 +563,24 @@ impl BichonUserV2 {
Ok(result)
}
pub async fn cleanup_account(account_id: u64) -> BichonResult<()> {
let users = Self::list_authorized_users(account_id).await?;
pub fn cleanup_account(account_id: u64) -> BichonResult<()> {
let users = Self::list_authorized_users(account_id)?;
if users.is_empty() {
return Ok(());
}
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
for user in users {
let current = rw
.get()
.primary::<UserModel>(user.id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User {} not found", user.id),
ErrorCode::ResourceNotFound
)
})?;
let now = utc_now!();
for user in users {
let key = user.id.to_string();
update_impl::<UserModel>(DB_MANAGER.db(), &key, move |current| {
let mut updated = current.clone();
if updated.account_access_map.remove(&account_id).is_some() {
updated.updated_at = now;
rw.update(current, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
Ok(())
})
.await?;
Ok(updated)
})?;
}
Ok(())
}
}
impl From<BichonUserV2> for BichonUser {
fn from(value: BichonUserV2) -> Self {
BichonUser {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
}
}
}
impl From<BichonUser> for BichonUserV2 {
fn from(value: BichonUser) -> Self {
BichonUserV2 {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
theme: None,
language: None,
}
}
}

View File

@@ -95,7 +95,7 @@ pub struct RoleCreateRequest {
}
impl RoleCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
pub fn validate(&self) -> BichonResult<()> {
let trimmed_name = self.name.trim();
if trimmed_name.is_empty() {
return Err(raise_error!(
@@ -148,7 +148,7 @@ pub struct RoleUpdateRequest {
}
impl RoleUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
pub fn validate(&self) -> BichonResult<()> {
// 1. Ensure at least one field is provided for the update
if self.name.is_none() && self.description.is_none() && self.permissions.is_none() {
return Err(raise_error!(
@@ -216,7 +216,10 @@ impl RoleUpdateRequest {
pub struct UserCreateRequest {
pub username: String,
#[cfg_attr(feature = "web-api", oai(validator(custom = "crate::common::validator::EmailValidator")))]
#[cfg_attr(
feature = "web-api",
oai(validator(custom = "crate::common::validator::EmailValidator"))
)]
pub email: String,
pub password: String,
@@ -236,7 +239,7 @@ pub struct UserCreateRequest {
}
impl UserCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
pub fn validate(&self) -> BichonResult<()> {
let username_len = self.username.len();
// 1. Username constraints
@@ -279,7 +282,7 @@ impl UserCreateRequest {
validate_theme(&self.theme)?;
validate_language(&self.language)?;
let all_roles = UserRole::list_all().await?;
let all_roles = UserRole::list_all()?;
let role_type_map: HashMap<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
@@ -302,7 +305,7 @@ impl UserCreateRequest {
}
for (aid, rid) in &self.account_access_map {
if AccountModel::async_find(*aid).await?.is_none() {
if AccountModel::find(*aid)?.is_none() {
return Err(raise_error!(
format!("Account {} not found", aid),
ErrorCode::InvalidParameter
@@ -353,7 +356,10 @@ impl UserCreateRequest {
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct UserUpdateRequest {
pub username: Option<String>,
#[cfg_attr(feature = "web-api", oai(validator(custom = "crate::common::validator::EmailValidator")))]
#[cfg_attr(
feature = "web-api",
oai(validator(custom = "crate::common::validator::EmailValidator"))
)]
pub email: Option<String>,
pub password: Option<String>,
pub avatar_base64: Option<String>,
@@ -367,7 +373,7 @@ pub struct UserUpdateRequest {
}
impl UserUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
pub fn validate(&self) -> BichonResult<()> {
if let Some(username) = &self.username {
let len = username.len();
if len < 3 || len > 32 {
@@ -391,7 +397,7 @@ impl UserUpdateRequest {
validate_theme(&self.theme)?;
validate_language(&self.language)?;
let all_roles = UserRole::list_all().await?;
let all_roles = UserRole::list_all()?;
let role_type_map: HashMap<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
@@ -423,7 +429,7 @@ impl UserUpdateRequest {
if let Some(account_access_map) = &self.account_access_map {
for (aid, rid) in account_access_map {
if AccountModel::async_find(*aid).await?.is_none() {
if AccountModel::find(*aid)?.is_none() {
return Err(raise_error!(
format!("Account {} not found", aid),
ErrorCode::InvalidParameter

View File

@@ -21,17 +21,14 @@ use std::{
fmt::{self, Display},
};
use native_db::*;
use native_model::{native_model, Model};
//use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use crate::{
id, raise_error, utc_now,
{
database::{
async_find_impl, delete_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
update_impl, with_transaction,
delete_impl, find_impl, insert_impl, list_all_impl, manager::DB_MANAGER, update_impl,
with_transaction, MemDbModel,
},
error::{code::ErrorCode, BichonResult},
users::{
@@ -184,10 +181,7 @@ pub enum RoleType {
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
#[native_model(id = 9, version = 1)]
#[native_db]
pub struct UserRole {
#[primary_key]
pub id: u64,
pub name: String,
pub description: Option<String>,
@@ -198,8 +192,17 @@ pub struct UserRole {
pub updated_at: i64,
}
impl MemDbModel for UserRole {
fn collection() -> &'static str {
"roles"
}
fn key(&self) -> String {
self.id.to_string()
}
}
impl UserRole {
pub async fn ensure_default_roles_exists() -> BichonResult<()> {
pub fn ensure_default_roles_exists() -> BichonResult<()> {
let builtin_roles = vec![
(BuiltinRole::Admin, DEFAULT_ADMIN_ROLE_ID, RoleType::Global),
(
@@ -224,24 +227,19 @@ impl UserRole {
),
];
with_transaction(DB_MANAGER.meta_db(), move |rw| {
with_transaction(DB_MANAGER.db(), move |txn| {
let mut txn = txn;
let now = utc_now!();
for (role, role_id, role_type) in builtin_roles {
let exists = rw
.get()
.primary::<UserRole>(role_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.is_some();
let key = role_id.to_string();
let exists = find_impl::<UserRole>(DB_MANAGER.db(), &key)?.is_some();
if !exists {
let permissions: BTreeSet<String> = role
.get_permissions()
.into_iter()
.map(|s| s.to_string())
.collect();
rw.insert(UserRole {
let role_item = UserRole {
id: role_id,
name: role.to_string(),
description: Some(role.description().to_string()),
@@ -250,27 +248,28 @@ impl UserRole {
updated_at: now,
is_builtin: true,
role_type,
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
};
txn = txn
.insert(UserRole::collection(), role_item.key(), &role_item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
Ok(())
})
.await?;
Ok(txn)
})?;
Ok(())
}
pub async fn list_all() -> BichonResult<Vec<UserRole>> {
list_all_impl(DB_MANAGER.meta_db()).await
pub fn list_all() -> BichonResult<Vec<UserRole>> {
list_all_impl::<UserRole>(DB_MANAGER.db())
}
pub async fn find(role_id: u64) -> BichonResult<Option<UserRole>> {
async_find_impl(DB_MANAGER.meta_db(), role_id).await
pub fn find(role_id: u64) -> BichonResult<Option<UserRole>> {
find_impl::<UserRole>(DB_MANAGER.db(), &role_id.to_string())
}
pub async fn create(request: RoleCreateRequest) -> BichonResult<UserRole> {
let _ = &request.validate().await?;
pub fn create(request: RoleCreateRequest) -> BichonResult<UserRole> {
let _ = &request.validate()?;
let now = utc_now!();
let new_role = UserRole {
id: id!(64),
@@ -282,21 +281,21 @@ impl UserRole {
is_builtin: false,
role_type: request.role_type,
};
insert_impl(DB_MANAGER.meta_db(), new_role.clone()).await?;
insert_impl(DB_MANAGER.db(), new_role.clone())?;
Ok(new_role)
}
pub async fn update(id: u64, request: RoleUpdateRequest) -> BichonResult<()> {
pub fn update(id: u64, request: RoleUpdateRequest) -> BichonResult<()> {
if is_builtin(id) && request.permissions.is_some() {
return Err(raise_error!(
"The permissions of a builtin role are immutable. Please create a custom role instead.".into(),
ErrorCode::Forbidden
));
}
let _ = &request.validate().await?;
let _ = &request.validate()?;
if let Some(permissions) = &request.permissions {
let role = Self::find(id).await?.ok_or_else(|| {
let role = Self::find(id)?.ok_or_else(|| {
raise_error!(
format!("UserRole with id={} not found", id),
ErrorCode::ResourceNotFound
@@ -306,40 +305,27 @@ impl UserRole {
}
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<UserRole>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("UserRole with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
DB_MANAGER.db(),
&id.to_string(),
move |current: UserRole| {
let mut updated = current.clone();
if let Some(name) = request.name {
updated.name = name;
}
if let Some(desc) = request.description {
updated.description = Some(desc);
}
if let Some(permissions) = request.permissions {
updated.permissions = permissions;
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
)?;
Ok(())
}
pub async fn delete(id: u64) -> BichonResult<()> {
pub fn delete(id: u64) -> BichonResult<()> {
if is_builtin(id) {
return Err(raise_error!(
format!("Cannot delete a default system role (ID: {}).", id),
@@ -347,7 +333,7 @@ impl UserRole {
));
}
let all_users = UserModel::list_all().await?;
let all_users = UserModel::list_all()?;
let active_users: Vec<String> = all_users
.iter()
.filter(|user| user.is_using_role(id))
@@ -365,17 +351,6 @@ impl UserRole {
));
}
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<UserRole>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("UserRole '{}' not found during deletion process.", id),
ErrorCode::ResourceNotFound
)
})
})
.await
delete_impl::<UserRole>(DB_MANAGER.db(), &id.to_string())
}
}

View File

@@ -136,12 +136,49 @@ mod tests {
use super::*;
#[test]
fn test_encrypt_decrypt() {
let password = "my_secure_passwasdasdasdasdasord";
let plaintext = "Helloasdasdasdasdasd, World!";
fn test_encrypt_decrypt_roundtrip() {
let password = "my_secure_password";
let plaintext = "Hello, World!";
let encrypted = internal_encrypt_string(password, plaintext).unwrap();
println!("{}", &encrypted);
let decrypted = internal_decrypt_string(password, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_wrong_password_fails() {
let encrypted =
internal_encrypt_string("correct_password", "secret").unwrap();
assert!(internal_decrypt_string("wrong_password", &encrypted).is_err());
}
#[test]
fn test_empty_string() {
let encrypted = internal_encrypt_string("pw", "").unwrap();
let decrypted = internal_decrypt_string("pw", &encrypted).unwrap();
assert_eq!(decrypted, "");
}
#[test]
fn test_unicode_content() {
let plaintext = "你好世界 🌍 émoji test";
let encrypted = internal_encrypt_string("pw", plaintext).unwrap();
let decrypted = internal_decrypt_string("pw", &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encryption_produces_different_ciphertexts() {
let p1 = internal_encrypt_string("pw", "data").unwrap();
let p2 = internal_encrypt_string("pw", "data").unwrap();
// Same plaintext should produce different ciphertexts (random salt+nonce)
assert_ne!(p1, p2);
}
#[test]
fn test_decrypt_corrupted_data_fails() {
let mut encrypted = internal_encrypt_string("pw", "data").unwrap();
// Corrupt the base64 data by modifying a character
encrypted.push('X');
assert!(internal_decrypt_string("pw", &encrypted).is_err());
}
}

View File

@@ -45,3 +45,47 @@ pub fn extract_text(html: String) -> String {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_text_from_plain_html() {
let html = "<html><body><p>Hello World</p></body></html>".to_string();
let text = extract_text(html);
assert!(text.contains("Hello World"));
}
#[test]
fn extract_text_strips_tags() {
let html = "<div><h1>Title</h1><p>Paragraph with <b>bold</b> text.</p></div>".to_string();
let text = extract_text(html);
assert!(text.contains("Title"));
assert!(text.contains("Paragraph"));
assert!(text.contains("bold"));
assert!(!text.contains("<h1>"));
assert!(!text.contains("<b>"));
}
#[test]
fn extract_text_empty_string() {
let html = "".to_string();
let text = extract_text(html);
assert!(text.is_empty());
}
#[test]
fn extract_text_plain_text_passthrough() {
let html = "Just some plain text without any HTML tags.".to_string();
let text = extract_text(html);
assert!(text.contains("plain text"));
}
#[test]
fn extract_text_with_links() {
let html = "<a href=\"https://example.com\">Click here</a>".to_string();
let text = extract_text(html);
assert!(text.contains("Click here"));
}
}

View File

@@ -351,3 +351,150 @@ pub fn compute_content_hash(content: &[u8]) -> String {
let hash = blake3::hash(content);
hash.to_hex().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
// ── hash ──────────────────────────────────────────────────────────
#[test]
fn hash_is_deterministic() {
let a = hash("hello world");
let b = hash("hello world");
assert_eq!(a, b);
}
#[test]
fn hash_different_inputs_produce_different_outputs() {
let a = hash("hello");
let b = hash("world");
assert_ne!(a, b);
}
#[test]
fn hash_empty_string() {
let h = hash("");
assert!(h < (1u64 << 53));
}
#[test]
fn hex_hash_is_deterministic() {
let a = hex_hash("test");
let b = hex_hash("test");
assert_eq!(a, b);
assert_eq!(a.len(), 32);
}
#[test]
fn hex_hash_different_inputs_produce_different_outputs() {
assert_ne!(hex_hash("a"), hex_hash("b"));
}
// ── create_hash / create_hash2 ────────────────────────────────────
#[test]
fn create_hash_deterministic() {
let a = create_hash(1, "INBOX");
let b = create_hash(1, "INBOX");
assert_eq!(a, b);
}
#[test]
fn create_hash_different_accounts_differ() {
assert_ne!(create_hash(1, "INBOX"), create_hash(2, "INBOX"));
}
#[test]
fn create_hash_different_fields_differ() {
assert_ne!(create_hash(1, "INBOX"), create_hash(1, "Sent"));
}
#[test]
fn create_hash2_deterministic() {
let a = create_hash2(1, 100, "INBOX");
let b = create_hash2(1, 100, "INBOX");
assert_eq!(a, b);
}
#[test]
fn create_hash2_different_inputs_differ() {
assert_ne!(create_hash2(1, 100, "INBOX"), create_hash2(1, 200, "INBOX"));
assert_ne!(create_hash2(1, 100, "INBOX"), create_hash2(1, 100, "Sent"));
}
// ── compute_content_hash ──────────────────────────────────────────
#[test]
fn compute_content_hash_deterministic() {
let data = b"test content";
let a = compute_content_hash(data);
let b = compute_content_hash(data);
assert_eq!(a, b);
assert_eq!(a.len(), 64);
}
#[test]
fn compute_content_hash_different_content_differ() {
assert_ne!(compute_content_hash(b"a"), compute_content_hash(b"b"));
}
// ── validate_email ────────────────────────────────────────────────
#[test]
fn validate_email_valid() {
assert!(validate_email("user@example.com").is_ok());
assert!(validate_email("a@b.co").is_ok());
assert!(validate_email("test.user+tag@domain.com").is_ok());
}
#[test]
fn validate_email_invalid() {
assert!(validate_email("not-an-email").is_err());
assert!(validate_email("").is_err());
assert!(validate_email("@domain.com").is_err());
assert!(validate_email("user@").is_err());
}
// ── generate_token_impl ───────────────────────────────────────────
#[test]
fn generate_token_has_expected_length() {
let token = generate_token_impl(256);
// URL-safe base64 encodes 3 bytes → 4 chars, so length is roughly
// ceil(bit_strength / 24) * 4, but chars like /+=-_ are replaced
assert!(!token.is_empty());
}
#[test]
fn generate_token_does_not_contain_special_chars() {
for _ in 0..10 {
let token = generate_token_impl(256);
assert!(!token.contains('/'));
assert!(!token.contains('+'));
assert!(!token.contains('-'));
assert!(!token.contains('_'));
}
}
#[test]
fn generate_token_is_random() {
let a = generate_token_impl(256);
let b = generate_token_impl(256);
assert_ne!(a, b);
}
// ── decode_avatar_bytes ───────────────────────────────────────────
#[test]
fn decode_avatar_bytes_valid() {
// "avatar" in base64 = "YXZhdGFy"
let result = decode_avatar_bytes("YXZhdGFy").unwrap();
assert_eq!(result, b"avatar");
}
#[test]
fn decode_avatar_bytes_invalid_base64() {
assert!(decode_avatar_bytes("!!!invalid!!!").is_err());
}
}

View File

@@ -111,7 +111,7 @@ async fn connect_with_optional_proxy(
) -> BichonResult<TcpStream> {
// Try if proxy is enabled
if let Some(proxy_id) = use_proxy {
let proxy = Proxy::get(proxy_id).await?;
let proxy = Proxy::get(proxy_id)?;
let proxy = parse_proxy_addr(&proxy.url)?;
return timeout(TIMEOUT, Socks5Stream::connect(proxy, address))
.await

14
crates/memdb/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "memdb"
version = "0.1.0"
edition = "2021"
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror = "=2.0.18"
tokio.workspace = true
[dev-dependencies]
rand = "=0.9.2"
tempfile = "=3.27.0"

598
crates/memdb/src/db.rs Normal file
View File

@@ -0,0 +1,598 @@
use crate::error::{DbError, Result};
use crate::query::{Page, Paginated};
use crate::wal::{self, WalEntry, WalOp};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
// ─── Durability ──────────────────────────────────────────────────────────
/// Controls when WAL data is fsynced to disk.
#[derive(Clone, Debug)]
pub enum Durability {
/// fsync every write — safest, ~250 ops/s.
Full,
/// Buffer up to `max_ops` writes, then fsync once.
/// Call `MemDb::flush()` before shutdown to commit any remaining
/// buffered writes.
Batch { max_ops: usize },
/// Never fsync — fastest (~10k+ ops/s), zero durability.
/// Useful for ephemeral caches or when `snapshot()` handles persistence.
Off,
}
impl Default for Durability {
fn default() -> Self {
Durability::Full
}
}
impl Durability {
/// Convenience: batch up to `max_ops` writes per fsync.
pub fn batch(max_ops: usize) -> Self {
assert!(max_ops > 0);
Durability::Batch { max_ops }
}
}
// ─── In-memory state ─────────────────────────────────────────────────────
/// Complete in-memory state, used for deserializing snapshot.json.
#[derive(Deserialize, Default)]
struct Snapshot {
/// The highest WAL seq covered by this snapshot.
last_seq: u64,
/// All collection data.
data: BTreeMap<String, BTreeMap<String, Value>>,
}
/// Borrowed snapshot for zero-copy serialization — avoids cloning the
/// entire dataset when writing a snapshot file.
#[derive(Serialize)]
struct SnapshotRef<'a> {
last_seq: u64,
data: &'a BTreeMap<String, BTreeMap<String, Value>>,
}
/// Runtime state. All writes are serialized under this lock.
struct Inner {
last_seq: u64,
data: BTreeMap<String, BTreeMap<String, Value>>,
/// Open WAL file handle, reused across writes.
wal_file: Option<File>,
/// WAL file path, used when snapshot truncation needs to reopen the handle.
wal_path: PathBuf,
snapshot_path: PathBuf,
durability: Durability,
/// Buffered WAL entries not yet flushed to disk (Batch mode).
pending: Vec<WalEntry>,
/// When the first entry was added to the current batch.
pending_since: Option<Instant>,
}
impl Inner {
/// Execute a batch of ops under the lock: allocate seq → apply to memory
/// → write WAL (fsync behaviour depends on Durability).
fn commit(&mut self, ops: Vec<WalOp>) -> Result<u64> {
if ops.is_empty() {
return Ok(0);
}
self.last_seq += 1;
let seq = self.last_seq;
let entry = WalEntry {
seq,
ops,
};
// Always apply to memory first — clients can read their own writes
// immediately regardless of durability mode.
for op in &entry.ops {
apply_op(&mut self.data, op.clone());
}
// WAL path depends on Durability.
match self.durability {
Durability::Full => {
if let Some(ref mut f) = self.wal_file {
wal::write_entry(f, &entry)?;
wal::sync_wal(f)?;
}
}
Durability::Batch { .. } => {
self.push_pending(entry);
if self.pending.len() >= self.batch_threshold() {
self.flush_pending()?;
}
}
Durability::Off => {
if let Some(ref mut f) = self.wal_file {
wal::write_entry(f, &entry)?;
}
}
}
Ok(seq)
}
fn batch_threshold(&self) -> usize {
match self.durability {
Durability::Batch { max_ops } => max_ops,
_ => 0,
}
}
fn push_pending(&mut self, entry: WalEntry) {
if self.pending.is_empty() {
self.pending_since = Some(Instant::now());
}
self.pending.push(entry);
}
/// Write all buffered entries to WAL and fsync once.
fn flush_pending(&mut self) -> Result<usize> {
let count = self.pending.len();
if count == 0 {
return Ok(0);
}
if let Some(ref mut f) = self.wal_file {
for entry in &self.pending {
wal::write_entry(f, entry)?;
}
wal::sync_wal(f)?;
}
self.pending.clear();
self.pending_since = None;
Ok(count)
}
}
fn apply_op(data: &mut BTreeMap<String, BTreeMap<String, Value>>, op: WalOp) {
match op {
WalOp::Insert { collection, key, value } => {
data.entry(collection).or_default().insert(key, value);
}
WalOp::Upsert { collection, key, value } => {
data.entry(collection).or_default().insert(key, value);
}
WalOp::Delete { collection, key } => {
if let Some(col) = data.get_mut(&collection) {
col.remove(&key);
}
}
}
}
// ─── MemDb ───────────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct MemDb {
/// All writes (WAL + memory) are serialized under this lock.
inner: Arc<Mutex<Inner>>,
}
impl MemDb {
/// Open the database with `Durability::Full` (backward-compatible).
pub fn open(data_dir: impl AsRef<Path>) -> Result<Self> {
Self::open_with(data_dir, Durability::Full)
}
/// Open the database with a specific durability policy.
pub fn open_with(data_dir: impl AsRef<Path>, durability: Durability) -> Result<Self> {
let dir = data_dir.as_ref();
std::fs::create_dir_all(dir)?;
let snapshot_path = dir.join("snapshot.json");
let wal_path = dir.join("wal.jsonl");
// 1. Read snapshot.
let mut snapshot = if snapshot_path.exists() {
let bytes = std::fs::read(&snapshot_path)?;
serde_json::from_slice::<Snapshot>(&bytes)?
} else {
Snapshot::default()
};
let after_seq = snapshot.last_seq;
// 2. Replay WAL entries with seq > last_seq.
let entries = wal::read_after(&wal_path, after_seq)?;
let replayed = entries.len();
for entry in entries {
for op in entry.ops {
apply_op(&mut snapshot.data, op);
}
snapshot.last_seq = snapshot.last_seq.max(entry.seq);
}
if replayed > 0 {
eprintln!("[memdb] replayed {replayed} WAL entries after seq={after_seq}");
}
// 3. Open WAL file handle for subsequent writes.
let wal_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&wal_path)?;
Ok(Self {
inner: Arc::new(Mutex::new(Inner {
last_seq: snapshot.last_seq,
data: snapshot.data,
wal_file: Some(wal_file),
wal_path,
snapshot_path,
durability,
pending: vec![],
pending_since: None,
})),
})
}
/// Pure in-memory mode (for tests, no persistence).
pub fn in_memory() -> Self {
Self {
inner: Arc::new(Mutex::new(Inner {
last_seq: 0,
data: BTreeMap::new(),
wal_file: None,
wal_path: PathBuf::from("/dev/null"),
snapshot_path: PathBuf::from("/dev/null"),
durability: Durability::Off,
pending: vec![],
pending_since: None,
})),
}
}
/// Flush any buffered WAL entries to disk.
/// Important in `Durability::Batch` mode before shutdown — without this
/// call the last buffered batch may be lost on crash.
pub fn flush(&self) -> Result<usize> {
self.inner.lock().unwrap().flush_pending()
}
/// Return the number of buffered entries not yet flushed to disk.
pub fn pending_writes(&self) -> usize {
self.inner.lock().unwrap().pending.len()
}
/// Trigger a manual snapshot:
/// 1. Flush pending WAL entries (so crash recovery sees them).
/// 2. Read (last_seq, data) atomically under the lock.
/// 3. Write snapshot file outside the lock (non-blocking for writers).
/// 4. Atomic rename ensures snapshot file is never partial.
/// 5. Re-lock and truncate the WAL only when no writes raced in between.
pub fn snapshot(&self) -> Result<()> {
// Flush pending so every committed write is in the WAL before we
// potentially truncate it.
self.flush()?;
// Serialize inside the lock — borrows data directly (zero-copy),
// then write to disk outside the lock so writers aren't blocked.
let (bytes, last_seq, snapshot_path) = {
let inner = self.inner.lock().unwrap();
let path = inner.snapshot_path.clone();
if path == Path::new("/dev/null") {
return Ok(());
}
let snap = SnapshotRef {
last_seq: inner.last_seq,
data: &inner.data,
};
(serde_json::to_vec_pretty(&snap)?, inner.last_seq, path)
};
// Write snapshot outside lock so writers are not blocked.
let tmp = snapshot_path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &snapshot_path)?;
// Re-lock. Only truncate the WAL if no writes have committed since
// the snapshot was taken.
let mut inner = self.inner.lock().unwrap();
if inner.last_seq == last_seq {
if let Some(f) = inner.wal_file.take() {
drop(f);
// Truncate the file to zero.
std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&inner.wal_path)?;
// Reopen in append mode for future writes.
inner.wal_file = Some(
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&inner.wal_path)?,
);
}
}
eprintln!("[memdb] snapshot saved at seq={last_seq}");
Ok(())
}
/// Start a background snapshot worker that fires at the given interval.
pub fn start_snapshot_worker(&self, interval: Duration) -> tokio::task::JoinHandle<()> {
let db = self.clone();
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.tick().await; // skip immediate first tick
loop {
ticker.tick().await;
if let Err(e) = db.snapshot() {
eprintln!("[memdb] snapshot error: {e}");
}
}
})
}
/// Start a background flush worker for Batch durability mode.
/// Guarantees that buffered writes are flushed at least every `interval`.
pub fn start_flush_worker(&self, interval: Duration) -> tokio::task::JoinHandle<()> {
let db = self.clone();
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(e) = db.flush() {
eprintln!("[memdb] flush error: {e}");
}
}
})
}
/// Get a handle to the named collection.
pub fn collection(&self, name: &'static str) -> Collection {
Collection {
db: self.clone(),
name,
}
}
/// Begin a cross-collection atomic transaction.
pub fn transaction(&self) -> Transaction {
Transaction {
db: self.clone(),
ops: vec![],
}
}
fn commit(&self, ops: Vec<WalOp>) -> Result<()> {
self.inner.lock().unwrap().commit(ops)?;
Ok(())
}
}
// ─── Collection ──────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct Collection {
db: MemDb,
name: &'static str,
}
impl Collection {
// ── Write operations ──────────────────────────────────────────────────
/// Insert a record. Returns `DuplicateKey` if the key already exists.
pub fn insert<T: Serialize>(&self, key: impl Into<String>, value: &T) -> Result<()> {
let key = key.into();
let value = serde_json::to_value(value)?;
let mut inner = self.db.inner.lock().unwrap();
if inner.data.get(self.name).and_then(|c| c.get(&key)).is_some() {
return Err(DbError::DuplicateKey(self.name.to_string(), key));
}
inner.commit(vec![WalOp::Insert {
collection: self.name.to_string(),
key,
value,
}])?;
Ok(())
}
/// Insert or overwrite a record.
pub fn upsert<T: Serialize>(&self, key: impl Into<String>, value: &T) -> Result<()> {
let key = key.into();
let value = serde_json::to_value(value)?;
self.db.commit(vec![WalOp::Upsert {
collection: self.name.to_string(),
key,
value,
}])
}
/// Read-modify-write, performed atomically under the lock.
/// Returns the updated value.
pub fn update<T>(&self, key: impl Into<String>, f: impl FnOnce(T) -> T) -> Result<T>
where
T: Serialize + for<'de> Deserialize<'de>,
{
let key = key.into();
let mut inner = self.db.inner.lock().unwrap();
let old_val = inner
.data
.get(self.name)
.and_then(|c| c.get(&key))
.ok_or_else(|| DbError::NotFound(self.name.to_string(), key.clone()))?
.clone();
let old: T = serde_json::from_value(old_val)?;
let updated = f(old);
let new_val = serde_json::to_value(&updated)?;
inner.commit(vec![WalOp::Upsert {
collection: self.name.to_string(),
key,
value: new_val,
}])?;
Ok(updated)
}
/// Delete a record. Returns whether the record existed.
pub fn delete(&self, key: impl Into<String>) -> Result<bool> {
let key = key.into();
let mut inner = self.db.inner.lock().unwrap();
let existed = inner.data.get(self.name).and_then(|c| c.get(&key)).is_some();
if existed {
inner.commit(vec![WalOp::Delete {
collection: self.name.to_string(),
key,
}])?;
}
Ok(existed)
}
// ── Read operations (in-memory, no WAL involvement) ───────────────────
/// Look up a record by primary key.
pub fn get<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<Option<T>> {
let inner = self.db.inner.lock().unwrap();
match inner.data.get(self.name).and_then(|c| c.get(key)) {
Some(v) => Ok(Some(serde_json::from_value(v.clone())?)),
None => Ok(None),
}
}
/// Look up a record by primary key; return an error if not found.
pub fn get_required<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<T> {
self.get(key)?
.ok_or_else(|| DbError::NotFound(self.name.to_string(), key.to_string()))
}
/// Full scan with a predicate filter.
pub fn filter<T, F>(&self, predicate: F) -> Result<Vec<T>>
where
T: for<'de> Deserialize<'de>,
F: Fn(&T) -> bool,
{
let inner = self.db.inner.lock().unwrap();
let Some(col) = inner.data.get(self.name) else {
return Ok(vec![]);
};
let mut results = vec![];
for v in col.values() {
let item: T = serde_json::from_value(v.clone())?;
if predicate(&item) {
results.push(item);
}
}
Ok(results)
}
/// List all records in key order.
pub fn list_all<T: for<'de> Deserialize<'de>>(&self) -> Result<Vec<T>> {
let inner = self.db.inner.lock().unwrap();
let Some(col) = inner.data.get(self.name) else {
return Ok(vec![]);
};
col.values()
.map(|v| serde_json::from_value(v.clone()).map_err(DbError::from))
.collect()
}
/// Prefix scan — returns records whose key starts with the given prefix,
/// in key order.
pub fn scan_prefix<T: for<'de> Deserialize<'de>>(&self, prefix: &str) -> Result<Vec<T>> {
let inner = self.db.inner.lock().unwrap();
let Some(col) = inner.data.get(self.name) else {
return Ok(vec![]);
};
col.range(prefix.to_string()..)
.take_while(|(k, _)| k.starts_with(prefix))
.map(|(_, v)| serde_json::from_value(v.clone()).map_err(DbError::from))
.collect()
}
/// Paginated query in key order.
pub fn paginate<T: for<'de> Deserialize<'de>>(
&self,
page: &Page,
desc: bool,
) -> Result<Paginated<T>> {
let inner = self.db.inner.lock().unwrap();
let Some(col) = inner.data.get(self.name) else {
return Ok(Paginated::empty(page));
};
let total = col.len() as u64;
let offset = page.offset();
let items: Vec<T> = if desc {
col.values()
.rev()
.skip(offset)
.take(page.page_size as usize)
.map(|v| serde_json::from_value(v.clone()).map_err(DbError::from))
.collect::<Result<_>>()?
} else {
col.values()
.skip(offset)
.take(page.page_size as usize)
.map(|v| serde_json::from_value(v.clone()).map_err(DbError::from))
.collect::<Result<_>>()?
};
Ok(Paginated::new(page, total, items))
}
/// Return the number of records in this collection.
pub fn count(&self) -> usize {
let inner = self.db.inner.lock().unwrap();
inner.data.get(self.name).map(|c| c.len()).unwrap_or(0)
}
/// Check whether a key exists.
pub fn exists(&self, key: &str) -> bool {
let inner = self.db.inner.lock().unwrap();
inner.data.get(self.name).and_then(|c| c.get(key)).is_some()
}
}
// ─── Transaction ─────────────────────────────────────────────────────────
pub struct Transaction {
db: MemDb,
ops: Vec<WalOp>,
}
impl Transaction {
pub fn insert<T: Serialize>(
mut self,
collection: &str,
key: impl Into<String>,
value: &T,
) -> Result<Self> {
self.ops.push(WalOp::Insert {
collection: collection.to_string(),
key: key.into(),
value: serde_json::to_value(value)?,
});
Ok(self)
}
pub fn upsert<T: Serialize>(
mut self,
collection: &str,
key: impl Into<String>,
value: &T,
) -> Result<Self> {
self.ops.push(WalOp::Upsert {
collection: collection.to_string(),
key: key.into(),
value: serde_json::to_value(value)?,
});
Ok(self)
}
pub fn delete(mut self, collection: &str, key: impl Into<String>) -> Self {
self.ops.push(WalOp::Delete {
collection: collection.to_string(),
key: key.into(),
});
self
}
/// Atomically commit all buffered ops as a single WAL entry.
pub fn commit(self) -> Result<()> {
self.db.commit(self.ops)
}
}

18
crates/memdb/src/error.rs Normal file
View File

@@ -0,0 +1,18 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DbError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("record not found: collection={0}, key={1}")]
NotFound(String, String),
#[error("duplicate key: collection={0}, key={1}")]
DuplicateKey(String, String),
}
pub type Result<T> = std::result::Result<T, DbError>;

8
crates/memdb/src/lib.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod db;
pub mod error;
pub mod query;
pub mod wal;
pub use db::{Collection, Durability, MemDb, Transaction};
pub use error::DbError;
pub use query::{Page, Paginated};

48
crates/memdb/src/query.rs Normal file
View File

@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug)]
pub struct Page {
pub page: u64,
pub page_size: u64,
}
impl Page {
pub fn new(page: u64, page_size: u64) -> Self {
assert!(page >= 1 && page_size >= 1, "page and page_size must be >= 1");
Self { page, page_size }
}
pub fn offset(&self) -> usize {
((self.page - 1) * self.page_size) as usize
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Paginated<T> {
pub page: u64,
pub page_size: u64,
pub total: u64,
pub total_pages: u64,
pub items: Vec<T>,
}
impl<T> Paginated<T> {
pub fn new(page: &Page, total: u64, items: Vec<T>) -> Self {
let total_pages = if total == 0 {
0
} else {
(total + page.page_size - 1) / page.page_size
};
Self {
page: page.page,
page_size: page.page_size,
total,
total_pages,
items,
}
}
pub fn empty(page: &Page) -> Self {
Self::new(page, 0, vec![])
}
}

74
crates/memdb/src/wal.rs Normal file
View File

@@ -0,0 +1,74 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::io::{BufRead, Write};
use std::path::Path;
/// A single WAL operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op")]
pub enum WalOp {
Insert {
collection: String,
key: String,
value: serde_json::Value,
},
Upsert {
collection: String,
key: String,
value: serde_json::Value,
},
Delete {
collection: String,
key: String,
},
}
/// A WAL entry carrying a monotonically increasing sequence number.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalEntry {
pub seq: u64,
/// One entry may carry multiple ops (transaction batch).
pub ops: Vec<WalOp>,
}
/// Write a WAL entry line to the file (no fsync — caller decides when to
/// sync for durability).
pub fn write_entry(file: &mut std::fs::File, entry: &WalEntry) -> Result<()> {
let line = serde_json::to_string(entry)?;
writeln!(file, "{}", line)?;
Ok(())
}
/// Force buffered WAL data to disk.
pub fn sync_wal(file: &std::fs::File) -> Result<()> {
file.sync_data()?;
Ok(())
}
/// Read all valid WAL entries whose seq > `after_seq`.
/// Corrupted lines (e.g. partial write after power loss) are skipped
/// with a warning and do not prevent startup.
pub fn read_after(path: &Path, after_seq: u64) -> Result<Vec<WalEntry>> {
if !path.exists() {
return Ok(vec![]);
}
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
let mut entries = vec![];
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<WalEntry>(trimmed) {
Ok(entry) if entry.seq > after_seq => entries.push(entry),
// seq <= after_seq — already covered by snapshot, skip.
Ok(_) => {}
Err(e) => {
eprintln!("[wal] skipping corrupted entry: {e}");
}
}
}
Ok(entries)
}

View File

@@ -0,0 +1,743 @@
use memdb::{DbError, MemDb, Page};
use serde::{Deserialize, Serialize};
use tempfile::TempDir;
// ─── Test models ─────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct Account {
id: String,
email: String,
status: String,
#[serde(default)]
role: String,
}
impl Account {
fn new(id: &str, email: &str, status: &str) -> Self {
Self {
id: id.into(),
email: email.into(),
status: status.into(),
role: "user".into(),
}
}
}
fn open_tmp() -> (MemDb, TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = MemDb::open(dir.path()).unwrap();
(db, dir)
}
// ─── Basic CRUD ──────────────────────────────────────────────────────────
#[test]
fn test_insert_and_get() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
let acc = Account::new("1", "a@x.com", "active");
col.insert("1", &acc).unwrap();
let found: Option<Account> = col.get("1").unwrap();
assert_eq!(found, Some(acc));
}
#[test]
fn test_get_missing_returns_none() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
let found: Option<Account> = col.get("nope").unwrap();
assert!(found.is_none());
}
#[test]
fn test_get_required_missing_returns_error() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
let err = col.get_required::<Account>("nope").unwrap_err();
assert!(matches!(err, DbError::NotFound(_, _)));
}
#[test]
fn test_insert_duplicate_returns_error() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
let acc = Account::new("1", "a@x.com", "active");
col.insert("1", &acc).unwrap();
let err = col.insert("1", &acc).unwrap_err();
assert!(matches!(err, DbError::DuplicateKey(_, _)));
}
#[test]
fn test_upsert_creates_and_overwrites() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
let acc = Account::new("1", "a@x.com", "active");
col.upsert("1", &acc).unwrap();
let found: Account = col.get_required("1").unwrap();
assert_eq!(found.status, "active");
let updated = Account::new("1", "a@x.com", "disabled");
col.upsert("1", &updated).unwrap();
let found: Account = col.get_required("1").unwrap();
assert_eq!(found.status, "disabled");
}
#[test]
fn test_update_modifies_record() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
let result: Account = col
.update("1", |mut a: Account| {
a.status = "disabled".into();
a
})
.unwrap();
assert_eq!(result.status, "disabled");
let found: Account = col.get_required("1").unwrap();
assert_eq!(found.status, "disabled");
}
#[test]
fn test_update_missing_returns_error() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
let err = col.update("nope", |a: Account| a).unwrap_err();
assert!(matches!(err, DbError::NotFound(_, _)));
}
#[test]
fn test_delete_existing() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
let deleted = col.delete("1").unwrap();
assert!(deleted);
assert!(!col.exists("1"));
}
#[test]
fn test_delete_missing_returns_false() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
let deleted = col.delete("nope").unwrap();
assert!(!deleted);
}
#[test]
fn test_exists() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
assert!(!col.exists("1"));
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
assert!(col.exists("1"));
}
#[test]
fn test_count() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
assert_eq!(col.count(), 0);
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
col.insert("2", &Account::new("2", "b@x.com", "active"))
.unwrap();
assert_eq!(col.count(), 2);
col.delete("1").unwrap();
assert_eq!(col.count(), 1);
}
// ─── Queries ─────────────────────────────────────────────────────────────
#[test]
fn test_list_all() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
col.insert("2", &Account::new("2", "b@x.com", "active"))
.unwrap();
let all: Vec<Account> = col.list_all().unwrap();
assert_eq!(all.len(), 2);
}
#[test]
fn test_list_all_empty_collection() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
let all: Vec<Account> = col.list_all().unwrap();
assert!(all.is_empty());
}
#[test]
fn test_filter() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
col.insert("2", &Account::new("2", "b@x.com", "disabled"))
.unwrap();
col.insert("3", &Account::new("3", "c@x.com", "active"))
.unwrap();
let active: Vec<Account> = col.filter(|a: &Account| a.status == "active").unwrap();
assert_eq!(active.len(), 2);
let disabled: Vec<Account> = col.filter(|a: &Account| a.status == "disabled").unwrap();
assert_eq!(disabled.len(), 1);
}
#[test]
fn test_filter_empty_collection() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
let result: Vec<Account> = col.filter(|_: &Account| true).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_scan_prefix() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
col.insert("2024_001", &Account::new("1", "a@x.com", "active"))
.unwrap();
col.insert("2024_002", &Account::new("2", "b@x.com", "active"))
.unwrap();
col.insert("2025_001", &Account::new("3", "c@x.com", "active"))
.unwrap();
let result: Vec<Account> = col.scan_prefix("2024_").unwrap();
assert_eq!(result.len(), 2);
let result: Vec<Account> = col.scan_prefix("2025_").unwrap();
assert_eq!(result.len(), 1);
let result: Vec<Account> = col.scan_prefix("9999_").unwrap();
assert!(result.is_empty());
}
#[test]
fn test_paginate_asc() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
for i in 1..=5 {
col.insert(
format!("{:03}", i),
&Account::new(&i.to_string(), &format!("{}@x.com", i), "active"),
)
.unwrap();
}
let page = col.paginate::<Account>(&Page::new(1, 2), false).unwrap();
assert_eq!(page.total, 5);
assert_eq!(page.total_pages, 3);
assert_eq!(page.items.len(), 2);
assert_eq!(page.items[0].id, "1");
assert_eq!(page.items[1].id, "2");
let page2 = col.paginate::<Account>(&Page::new(2, 2), false).unwrap();
assert_eq!(page2.items.len(), 2);
assert_eq!(page2.items[0].id, "3");
let page3 = col.paginate::<Account>(&Page::new(3, 2), false).unwrap();
assert_eq!(page3.items.len(), 1);
assert_eq!(page3.items[0].id, "5");
}
#[test]
fn test_paginate_desc() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
for i in 1..=5 {
col.insert(
format!("{:03}", i),
&Account::new(&i.to_string(), &format!("{}@x.com", i), "active"),
)
.unwrap();
}
let page = col.paginate::<Account>(&Page::new(1, 2), true).unwrap();
assert_eq!(page.items[0].id, "5");
assert_eq!(page.items[1].id, "4");
}
#[test]
fn test_paginate_out_of_range() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
let page = col.paginate::<Account>(&Page::new(99, 10), false).unwrap();
assert_eq!(page.total, 1);
assert!(page.items.is_empty());
}
#[test]
fn test_paginate_empty_collection() {
let db = MemDb::in_memory();
let col = db.collection("accounts");
let page = col.paginate::<Account>(&Page::new(1, 10), false).unwrap();
assert_eq!(page.total, 0);
assert_eq!(page.total_pages, 0);
assert!(page.items.is_empty());
}
// ─── Transactions ────────────────────────────────────────────────────────
#[test]
fn test_transaction_commit() {
let db = MemDb::in_memory();
let acc1 = Account::new("1", "a@x.com", "active");
let acc2 = Account::new("2", "b@x.com", "active");
db.transaction()
.upsert("accounts", "1", &acc1)
.unwrap()
.upsert("accounts", "2", &acc2)
.unwrap()
.commit()
.unwrap();
let col = db.collection("accounts");
assert!(col.exists("1"));
assert!(col.exists("2"));
}
#[test]
fn test_transaction_delete_across_collections() {
let db = MemDb::in_memory();
let acc = Account::new("1", "a@x.com", "active");
db.collection("accounts").insert("1", &acc).unwrap();
db.collection("logs").insert("log-1", &acc).unwrap();
db.transaction()
.delete("accounts", "1")
.delete("logs", "log-1")
.commit()
.unwrap();
assert!(!db.collection("accounts").exists("1"));
assert!(!db.collection("logs").exists("log-1"));
}
// ─── Collection isolation ────────────────────────────────────────────────
#[test]
fn test_collections_are_isolated() {
let db = MemDb::in_memory();
let acc = Account::new("1", "a@x.com", "active");
db.collection("accounts").insert("1", &acc).unwrap();
// Same key in different collections should not interfere.
assert!(db.collection("accounts").exists("1"));
assert!(!db.collection("users").exists("1"));
}
// ─── WAL + Snapshot persistence ──────────────────────────────────────────
#[test]
fn test_persist_and_recover() {
let dir = tempfile::tempdir().unwrap();
// Write data.
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
col.insert("2", &Account::new("2", "b@x.com", "disabled"))
.unwrap();
}
// Recover after restart.
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
assert_eq!(col.count(), 2);
let acc: Account = col.get_required("1").unwrap();
assert_eq!(acc.email, "a@x.com");
}
}
#[test]
fn test_recover_after_snapshot() {
let dir = tempfile::tempdir().unwrap();
// Write + snapshot.
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
db.snapshot().unwrap();
}
// Recover from snapshot.
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
assert_eq!(col.count(), 1);
assert!(col.exists("1"));
}
}
#[test]
fn test_recover_snapshot_plus_wal() {
let dir = tempfile::tempdir().unwrap();
// Write 3 records, snapshot, then write 2 more.
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
col.insert("2", &Account::new("2", "b@x.com", "active"))
.unwrap();
col.insert("3", &Account::new("3", "c@x.com", "active"))
.unwrap();
db.snapshot().unwrap(); // WAL truncated, last_seq=3
// Writes after snapshot — go into fresh WAL.
col.insert("4", &Account::new("4", "d@x.com", "active"))
.unwrap();
col.insert("5", &Account::new("5", "e@x.com", "active"))
.unwrap();
}
// Recover: snapshot(seq=3) + replay WAL(seq=4,5).
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
assert_eq!(col.count(), 5);
for i in 1..=5 {
assert!(col.exists(&i.to_string()));
}
}
}
#[test]
fn test_snapshot_does_not_duplicate_on_recovery() {
let dir = tempfile::tempdir().unwrap();
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
col.insert("2", &Account::new("2", "b@x.com", "active"))
.unwrap();
db.snapshot().unwrap();
// Multiple snapshots should not duplicate data.
db.snapshot().unwrap();
}
{
let db = MemDb::open(dir.path()).unwrap();
assert_eq!(db.collection("accounts").count(), 2);
}
}
#[test]
fn test_wal_seq_skips_already_snapshotted_entries() {
let dir = tempfile::tempdir().unwrap();
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap(); // seq=1
col.insert("2", &Account::new("2", "b@x.com", "active"))
.unwrap(); // seq=2
db.snapshot().unwrap(); // snapshot last_seq=2, WAL truncated
// Write after snapshot.
col.insert("3", &Account::new("3", "c@x.com", "active"))
.unwrap(); // seq=3
}
// Verify WAL only contains seq=3.
let wal_path = dir.path().join("wal.jsonl");
let entries = memdb::wal::read_after(&wal_path, 2).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].seq, 3);
// Data is complete after recovery.
{
let db = MemDb::open(dir.path()).unwrap();
assert_eq!(db.collection("accounts").count(), 3);
}
}
#[test]
fn test_delete_persisted_across_restart() {
let dir = tempfile::tempdir().unwrap();
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
col.insert("2", &Account::new("2", "b@x.com", "active"))
.unwrap();
col.delete("1").unwrap();
}
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
assert!(!col.exists("1"));
assert!(col.exists("2"));
}
}
#[test]
fn test_update_persisted_across_restart() {
let dir = tempfile::tempdir().unwrap();
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
col.insert("1", &Account::new("1", "a@x.com", "active"))
.unwrap();
col.update("1", |mut a: Account| {
a.status = "disabled".into();
a
})
.unwrap();
}
{
let db = MemDb::open(dir.path()).unwrap();
let acc: Account = db.collection("accounts").get_required("1").unwrap();
assert_eq!(acc.status, "disabled");
}
}
#[test]
fn test_transaction_persisted_across_restart() {
let dir = tempfile::tempdir().unwrap();
{
let db = MemDb::open(dir.path()).unwrap();
let acc1 = Account::new("1", "a@x.com", "active");
let acc2 = Account::new("2", "b@x.com", "active");
db.transaction()
.upsert("accounts", "1", &acc1)
.unwrap()
.upsert("accounts", "2", &acc2)
.unwrap()
.delete("accounts", "nonexistent")
.commit()
.unwrap();
}
{
let db = MemDb::open(dir.path()).unwrap();
let col = db.collection("accounts");
assert_eq!(col.count(), 2);
assert!(col.exists("1"));
assert!(col.exists("2"));
}
}
// ─── Schema evolution ────────────────────────────────────────────────────
#[test]
fn test_schema_add_field_with_default() {
let (db, dir) = open_tmp();
// Write with old model.
#[derive(Serialize, Deserialize)]
struct AccountV1 {
id: String,
email: String,
}
db.collection("accounts")
.insert(
"1",
&AccountV1 {
id: "1".into(),
email: "a@x.com".into(),
},
)
.unwrap();
drop(db);
// New model adds a field with #[serde(default)].
#[derive(Serialize, Deserialize, Debug)]
struct AccountV2 {
id: String,
email: String,
#[serde(default)]
role: String,
}
let db = MemDb::open(dir.path()).unwrap();
let acc: AccountV2 = db.collection("accounts").get_required("1").unwrap();
assert_eq!(acc.email, "a@x.com");
assert_eq!(acc.role, ""); // default fills empty string
}
#[test]
fn test_schema_remove_field() {
let (db, dir) = open_tmp();
// Write with old model that has a legacy field.
#[derive(Serialize, Deserialize)]
struct AccountWithLegacy {
id: String,
email: String,
legacy_field: String,
}
db.collection("accounts")
.insert(
"1",
&AccountWithLegacy {
id: "1".into(),
email: "a@x.com".into(),
legacy_field: "old_value".into(),
},
)
.unwrap();
drop(db);
// New model drops legacy_field — serde ignores unknown fields by default.
#[derive(Serialize, Deserialize, Debug)]
struct AccountV2 {
id: String,
email: String,
}
let db = MemDb::open(dir.path()).unwrap();
let acc: AccountV2 = db.collection("accounts").get_required("1").unwrap();
assert_eq!(acc.email, "a@x.com");
}
#[test]
fn test_schema_rename_field_with_alias() {
let (db, dir) = open_tmp();
#[derive(Serialize, Deserialize)]
struct AccountOld {
id: String,
username: String,
}
db.collection("accounts")
.insert(
"1",
&AccountOld {
id: "1".into(),
username: "alice".into(),
},
)
.unwrap();
drop(db);
// Field renamed; alias keeps backward compatibility with old data.
#[derive(Serialize, Deserialize, Debug)]
struct AccountNew {
id: String,
#[serde(alias = "username")]
display_name: String,
}
let db = MemDb::open(dir.path()).unwrap();
let acc: AccountNew = db.collection("accounts").get_required("1").unwrap();
assert_eq!(acc.display_name, "alice");
}
// ─── Concurrency safety ──────────────────────────────────────────────────
#[tokio::test]
async fn test_concurrent_writes_no_data_loss() {
let db = MemDb::in_memory();
let db = std::sync::Arc::new(db);
let mut handles = vec![];
for i in 0..100 {
let db = db.clone();
handles.push(tokio::spawn(async move {
let col = db.collection("accounts");
let acc = Account::new(&i.to_string(), &format!("{}@x.com", i), "active");
// spawn_blocking because the Mutex may block briefly.
tokio::task::spawn_blocking(move || col.upsert(i.to_string(), &acc))
.await
.unwrap()
.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(db.collection("accounts").count(), 100);
}
#[tokio::test]
async fn test_concurrent_writes_wal_seq_monotonic() {
let dir = tempfile::tempdir().unwrap();
let db = std::sync::Arc::new(MemDb::open(dir.path()).unwrap());
let mut handles = vec![];
for i in 0..50 {
let db = db.clone();
handles.push(tokio::spawn(async move {
let col = db.collection("accounts");
let acc = Account::new(&i.to_string(), &format!("{}@x.com", i), "active");
tokio::task::spawn_blocking(move || col.upsert(i.to_string(), &acc))
.await
.unwrap()
.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
// Verify WAL seq is strictly monotonic.
let wal_path = dir.path().join("wal.jsonl");
let entries = memdb::wal::read_after(&wal_path, 0).unwrap();
assert_eq!(entries.len(), 50);
let mut last = 0u64;
for e in &entries {
assert!(e.seq > last, "seq not monotonic: {} <= {}", e.seq, last);
last = e.seq;
}
}
#[tokio::test]
async fn test_concurrent_snapshot_and_writes() {
let dir = tempfile::tempdir().unwrap();
let db = std::sync::Arc::new(MemDb::open(dir.path()).unwrap());
// Concurrent writes + snapshots.
let db_write = db.clone();
let write_handle = tokio::spawn(async move {
for i in 0..100 {
let col = db_write.collection("accounts");
let acc = Account::new(&i.to_string(), &format!("{}@x.com", i), "active");
tokio::task::spawn_blocking(move || col.upsert(i.to_string(), &acc))
.await
.unwrap()
.unwrap();
}
});
let db_snap = db.clone();
let snap_handle = tokio::spawn(async move {
for _ in 0..5 {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
db_snap.snapshot().unwrap();
}
});
write_handle.await.unwrap();
snap_handle.await.unwrap();
// Final snapshot to ensure everything is on disk.
db.snapshot().unwrap();
// Data is complete after recovery.
let db2 = MemDb::open(dir.path()).unwrap();
assert_eq!(db2.collection("accounts").count(), 100);
}

1602
crates/memdb/tests/stress.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -30,3 +30,7 @@ tokio.workspace = true
http.workspace = true
urlencoding.workspace = true
mimalloc.workspace = true
[dev-dependencies]
poem = { version = "3.1.12", features = ["test"] }
tempfile = "3"

View File

@@ -106,11 +106,9 @@ pub async fn extract_client_context(req: &Request) -> Result<ClientContext> {
})?;
// Validate and update access token
let user = AccessTokenModel::resolve_user_from_token(&token)
.await
.map_err(|e| {
create_api_error_response(&format!("{:#?}", e), ErrorCode::PermissionDenied)
})?;
let user = AccessTokenModel::resolve_user_from_token(&token).map_err(|e| {
create_api_error_response(&format!("{:#?}", e), ErrorCode::PermissionDenied)
})?;
return Ok(ClientContext {
ip_addr: Some(ip_addr),

View File

@@ -41,3 +41,33 @@ impl Validator<String> for EmailValidator {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_email_passes() {
let validator = EmailValidator;
assert!(validator.check(&"user@example.com".to_string()));
assert!(validator.check(&"a@b.co".to_string()));
assert!(validator.check(&"test.user+tag@domain.com".to_string()));
}
#[test]
fn invalid_email_fails() {
let validator = EmailValidator;
assert!(!validator.check(&"not-an-email".to_string()));
assert!(!validator.check(&"".to_string()));
assert!(!validator.check(&"@domain.com".to_string()));
assert!(!validator.check(&"user@".to_string()));
}
#[test]
fn display_message() {
assert_eq!(
EmailValidator.to_string(),
"Not a valid email address"
);
}
}

View File

@@ -53,3 +53,106 @@ impl IntoStatusCode for ErrorCode {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_parameter_is_bad_request() {
assert_eq!(
ErrorCode::InvalidParameter.status(),
StatusCode::BAD_REQUEST
);
}
#[test]
fn permission_denied_is_unauthorized() {
assert_eq!(
ErrorCode::PermissionDenied.status(),
StatusCode::UNAUTHORIZED
);
}
#[test]
fn forbidden_is_forbidden() {
assert_eq!(ErrorCode::Forbidden.status(), StatusCode::FORBIDDEN);
}
#[test]
fn resource_not_found_is_not_found() {
assert_eq!(
ErrorCode::ResourceNotFound.status(),
StatusCode::NOT_FOUND
);
}
#[test]
fn internal_error_is_internal_server_error() {
assert_eq!(
ErrorCode::InternalError.status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn too_many_request_is_429() {
assert_eq!(
ErrorCode::TooManyRequest.status(),
StatusCode::TOO_MANY_REQUESTS
);
}
#[test]
fn already_exists_is_conflict() {
assert_eq!(ErrorCode::AlreadyExists.status(), StatusCode::CONFLICT);
}
#[test]
fn method_not_allowed_is_405() {
assert_eq!(
ErrorCode::MethodNotAllowed.status(),
StatusCode::METHOD_NOT_ALLOWED
);
}
#[test]
fn every_error_code_maps_to_valid_status() {
// Ensure all variants produce a status code in the 4xx or 5xx range
let codes = [
ErrorCode::InvalidParameter,
ErrorCode::MissingConfiguration,
ErrorCode::Incompatible,
ErrorCode::PermissionDenied,
ErrorCode::AccountDisabled,
ErrorCode::OAuth2ItemDisabled,
ErrorCode::Forbidden,
ErrorCode::ResourceNotFound,
ErrorCode::RequestTimeout,
ErrorCode::PayloadTooLarge,
ErrorCode::TooManyRequest,
ErrorCode::AlreadyExists,
ErrorCode::InternalError,
ErrorCode::AutoconfigFetchFailed,
ErrorCode::ImapCommandFailed,
ErrorCode::ImapUnexpectedResult,
ErrorCode::HttpResponseError,
ErrorCode::ImapAuthenticationFailed,
ErrorCode::MissingRefreshToken,
ErrorCode::NetworkError,
ErrorCode::ConnectionTimeout,
ErrorCode::ConnectionPoolTimeout,
ErrorCode::UnhandledPoemError,
ErrorCode::MethodNotAllowed,
];
for code in &codes {
let status = code.status();
assert!(
status.is_client_error() || status.is_server_error(),
"{:?} should map to 4xx or 5xx, got {}",
code,
status
);
}
}
}

View File

@@ -139,3 +139,78 @@ async fn initialize() -> BichonResult<()> {
LazyLock::force(&ATTACHMENT_MANAGER);
Ok(())
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod api_tests {
use super::rest::api::create_openapi_service;
use poem::test::TestClient;
#[tokio::test]
async fn openapi_spec_json_is_served() {
let api_service = create_openapi_service();
let spec_endpoint = api_service.spec_endpoint();
let cli = TestClient::new(spec_endpoint);
let resp = cli.get("/").send().await;
resp.assert_status_is_ok();
let body = resp.json().await;
let obj = body.value().object();
assert!(obj.get_opt("openapi").is_some(), "missing openapi version");
assert!(obj.get_opt("info").is_some(), "missing info section");
assert!(obj.get_opt("paths").is_some(), "missing paths section");
}
#[tokio::test]
async fn openapi_spec_yaml_is_served() {
let api_service = create_openapi_service();
let spec_endpoint = api_service.spec_endpoint_yaml();
let cli = TestClient::new(spec_endpoint);
let resp = cli.get("/").send().await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn swagger_ui_is_served() {
let api_service = create_openapi_service();
let swagger = api_service.swagger_ui();
let cli = TestClient::new(swagger);
let resp = cli.get("/").send().await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn openapi_spec_lists_all_tag_groups() {
let api_service = create_openapi_service();
let spec_endpoint = api_service.spec_endpoint();
let cli = TestClient::new(spec_endpoint);
let resp = cli.get("/").send().await;
let body = resp.json().await;
let value = body.value();
let tag_names: Vec<&str> = value
.object()
.get("tags")
.array()
.iter()
.map(|v| v.object().get("name").string())
.collect();
assert!(tag_names.contains(&"AccessToken"), "missing AccessToken tag");
assert!(tag_names.contains(&"Attachment"), "missing Attachment tag");
assert!(tag_names.contains(&"AutoConfig"), "missing AutoConfig tag");
assert!(tag_names.contains(&"Account"), "missing Account tag");
assert!(tag_names.contains(&"System"), "missing System tag");
assert!(tag_names.contains(&"Mailbox"), "missing Mailbox tag");
assert!(tag_names.contains(&"OAuth2"), "missing OAuth2 tag");
assert!(tag_names.contains(&"Message"), "missing Message tag");
assert!(tag_names.contains(&"Import"), "missing Import tag");
assert!(tag_names.contains(&"Users"), "missing Users tag");
}
}

View File

@@ -38,11 +38,9 @@ impl AccessTokenApi {
&self,
context: WrappedContext,
) -> ApiResult<Json<Vec<AccessTokenResp>>> {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
context.require_permission(None, Permission::TOKEN_MANAGE)?;
Ok(Json(AccessTokenModel::list_all_api_tokens().await?))
Ok(Json(AccessTokenModel::list_all_api_tokens()?))
}
/// Deletes a specific access token.
@@ -58,14 +56,12 @@ impl AccessTokenApi {
context: WrappedContext,
) -> ApiResult<()> {
let token = token.0.trim();
let token = AccessTokenModel::get_token(token).await?;
let token = AccessTokenModel::get_token(token)?;
if context.user.id != token.user_id {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
context.require_permission(None, Permission::TOKEN_MANAGE)?;
}
Ok(AccessTokenModel::delete(&token.token).await?)
Ok(AccessTokenModel::delete(&token.token)?)
}
/// Creates a new api token.
@@ -83,12 +79,10 @@ impl AccessTokenApi {
let current_user_id = context.user.id;
let target_user_id = payload.0.user_id.unwrap_or(current_user_id);
if target_user_id != current_user_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
context.require_permission(None, Permission::USER_MANAGE)?;
}
let token_string = AccessTokenModel::create_api_token(target_user_id, payload.0).await?;
let token_string = AccessTokenModel::create_api_token(target_user_id, payload.0)?;
Ok(PlainText(token_string))
}
}

View File

@@ -56,10 +56,8 @@ impl AccountApi {
context: WrappedContext,
) -> ApiResult<Json<AccountModel>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
Ok(Json(AccountModel::async_get(account_id).await?))
context.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)?;
Ok(Json(AccountModel::get(account_id)?))
}
/// Delete an account by ID - WARNING: This permanently removes the account and all associated resources
@@ -75,10 +73,9 @@ impl AccountApi {
context: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::delete(account_id).await?)
context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?;
AccountModel::delete(account_id).await?;
Ok(())
}
/// Create a new account
@@ -89,9 +86,7 @@ impl AccountApi {
payload: Json<AccountCreateRequest>,
context: WrappedContext,
) -> ApiResult<Json<AccountModel>> {
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
context.require_permission(None, Permission::ACCOUNT_CREATE)?;
let account = AccountModel::create_account(context.user.id, payload.0).await?;
Ok(Json(account))
}
@@ -111,10 +106,8 @@ impl AccountApi {
context: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::update(account_id, payload.0, true).await?)
context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?;
Ok(AccountModel::update(account_id, payload.0, true)?)
}
/// List accounts with optional pagination parameters
@@ -129,16 +122,15 @@ impl AccountApi {
desc: Query<Option<bool>>,
context: WrappedContext,
) -> ApiResult<Json<DataPage<AccountResp>>> {
let is_admin = context.user.is_admin().await;
let is_admin = context.user.is_admin();
let sort_desc = desc.0.unwrap_or(true);
let user_map: HashMap<u64, UserModel> = UserModel::list_all()
.await?
let user_map: HashMap<u64, UserModel> = UserModel::list_all()?
.into_iter()
.map(|u| (u.id, u))
.collect();
let page_data: DataPage<AccountModel> = if is_admin {
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?
AccountModel::paginate_list(page.0, page_size.0, desc.0)?
} else {
let authorized_ids: HashSet<u64> =
context.user.account_access_map.keys().cloned().collect();
@@ -153,8 +145,7 @@ impl AccountApi {
}));
}
let mut accounts: Vec<AccountModel> = AccountModel::list_all()
.await?
let mut accounts: Vec<AccountModel> = AccountModel::list_all()?
.into_iter()
.filter(|acct| authorized_ids.contains(&acct.id))
.collect();
@@ -198,11 +189,9 @@ impl AccountApi {
context: WrappedContext,
) -> ApiResult<Json<DownloadState>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let state = DownloadState::get(account_id).await?;
AccountModel::check_account_exists(account_id)?;
context.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)?;
let state = DownloadState::get(account_id)?;
let state = state.unwrap_or(DownloadState::empty(account_id));
Ok(Json(state))
}
@@ -220,16 +209,14 @@ impl AccountApi {
context: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
let account = AccountModel::check_account_exists(account_id).await?;
let account = AccountModel::check_account_exists(account_id)?;
if !matches!(account.account_type, AccountType::IMAP) {
return Err(raise_error!(
format!("Manual download is not supported for '{:#?}' accounts. Only IMAP accounts are supported.", account.account_type),
ErrorCode::InvalidParameter
))?;
}
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?;
SYNC_TASKS.start_manual_task(account_id).await?;
Ok(())
}
@@ -247,7 +234,7 @@ impl AccountApi {
context: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
let account = AccountModel::check_account_exists(account_id).await?;
let account = AccountModel::check_account_exists(account_id)?;
if !matches!(account.account_type, AccountType::IMAP) {
return Err(raise_error!(
@@ -255,9 +242,7 @@ impl AccountApi {
ErrorCode::InvalidParameter
))?;
}
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?;
if !SYNC_TASKS.is_manual_running(account_id).await {
return Err(raise_error!(
@@ -282,11 +267,9 @@ impl AccountApi {
context: WrappedContext,
) -> ApiResult<Json<AccountStats>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let state = ENVELOPE_MANAGER.get_account_stats(account_id).await?;
AccountModel::check_account_exists(account_id)?;
context.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)?;
let state = ENVELOPE_MANAGER.get_account_stats(account_id)?;
Ok(Json(state))
}
@@ -304,10 +287,10 @@ impl AccountApi {
only_nosync: Query<Option<bool>>,
context: WrappedContext,
) -> ApiResult<Json<Vec<MinimalAccount>>> {
let is_admin = context.user.is_admin().await;
let is_admin = context.user.is_admin();
let only_nosync = only_nosync.0.unwrap_or_default();
let minimal_list = AccountModel::minimal_list(only_nosync).await?;
let minimal_list = AccountModel::minimal_list(only_nosync)?;
if is_admin {
return Ok(Json(minimal_list));
}
@@ -323,8 +306,8 @@ impl AccountApi {
req: Json<BatchAccountRoleRequest>,
context: WrappedContext,
) -> ApiResult<()> {
req.validate_existence().await?;
req.0.do_assign(&context).await?;
req.validate_existence()?;
req.0.do_assign(&context)?;
Ok(())
}
}

View File

@@ -52,17 +52,13 @@ impl AttachmentApi {
payload: Json<AttachmentSearchRequest>,
context: WrappedContext,
) -> ApiResult<Json<DataPage<AttachmentModel>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
search_attachment_impl(authorized_ids, payload.0).await?,
))
let authorized_ids: Option<HashSet<u64>> =
if context.has_permission(None, Permission::DATA_READ_ALL) {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(search_attachment_impl(authorized_ids, payload.0)?))
}
/// Retrieves the attachment (metadata) of a specific message.
@@ -80,13 +76,10 @@ impl AttachmentApi {
context: WrappedContext,
) -> ApiResult<Json<AttachmentModel>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
context.require_permission(Some(account_id), Permission::DATA_READ)?;
let attachment_id = attachment_id.0;
let a = ATTACHMENT_MANAGER
.get_attachment_by_id(account_id, &attachment_id)
.await?
.get_attachment_by_id(account_id, &attachment_id)?
.ok_or_else(|| {
raise_error!(
format!(
@@ -109,15 +102,13 @@ impl AttachmentApi {
&self,
context: WrappedContext,
) -> ApiResult<Json<Vec<TagCount>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(ATTACHMENT_MANAGER.get_all_tags(authorized_ids).await?))
let authorized_ids: Option<HashSet<u64>> =
if context.has_permission(None, Permission::DATA_READ_ALL) {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(ATTACHMENT_MANAGER.get_all_tags(authorized_ids)?))
}
/// Adds or removes facet tags for multiple emails across accounts.
@@ -137,9 +128,7 @@ impl AttachmentApi {
}
for account_id in req.updates.keys() {
context
.require_permission(Some(*account_id), Permission::DATA_MANAGE)
.await?;
context.require_permission(Some(*account_id), Permission::DATA_MANAGE)?;
}
ATTACHMENT_MANAGER.update_attachment_tags(req).await?;
@@ -156,17 +145,13 @@ impl AttachmentApi {
&self,
context: WrappedContext,
) -> ApiResult<Json<HashSet<String>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ATTACHMENT_MANAGER.get_all_senders(authorized_ids).await?,
))
let authorized_ids: Option<HashSet<u64>> =
if context.has_permission(None, Permission::DATA_READ_ALL) {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(ATTACHMENT_MANAGER.get_all_senders(authorized_ids)?))
}
/// Retrieves unique metadata for all attachments across authorized accounts.
@@ -179,14 +164,12 @@ impl AttachmentApi {
&self,
context: WrappedContext,
) -> ApiResult<Json<AttachmentMetadata>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
let authorized_ids: Option<HashSet<u64>> =
if context.has_permission(None, Permission::DATA_READ_ALL) {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ATTACHMENT_MANAGER.collect_attachment_metadata(authorized_ids)?,
))

View File

@@ -44,10 +44,7 @@ impl AutoConfigApi {
email_address: Path<String>,
context: WrappedContext,
) -> ApiResult<Json<MailServerConfig>> {
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
context.require_permission(None, Permission::ACCOUNT_CREATE)?;
let result = resolve_autoconfig(email_address.0.trim())
.await?
.ok_or_else(|| {

View File

@@ -44,9 +44,7 @@ impl ImportApi {
payload: Json<BatchEmlRequest>,
context: WrappedContext,
) -> ApiResult<Json<BatchEmlResult>> {
context
.require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH)
.await?;
context.require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH)?;
Ok(Json(ImportEmls::do_import(payload.0).await?))
}
}

View File

@@ -53,9 +53,7 @@ impl MailBoxApi {
context: WrappedContext,
) -> ApiResult<Json<Vec<MailBox>>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
context.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)?;
let remote = remote.0.unwrap_or(false);
Ok(Json(get_account_mailboxes(account_id, remote).await?))
}
@@ -82,9 +80,7 @@ impl MailBoxApi {
) -> ApiResult<()> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context
.require_permission(Some(account_id), Permission::DATA_DELETE)
.await?;
context.require_permission(Some(account_id), Permission::DATA_DELETE)?;
Ok(delete_mailbox_impl(account_id, mailbox_id).await?)
}
}

View File

@@ -35,8 +35,8 @@ use bichon_core::message::search::{search_messages_impl, EmailSearchRequest};
use bichon_core::message::tags::TagCount;
use bichon_core::message::tags::TagsRequest;
use bichon_core::raise_error;
use bichon_core::store::envelope::Envelope;
use bichon_core::store::blob::get_reader;
use bichon_core::store::envelope::Envelope;
use bichon_core::store::tantivy::envelope::ENVELOPE_MANAGER;
use bichon_core::store::tantivy::validate_facet;
use bichon_core::users::permissions::Permission;
@@ -65,9 +65,7 @@ impl MessageApi {
) -> ApiResult<()> {
let request = payload.0;
for account_id in request.keys() {
context
.require_permission(Some(*account_id), Permission::DATA_DELETE)
.await?;
context.require_permission(Some(*account_id), Permission::DATA_DELETE)?;
}
Ok(delete_messages_impl(request).await?)
}
@@ -84,15 +82,13 @@ impl MessageApi {
payload: Json<EmailSearchRequest>,
context: WrappedContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(search_messages_impl(authorized_ids, payload.0).await?))
let authorized_ids: Option<HashSet<u64>> =
if context.has_permission(None, Permission::DATA_READ_ALL) {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(search_messages_impl(authorized_ids, payload.0)?))
}
/// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters.
@@ -115,12 +111,13 @@ impl MessageApi {
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let thread_id = thread_id.0.trim();
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
get_thread_messages(account_id, thread_id, page.0, page_size.0).await?,
))
context.require_permission(Some(account_id), Permission::DATA_READ)?;
Ok(Json(get_thread_messages(
account_id,
thread_id,
page.0,
page_size.0,
)?))
}
/// Fetches the content of a specific email.
@@ -138,12 +135,8 @@ impl MessageApi {
context: WrappedContext,
) -> ApiResult<Json<FullMessageContent>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
retrieve_email_content(account_id, envelope_id.0).await?,
))
context.require_permission(Some(account_id), Permission::DATA_READ)?;
Ok(Json(retrieve_email_content(account_id, envelope_id.0)?))
}
/// Retrieves the content of an email embedded as an attachment.
@@ -162,13 +155,13 @@ impl MessageApi {
context: WrappedContext,
) -> ApiResult<Json<FullNestedMessageContent>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
context.require_permission(Some(account_id), Permission::DATA_READ)?;
let content_hash = content_hash.0.trim();
Ok(Json(
retrieve_nested_eml_content(account_id, envelope_id.0, content_hash).await?,
))
Ok(Json(retrieve_nested_eml_content(
account_id,
envelope_id.0,
content_hash,
)?))
}
/// Retrieves the envelope (metadata) of a specific message.
@@ -186,13 +179,10 @@ impl MessageApi {
context: WrappedContext,
) -> ApiResult<Json<Envelope>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
context.require_permission(Some(account_id), Permission::DATA_READ)?;
let envelope_id = envelope_id.0;
let e = ENVELOPE_MANAGER
.get_envelope_by_id(account_id, &envelope_id)
.await?
.get_envelope_by_id(account_id, &envelope_id)?
.ok_or_else(|| {
raise_error!(
format!(
@@ -220,12 +210,10 @@ impl MessageApi {
context: WrappedContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)
.await?;
AccountModel::check_account_exists(account_id)?;
context.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)?;
let envelope_id = envelope_id.0;
let reader = get_reader(account_id, envelope_id.clone()).await?;
let reader = get_reader(account_id, envelope_id.clone())?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
@@ -247,9 +235,7 @@ impl MessageApi {
context: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH)
.await?;
context.require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH)?;
Ok(restore_emails(account_id, payload.0.envelope_ids).await?)
}
@@ -271,12 +257,10 @@ impl MessageApi {
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
let envelope_id = envelope_id.0.trim().to_string();
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
AccountModel::check_account_exists(account_id)?;
context.require_permission(Some(account_id), Permission::DATA_READ)?;
let content_hash = content_hash.0.trim();
let reader = retrieve_attachment_content(account_id, envelope_id, content_hash).await?;
let reader = retrieve_attachment_content(account_id, envelope_id, content_hash)?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
@@ -303,10 +287,8 @@ impl MessageApi {
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
let envelope_id = envelope_id.0.trim().to_string();
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
AccountModel::check_account_exists(account_id)?;
context.require_permission(Some(account_id), Permission::DATA_READ)?;
let content_hash = content_hash.0.trim();
let nested_content_hash = nested_content_hash.0.trim();
let reader = retrieve_nested_attachment_content(
@@ -314,8 +296,7 @@ impl MessageApi {
envelope_id,
content_hash,
nested_content_hash,
)
.await?;
)?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
@@ -326,15 +307,13 @@ impl MessageApi {
/// Returns all facets in the index along with their document counts.
#[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")]
async fn get_all_tags(&self, context: WrappedContext) -> ApiResult<Json<Vec<TagCount>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(ENVELOPE_MANAGER.get_all_tags(authorized_ids).await?))
let authorized_ids: Option<HashSet<u64>> =
if context.has_permission(None, Permission::DATA_READ_ALL) {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(ENVELOPE_MANAGER.get_all_tags(authorized_ids)?))
}
/// Adds or removes facet tags for multiple emails across accounts.
@@ -354,9 +333,7 @@ impl MessageApi {
}
for account_id in req.updates.keys() {
context
.require_permission(Some(*account_id), Permission::DATA_MANAGE)
.await?;
context.require_permission(Some(*account_id), Permission::DATA_MANAGE)?;
}
ENVELOPE_MANAGER.update_envelope_tags(req).await?;
@@ -370,16 +347,12 @@ impl MessageApi {
operation_id = "get_all_contacts"
)]
async fn get_all_contacts(&self, context: WrappedContext) -> ApiResult<Json<HashSet<String>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ENVELOPE_MANAGER.get_all_contacts(authorized_ids).await?,
))
let authorized_ids: Option<HashSet<u64>> =
if context.has_permission(None, Permission::DATA_READ_ALL) {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(ENVELOPE_MANAGER.get_all_contacts(authorized_ids)?))
}
}

View File

@@ -52,13 +52,13 @@ impl OAuth2Api {
context: WrappedContext,
) -> ApiResult<Json<OAuth2>> {
let id = id.0;
let mut oauth2 = OAuth2::get(id).await?.ok_or_else(|| {
let mut oauth2 = OAuth2::get(id)?.ok_or_else(|| {
raise_error!(
format!("OAuth2 configuration id='{id}' not found"),
ErrorCode::ResourceNotFound
)
})?;
if context.has_permission(None, Permission::ROOT).await {
if context.has_permission(None, Permission::ROOT) {
return Ok(Json(oauth2));
}
oauth2.scrub_sensitive_fields();
@@ -80,8 +80,8 @@ impl OAuth2Api {
id: Path<u64>,
context: WrappedContext,
) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
Ok(OAuth2::delete(id.0).await?)
context.require_permission(None, Permission::ROOT)?;
Ok(OAuth2::delete(id.0)?)
}
/// Creates a new OAuth2 configuration.
@@ -99,9 +99,9 @@ impl OAuth2Api {
request: Json<OAuth2CreateRequest>,
context: WrappedContext,
) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
context.require_permission(None, Permission::ROOT)?;
let entity = OAuth2::new(request.0)?;
Ok(entity.save().await?)
Ok(entity.save()?)
}
/// Updates an existing OAuth2 configuration.
@@ -121,8 +121,8 @@ impl OAuth2Api {
payload: Json<OAuth2UpdateRequest>,
context: WrappedContext,
) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
Ok(OAuth2::update(id.0, payload.0).await?)
context.require_permission(None, Permission::ROOT)?;
Ok(OAuth2::update(id.0, payload.0)?)
}
/// Lists OAuth2 configurations with pagination and sorting options.
@@ -144,8 +144,8 @@ impl OAuth2Api {
desc: Query<Option<bool>>,
context: WrappedContext,
) -> ApiResult<Json<DataPage<OAuth2>>> {
let mut list = OAuth2::paginate_list(page.0, page_size.0, desc.0).await?;
if context.has_permission(None, Permission::ROOT).await {
let mut list = OAuth2::paginate_list(page.0, page_size.0, desc.0)?;
if context.has_permission(None, Permission::ROOT) {
return Ok(Json(list));
}
//Non-root users can only view masked data.
@@ -172,15 +172,13 @@ impl OAuth2Api {
context: WrappedContext,
) -> ApiResult<PlainText<String>> {
let request = request.0;
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(Some(request.account_id), Permission::ACCOUNT_MANAGE),
])
.await?;
context.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(Some(request.account_id), Permission::ACCOUNT_MANAGE),
])?;
let flow = OAuth2Flow::new(request.oauth2_id);
Ok(PlainText(flow.authorize_url(request.account_id).await?))
Ok(PlainText(flow.authorize_url(request.account_id)?))
}
/// Retrieves OAuth2 access tokens for a specified account.
@@ -198,17 +196,13 @@ impl OAuth2Api {
context: WrappedContext,
) -> ApiResult<Json<OAuth2AccessToken>> {
let account = account_id.0;
context
.require_permission(Some(account), Permission::ACCOUNT_MANAGE)
.await?;
Ok(Json(OAuth2AccessToken::get(account).await?.ok_or_else(
|| {
raise_error!(
"OAuth2 access tokens not found".into(),
ErrorCode::ResourceNotFound
)
},
)?))
context.require_permission(Some(account), Permission::ACCOUNT_MANAGE)?;
Ok(Json(OAuth2AccessToken::get(account)?.ok_or_else(|| {
raise_error!(
"OAuth2 access tokens not found".into(),
ErrorCode::ResourceNotFound
)
})?))
}
/// Configures an external OAuth2 token for a specified account.
@@ -239,12 +233,10 @@ impl OAuth2Api {
context: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
AccountModel::check_account_exists(account_id)?;
// Check account access permissions
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
OAuth2AccessToken::upsert_external_oauth_token(account_id, request.0).await?;
context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?;
OAuth2AccessToken::upsert_external_oauth_token(account_id, request.0)?;
Ok(())
}
}

View File

@@ -75,7 +75,6 @@ impl SystemApi {
async fn list_proxy(&self, _context: WrappedContext) -> ApiResult<Json<Vec<Proxy>>> {
//The proxy list is visible to all users.
let proxies = Proxy::list_all()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Json(proxies))
}
@@ -88,8 +87,8 @@ impl SystemApi {
id: Path<u64>,
context: WrappedContext,
) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
Ok(Proxy::delete(id.0).await?)
context.require_permission(None, Permission::ROOT)?;
Ok(Proxy::delete(id.0)?)
}
/// Retrieve a specific proxy configuration by ID. Requires root permission.
@@ -100,16 +99,16 @@ impl SystemApi {
id: Path<u64>,
context: WrappedContext,
) -> ApiResult<Json<Proxy>> {
context.require_permission(None, Permission::ROOT).await?;
Ok(Json(Proxy::get(id.0).await?))
context.require_permission(None, Permission::ROOT)?;
Ok(Json(Proxy::get(id.0)?))
}
/// Create a new proxy configuration. Requires root permission.
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
async fn create_proxy(&self, url: PlainText<String>, context: WrappedContext) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
context.require_permission(None, Permission::ROOT)?;
let entity = Proxy::new(url.0);
Ok(entity.save().await?)
Ok(entity.save()?)
}
/// Update the URL of a specific proxy by ID. Requires root permission.
@@ -120,8 +119,8 @@ impl SystemApi {
url: PlainText<String>,
context: WrappedContext,
) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
Ok(Proxy::update(id.0, url.0).await?)
context.require_permission(None, Permission::ROOT)?;
Ok(Proxy::update(id.0, url.0)?)
}
/// Get system configurations.
///
@@ -136,7 +135,7 @@ impl SystemApi {
&self,
context: WrappedContext,
) -> ApiResult<Json<SystemConfigurations>> {
context.require_permission(None, Permission::ROOT).await?;
context.require_permission(None, Permission::ROOT)?;
let config: SystemConfigurations = SystemConfigurations::from(&*SETTINGS);
Ok(Json(config))
}

View File

@@ -40,11 +40,8 @@ pub struct UsersApi;
impl UsersApi {
#[oai(path = "/list-roles", method = "get", operation_id = "list_roles")]
async fn list_roles(&self, context: WrappedContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(UserRole::list_all().await?))
context.require_permission(None, Permission::USER_MANAGE)?;
Ok(Json(UserRole::list_all()?))
}
#[oai(path = "/roles/:id", method = "delete", operation_id = "remove_role")]
@@ -55,10 +52,8 @@ impl UsersApi {
context: WrappedContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::delete(id).await?)
context.require_permission(None, Permission::USER_MANAGE)?;
Ok(UserRole::delete(id)?)
}
/// Create a new account
@@ -69,10 +64,8 @@ impl UsersApi {
payload: Json<RoleCreateRequest>,
context: WrappedContext,
) -> ApiResult<Json<UserRole>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let role = UserRole::create(payload.0).await?;
context.require_permission(None, Permission::USER_MANAGE)?;
let role = UserRole::create(payload.0)?;
Ok(Json(role))
}
@@ -87,20 +80,16 @@ impl UsersApi {
context: WrappedContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::update(id, payload.0).await?)
context.require_permission(None, Permission::USER_MANAGE)?;
Ok(UserRole::update(id, payload.0)?)
}
#[oai(path = "/list-users", method = "get", operation_id = "list_users")]
async fn list_users(&self, context: WrappedContext) -> ApiResult<Json<Vec<UserView>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let roles = UserRole::list_all().await?;
context.require_permission(None, Permission::USER_MANAGE)?;
let roles = UserRole::list_all()?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
let users = UserModel::list_all().await?;
let users = UserModel::list_all()?;
let users = users.into_iter().map(|u| u.to_view(&role_lookup)).collect();
Ok(Json(users))
}
@@ -116,13 +105,11 @@ impl UsersApi {
context: WrappedContext,
) -> ApiResult<Json<Vec<AccessTokenModel>>> {
let target_user_id = id.0;
let tokens = AccessTokenModel::get_user_api_tokens(target_user_id).await?;
let tokens = AccessTokenModel::get_user_api_tokens(target_user_id)?;
if context.user.id == target_user_id {
return Ok(Json(tokens));
}
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
context.require_permission(None, Permission::USER_MANAGE)?;
Ok(Json(tokens))
}
@@ -134,10 +121,8 @@ impl UsersApi {
context: WrappedContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserModel::remove(id).await?)
context.require_permission(None, Permission::USER_MANAGE)?;
Ok(UserModel::remove(id)?)
}
#[oai(path = "/users", method = "post", operation_id = "create_user")]
@@ -146,11 +131,9 @@ impl UsersApi {
payload: Json<UserCreateRequest>,
context: WrappedContext,
) -> ApiResult<Json<UserView>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let user = UserModel::create(payload.0).await?;
let roles = UserRole::list_all().await?;
context.require_permission(None, Permission::USER_MANAGE)?;
let user = UserModel::create(payload.0)?;
let roles = UserRole::list_all()?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(user.to_view(&role_lookup)))
}
@@ -165,19 +148,15 @@ impl UsersApi {
let target_id = id.0;
let current_user_id = context.user.id;
if current_user_id != target_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
context.require_permission(None, Permission::USER_MANAGE)?;
}
let mut update_data = payload.0;
if current_user_id == target_id
&& !context.has_permission(None, Permission::USER_MANAGE).await
{
if current_user_id == target_id && !context.has_permission(None, Permission::USER_MANAGE) {
update_data.global_roles = None;
update_data.account_access_map = None;
update_data.acl = None;
}
Ok(UserModel::update(target_id, update_data).await?)
Ok(UserModel::update(target_id, update_data)?)
}
#[oai(
@@ -186,7 +165,7 @@ impl UsersApi {
operation_id = "get_current_user"
)]
async fn get_current_user(&self, context: WrappedContext) -> ApiResult<Json<UserView>> {
let roles = UserRole::list_all().await?;
let roles = UserRole::list_all()?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(context.0.user.to_view(&role_lookup)))
}
@@ -200,15 +179,12 @@ impl UsersApi {
&self,
context: WrappedContext,
) -> ApiResult<Json<Vec<MinimalUser>>> {
let is_admin = context.user.is_admin().await;
let minimal_list = MinimalUser::list_all().await?;
let is_admin = context.user.is_admin();
let minimal_list = MinimalUser::list_all()?;
if is_admin {
return Ok(Json(minimal_list));
}
context
.require_permission(None, Permission::USER_VIEW)
.await?;
context.require_permission(None, Permission::USER_VIEW)?;
Ok(Json(minimal_list))
}
@@ -218,10 +194,8 @@ impl UsersApi {
operation_id = "list_account_roles"
)]
async fn list_account_roles(&self, context: WrappedContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_VIEW)
.await?;
let all = UserRole::list_all().await?;
context.require_permission(None, Permission::USER_VIEW)?;
let all = UserRole::list_all()?;
Ok(Json(
all.into_iter()
.filter(|r| matches!(r.role_type, RoleType::Account))

View File

@@ -32,9 +32,9 @@ pub struct LoginPayload {
/// Accepts a plain text password and returns the `root_token`
/// on successful authentication.
#[handler]
pub async fn login(payload: Json<LoginPayload>) -> Response {
pub fn login(payload: Json<LoginPayload>) -> Response {
let payload = payload.0;
match UserModel::authenticate_user(payload.username, payload.password).await {
match UserModel::authenticate_user(payload.username, payload.password) {
Ok(result) => match serde_json::to_string(&result) {
Ok(json_string) => Response::builder()
.status(http::StatusCode::OK)

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use bichon_core::oauth2::{flow::OAuth2Flow, pending::OAuth2PendingEntity};
use poem::{
handler,
@@ -56,7 +55,7 @@ pub async fn oauth2_callback(
}
};
let pending = match OAuth2PendingEntity::get(state).await {
let pending = match OAuth2PendingEntity::get(state) {
Ok(Some(pending)) => pending,
_ => {
let message =
@@ -86,7 +85,7 @@ pub async fn oauth2_callback(
.into_response());
}
if let Err(e) = OAuth2PendingEntity::delete(state).await {
if let Err(e) = OAuth2PendingEntity::delete(state) {
error!("Failed to delete pending OAuth2 entity: {}", e);
}

View File

@@ -0,0 +1,58 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
use poem::test::TestClient;
use serde::Serialize;
use super::{admin_token, build_api_route, setup};
fn api_client(route: impl poem::Endpoint) -> TestClient<impl poem::Endpoint> {
TestClient::new(route).default_header("X-Forwarded-For", "127.0.0.1")
}
#[derive(Debug, Serialize)]
struct CreateTokenPayload {
name: String,
}
#[tokio::test]
async fn access_token_crud() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
// Create a new API token
let create = CreateTokenPayload {
name: "Test API Token".into(),
};
let resp = cli
.post("/api/v1/access-token")
.header("Authorization", &format!("Bearer {}", token))
.body_json(&create)
.send()
.await;
resp.assert_status_is_ok();
let new_token = resp.0.into_body().into_string().await.unwrap_or_default();
assert!(!new_token.is_empty(), "token string should not be empty");
// Verify token now appears in the list
let resp = cli
.get("/api/v1/access-token-list")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
let tokens: Vec<serde_json::Value> = resp.json().await.value().deserialize();
assert!(!tokens.is_empty(), "token list should not be empty after creation");
// Delete the NEW token (not the admin's WebUI token)
let resp = cli
.delete(&format!("/api/v1/access-token/{}", new_token))
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
}

View File

@@ -0,0 +1,202 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
use poem::test::TestClient;
use serde::{Deserialize, Serialize};
use super::{admin_token, build_api_route, setup};
fn api_client(route: impl poem::Endpoint) -> TestClient<impl poem::Endpoint> {
TestClient::new(route).default_header("X-Forwarded-For", "127.0.0.1")
}
// ── Payloads ────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
struct CreateAccountPayload {
email: String,
enabled: bool,
account_type: String,
use_dangerous: bool,
#[serde(skip_serializing_if = "Option::is_none")]
account_name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AccountResp {
id: u64,
email: String,
enabled: bool,
account_name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DataPage<T> {
items: Vec<T>,
total_items: u64,
}
#[derive(Debug, Serialize)]
struct UpdateAccountPayload {
enabled: Option<bool>,
account_name: Option<String>,
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[tokio::test]
async fn account_crud() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
// ── Create ──────────────────────────────────────────────────────────
let create_payload = CreateAccountPayload {
email: "test-crud@example.com".into(),
enabled: false,
account_type: "NoSync".into(),
use_dangerous: false,
account_name: Some("CRUD Test Account".into()),
};
let resp = cli
.post("/api/v1/account")
.header("Authorization", &format!("Bearer {}", token))
.body_json(&create_payload)
.send()
.await;
resp.assert_status_is_ok();
let account: AccountResp = resp.json().await.value().deserialize();
assert_eq!(account.email, "test-crud@example.com");
assert!(!account.enabled);
let account_id = account.id;
// ── Read ────────────────────────────────────────────────────────────
let resp = cli
.get(&format!("/api/v1/account/{}", account_id))
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
let account: AccountResp = resp.json().await.value().deserialize();
assert_eq!(account.id, account_id);
// ── List ────────────────────────────────────────────────────────────
let resp = cli
.get("/api/v1/accounts")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
// ── Update ──────────────────────────────────────────────────────────
let update_payload = UpdateAccountPayload {
enabled: Some(true),
account_name: Some("Updated Name".into()),
};
let resp = cli
.post(&format!("/api/v1/account/{}", account_id))
.header("Authorization", &format!("Bearer {}", token))
.body_json(&update_payload)
.send()
.await;
resp.assert_status_is_ok();
// ── Delete ──────────────────────────────────────────────────────────
let resp = cli
.delete(&format!("/api/v1/account/{}", account_id))
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
// ── Verify deleted ──────────────────────────────────────────────────
let resp = cli
.get(&format!("/api/v1/account/{}", account_id))
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
assert!(resp.0.status().is_client_error(), "should be 4xx after delete");
}
#[tokio::test]
async fn create_account_with_invalid_email_fails() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let payload = CreateAccountPayload {
email: "not-an-email".into(),
enabled: false,
account_type: "NoSync".into(),
use_dangerous: false,
account_name: None,
};
let resp = cli
.post("/api/v1/account")
.header("Authorization", &format!("Bearer {}", token))
.body_json(&payload)
.send()
.await;
assert!(resp.0.status().is_client_error(), "invalid email should fail");
}
#[tokio::test]
async fn create_account_with_empty_email_fails() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let payload = CreateAccountPayload {
email: "".into(),
enabled: false,
account_type: "NoSync".into(),
use_dangerous: false,
account_name: None,
};
let resp = cli
.post("/api/v1/account")
.header("Authorization", &format!("Bearer {}", token))
.body_json(&payload)
.send()
.await;
assert!(resp.0.status().is_client_error(), "empty email should fail");
}
#[tokio::test]
async fn get_nonexistent_account_returns_error() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let resp = cli
.get("/api/v1/account/99999999")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
assert!(resp.0.status().is_client_error(), "nonexistent account should 4xx");
}
#[tokio::test]
async fn delete_nonexistent_account_returns_error() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let resp = cli
.delete("/api/v1/account/99999999")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
assert!(resp.0.status().is_client_error(), "delete nonexistent should 4xx");
}

View File

@@ -0,0 +1,216 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
pub mod access_token_tests;
pub mod account_tests;
pub mod oauth2_tests;
pub mod proxy_tests;
pub mod role_tests;
pub mod system_tests;
pub mod user_tests;
use std::{
path::PathBuf,
sync::{
LazyLock,
Mutex,
},
};
use bichon_core::{
common::signal::SignalManager,
context::{executors::BichonContext, Initialize},
settings::{
cli::SETTINGS,
dir::DataDirManager,
},
store::{
blob::BLOB_MANAGER,
tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
},
users::manager::UserManager,
};
use poem::{EndpointExt, Route};
use serde::{Deserialize, Serialize};
use crate::{
common::{
auth::ApiGuard,
error::ErrorCapture,
log::Tracing,
timeout::Timeout,
},
rest::api::create_openapi_service,
};
static INIT: Mutex<bool> = Mutex::new(false);
/// Initialize the test environment. Safe to call multiple times — only runs once.
pub async fn setup() {
let mut initialized = INIT.lock().unwrap();
if *initialized {
drop(initialized);
return;
}
let root = PathBuf::from(&SETTINGS.bichon_root_dir);
if root.exists() {
let _ = std::fs::remove_dir_all(&root);
}
SignalManager::initialize().await.unwrap();
DataDirManager::initialize().await.unwrap();
UserManager::initialize().await.unwrap();
BichonContext::initialize().await.unwrap();
LazyLock::force(&BLOB_MANAGER);
LazyLock::force(&ENVELOPE_MANAGER);
LazyLock::force(&ATTACHMENT_MANAGER);
*initialized = true;
}
/// Build the full API route (same middleware stack as production).
pub fn build_api_route() -> impl poem::Endpoint {
let api_service = create_openapi_service();
Route::new()
.nest_no_strip("/api/v1", api_service)
.with(ApiGuard)
.with(ErrorCapture)
.with(Timeout)
.with(Tracing)
}
// ── Shared types ────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
struct LoginPayload {
username: String,
password: String,
}
#[derive(Debug, Deserialize)]
struct LoginResult {
success: bool,
#[allow(dead_code)]
error_message: Option<String>,
access_token: Option<String>,
#[allow(dead_code)]
theme: Option<String>,
#[allow(dead_code)]
language: Option<String>,
}
// ── Helpers ─────────────────────────────────────────────────────────────────
static TOKEN_CACHE: Mutex<Option<String>> = Mutex::new(None);
/// Login as admin and return the access token. The token is cached so
/// multiple callers share the same token (each call to
/// `reset_webui_token` would invalidate the previous one).
pub async fn admin_token() -> String {
let mut cache = TOKEN_CACHE.lock().unwrap();
if let Some(ref token) = *cache {
return token.clone();
}
let login_route = poem::Route::new()
.at("/api/login", poem::post(crate::rest::public::login::login));
let cli = poem::test::TestClient::new(login_route);
let resp = cli
.post("/api/login")
.body_json(&LoginPayload {
username: "admin".into(),
password: "admin@bichon".into(),
})
.send()
.await;
resp.assert_status_is_ok();
let result: LoginResult = resp.json().await.value().deserialize();
assert!(result.success, "Admin login failed");
let token = result.access_token.expect("access_token should be present");
*cache = Some(token.clone());
token
}
// ── Auth / Login Tests ─────────────────────────────────────────────────────
#[tokio::test]
async fn login_with_wrong_password_fails() {
setup().await;
let login_route = poem::Route::new()
.at("/api/login", poem::post(crate::rest::public::login::login));
let cli = poem::test::TestClient::new(login_route);
let resp = cli
.post("/api/login")
.body_json(&LoginPayload {
username: "admin".into(),
password: "wrong-password".into(),
})
.send()
.await;
resp.assert_status_is_ok();
let result: LoginResult = resp.json().await.value().deserialize();
assert!(!result.success);
}
#[tokio::test]
async fn login_with_nonexistent_user_fails() {
setup().await;
let login_route = poem::Route::new()
.at("/api/login", poem::post(crate::rest::public::login::login));
let cli = poem::test::TestClient::new(login_route);
let resp = cli
.post("/api/login")
.body_json(&LoginPayload {
username: "nonexistent".into(),
password: "whatever".into(),
})
.send()
.await;
resp.assert_status_is_ok();
let result: LoginResult = resp.json().await.value().deserialize();
assert!(!result.success);
}
#[tokio::test]
async fn protected_endpoint_requires_auth() {
setup().await;
let route = build_api_route();
let cli = poem::test::TestClient::new(route)
.default_header("X-Forwarded-For", "127.0.0.1");
// Without auth header — should fail (4xx)
let resp = cli.get("/api/v1/list-roles").send().await;
assert!(
resp.0.status().is_client_error(),
"expected 4xx for missing auth"
);
// With invalid token — should fail (4xx)
let resp = cli
.get("/api/v1/list-roles")
.header("Authorization", "Bearer invalid-token-here")
.send()
.await;
assert!(
resp.0.status().is_client_error(),
"expected 4xx for invalid token"
);
// With valid admin token — should succeed
let token = admin_token().await;
let resp = cli
.get("/api/v1/list-roles")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
}

View File

@@ -0,0 +1,100 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
use poem::test::TestClient;
use serde::{Deserialize, Serialize};
use super::{admin_token, build_api_route, setup};
fn api_client(route: impl poem::Endpoint) -> TestClient<impl poem::Endpoint> {
TestClient::new(route).default_header("X-Forwarded-For", "127.0.0.1")
}
#[derive(Debug, Serialize)]
struct CreateOAuth2Payload {
client_id: String,
client_secret: String,
auth_url: String,
token_url: String,
redirect_uri: String,
enabled: bool,
}
#[derive(Debug, Deserialize)]
struct OAuth2Config {
id: u64,
client_id: String,
enabled: bool,
}
#[derive(Debug, Serialize)]
struct UpdateOAuth2Payload {
enabled: Option<bool>,
}
#[tokio::test]
async fn oauth2_crud() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
// Create
let create = CreateOAuth2Payload {
client_id: "test-client-id".into(),
client_secret: "test-client-secret".into(),
auth_url: "https://provider.example.com/auth".into(),
token_url: "https://provider.example.com/token".into(),
redirect_uri: "http://localhost/callback".into(),
enabled: false,
};
let resp = cli
.post("/api/v1/oauth2")
.header("Authorization", &format!("Bearer {}", token))
.body_json(&create)
.send()
.await;
resp.assert_status_is_ok();
// List
let resp = cli
.get("/api/v1/oauth2-list")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
let list: serde_json::Value = resp.json().await.value().deserialize();
let items = list["items"].as_array().expect("items array");
assert!(!items.is_empty(), "should have at least one OAuth2 config");
let id = items[0]["id"].as_u64().unwrap();
// Get by ID
let resp = cli
.get(&format!("/api/v1/oauth2/{}", id))
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
// Update
let update = UpdateOAuth2Payload {
enabled: Some(true),
};
let resp = cli
.post(&format!("/api/v1/oauth2/{}", id))
.header("Authorization", &format!("Bearer {}", token))
.body_json(&update)
.send()
.await;
resp.assert_status_is_ok();
// Delete
let resp = cli
.delete(&format!("/api/v1/oauth2/{}", id))
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
}

View File

@@ -0,0 +1,74 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
use poem::test::TestClient;
use serde::Deserialize;
use super::{admin_token, build_api_route, setup};
fn api_client(route: impl poem::Endpoint) -> TestClient<impl poem::Endpoint> {
TestClient::new(route).default_header("X-Forwarded-For", "127.0.0.1")
}
#[derive(Debug, Deserialize)]
struct Proxy {
id: u64,
url: String,
}
#[tokio::test]
async fn proxy_crud() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
// Create proxy
let resp = cli
.post("/api/v1/proxy")
.header("Authorization", &format!("Bearer {}", token))
.content_type("text/plain")
.body("socks5://127.0.0.1:1080")
.send()
.await;
resp.assert_status_is_ok();
// List proxies
let resp = cli
.get("/api/v1/list-proxy")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
let proxies: Vec<Proxy> = resp.json().await.value().deserialize();
assert!(!proxies.is_empty(), "should have at least one proxy");
let proxy_id = proxies[0].id;
// Get single proxy
let resp = cli
.get(&format!("/api/v1/proxy/{}", proxy_id))
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
// Update proxy
let resp = cli
.post(&format!("/api/v1/proxy/{}", proxy_id))
.header("Authorization", &format!("Bearer {}", token))
.content_type("text/plain")
.body("socks5://192.168.1.1:1080")
.send()
.await;
resp.assert_status_is_ok();
// Delete proxy
let resp = cli
.delete(&format!("/api/v1/proxy/{}", proxy_id))
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
}

View File

@@ -0,0 +1,113 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
use poem::test::TestClient;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use super::{admin_token, build_api_route, setup};
fn api_client(route: impl poem::Endpoint) -> TestClient<impl poem::Endpoint> {
TestClient::new(route).default_header("X-Forwarded-For", "127.0.0.1")
}
#[derive(Debug, Deserialize)]
struct UserRole {
id: u64,
name: String,
is_builtin: bool,
permissions: BTreeSet<String>,
role_type: String,
}
#[derive(Debug, Serialize)]
struct CreateRolePayload {
name: String,
role_type: String,
permissions: BTreeSet<String>,
}
#[derive(Debug, Serialize)]
struct UpdateRolePayload {
name: Option<String>,
}
const ADMIN_ROLE_ID: u64 = 100_000_000_000_000;
#[tokio::test]
async fn role_crud() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
// List roles (5 built-in roles exist)
let resp = cli
.get("/api/v1/list-roles")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
let roles: Vec<UserRole> = resp.json().await.value().deserialize();
assert!(roles.len() >= 5, "should have at least 5 built-in roles");
assert!(roles.iter().any(|r| r.name == "admin"));
assert!(roles.iter().any(|r| r.name == "manager"));
assert!(roles.iter().any(|r| r.name == "member"));
// Create custom role
let mut perms = BTreeSet::new();
perms.insert("user:view".into());
let create = CreateRolePayload {
name: "test-role".into(),
role_type: "Global".into(),
permissions: perms,
};
let resp = cli
.post("/api/v1/roles")
.header("Authorization", &format!("Bearer {}", token))
.body_json(&create)
.send()
.await;
resp.assert_status_is_ok();
let role: UserRole = resp.json().await.value().deserialize();
assert_eq!(role.name, "test-role");
assert!(!role.is_builtin);
let role_id = role.id;
// Update custom role
let update = UpdateRolePayload {
name: Some("test-role-updated".into()),
};
let resp = cli
.post(&format!("/api/v1/roles/{}", role_id))
.header("Authorization", &format!("Bearer {}", token))
.body_json(&update)
.send()
.await;
resp.assert_status_is_ok();
// Delete custom role
let resp = cli
.delete(&format!("/api/v1/roles/{}", role_id))
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn cannot_delete_builtin_role() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let resp = cli
.delete(&format!("/api/v1/roles/{}", ADMIN_ROLE_ID))
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
assert!(resp.0.status().is_client_error(), "deleting builtin role should fail");
}

View File

@@ -0,0 +1,123 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
use poem::test::TestClient;
use serde::Deserialize;
use super::{admin_token, build_api_route, setup};
fn api_client(route: impl poem::Endpoint) -> TestClient<impl poem::Endpoint> {
TestClient::new(route).default_header("X-Forwarded-For", "127.0.0.1")
}
#[derive(Debug, Deserialize)]
struct SystemConfig {
bichon_root_dir: String,
bichon_http_port: i32,
bichon_version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DashboardStats {
account_count: usize,
email_count: u64,
attachment_count: u64,
system_version: String,
}
#[tokio::test]
async fn get_system_configurations() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let resp = cli
.get("/api/v1/system-configurations")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn get_dashboard_stats_returns_data() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let resp = cli
.get("/api/v1/dashboard-stats")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
// Dashboard stats may return errors if tantivy schemas are empty,
// but should always produce a response (not a crash)
let status = resp.0.status();
// Accept both success (200) and error (4xx/5xx) — just ensure it doesn't panic
assert!(status.as_u16() > 0, "should produce a valid HTTP response");
}
#[tokio::test]
async fn list_proxy_returns_array() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let resp = cli
.get("/api/v1/list-proxy")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn list_roles_returns_builtins() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let resp = cli
.get("/api/v1/list-roles")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn minimal_user_list_works() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let resp = cli
.get("/api/v1/minimal-user-list")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn list_account_roles_works() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let resp = cli
.get("/api/v1/list-account-roles")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
}

View File

@@ -0,0 +1,155 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
use poem::test::TestClient;
use serde::{Deserialize, Serialize};
use super::{admin_token, build_api_route, setup};
fn api_client(route: impl poem::Endpoint) -> TestClient<impl poem::Endpoint> {
TestClient::new(route).default_header("X-Forwarded-For", "127.0.0.1")
}
#[derive(Debug, Serialize)]
struct CreateUserPayload {
username: String,
email: String,
password: String,
global_roles: Vec<u64>,
account_access_map: std::collections::BTreeMap<u64, u64>,
}
#[derive(Debug, Deserialize)]
struct UserView {
id: u64,
username: String,
email: String,
}
#[derive(Debug, Serialize)]
struct UpdateUserPayload {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
const ADMIN_ROLE_ID: u64 = 100_000_000_000_000;
#[tokio::test]
async fn user_crud() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
// Create
let create_payload = CreateUserPayload {
username: "testuser1".into(),
email: "testuser1@example.com".into(),
password: "testpass123".into(),
global_roles: vec![ADMIN_ROLE_ID],
account_access_map: Default::default(),
};
let resp = cli
.post("/api/v1/users")
.header("Authorization", &format!("Bearer {}", token))
.body_json(&create_payload)
.send()
.await;
resp.assert_status_is_ok();
let user: UserView = resp.json().await.value().deserialize();
assert_eq!(user.username, "testuser1");
let user_id = user.id;
// List users
let resp = cli
.get("/api/v1/list-users")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
// Get current user
let resp = cli
.get("/api/v1/current-user")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
// Update
let update = UpdateUserPayload {
description: Some("Test description".into()),
};
let resp = cli
.post(&format!("/api/v1/users/{}", user_id))
.header("Authorization", &format!("Bearer {}", token))
.body_json(&update)
.send()
.await;
resp.assert_status_is_ok();
// Delete
let resp = cli
.delete(&format!("/api/v1/users/{}", user_id))
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn create_user_with_invalid_data_fails() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
// Username too short (min 3)
let payload = CreateUserPayload {
username: "ab".into(),
email: "valid@example.com".into(),
password: "testpass123".into(),
global_roles: vec![ADMIN_ROLE_ID],
account_access_map: Default::default(),
};
let resp = cli
.post("/api/v1/users")
.header("Authorization", &format!("Bearer {}", token))
.body_json(&payload)
.send()
.await;
assert!(resp.0.status().is_client_error(), "short username should fail");
// Password too short (min 8)
let payload = CreateUserPayload {
username: "validuser".into(),
email: "valid@example.com".into(),
password: "short".into(),
global_roles: vec![ADMIN_ROLE_ID],
account_access_map: Default::default(),
};
let resp = cli
.post("/api/v1/users")
.header("Authorization", &format!("Bearer {}", token))
.body_json(&payload)
.send()
.await;
assert!(resp.0.status().is_client_error(), "short password should fail");
}
#[tokio::test]
async fn cannot_delete_default_admin() {
setup().await;
let token = admin_token().await;
let route = build_api_route();
let cli = api_client(route);
let resp = cli
.delete("/api/v1/users/100000000000000")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
assert!(resp.0.status().is_client_error(), "deleting admin should fail");
}

Some files were not shown because too many files have changed in this diff Show More