mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d150c2982 | ||
|
|
9b49005522 | ||
|
|
1f4e9f7b06 | ||
|
|
d0e4cac229 | ||
|
|
30a8d856c1 | ||
|
|
7240be8c31 | ||
|
|
0d20a9676a | ||
|
|
48f8092b5a | ||
|
|
a64351d409 | ||
|
|
a6dd1d19ff | ||
|
|
f82b20e2fd | ||
|
|
9841038acb | ||
|
|
97c3db3bd2 | ||
|
|
82fb2a02bc | ||
|
|
c61977ce5c | ||
|
|
106a08fb7e | ||
|
|
3419506c2e | ||
|
|
3b040d0cd6 | ||
|
|
5d3c319a67 | ||
|
|
ad2a43aa35 | ||
|
|
884ae64fc5 | ||
|
|
254de35f0e | ||
|
|
bc3eba5bf7 | ||
|
|
4dc99b4a84 | ||
|
|
8ce8b9692d | ||
|
|
3fb761064d | ||
|
|
54a0a71c44 | ||
|
|
b490923e17 | ||
|
|
4ee44daf0d | ||
|
|
7edd7c2e35 | ||
|
|
0c46432150 | ||
|
|
d334a23ca7 | ||
|
|
1768c1a590 | ||
|
|
147f5b4f55 | ||
|
|
48312dc83e | ||
|
|
55e97510c4 | ||
|
|
0bf2003670 | ||
|
|
e56fe5ebea | ||
|
|
c69ada32ef | ||
|
|
3f4b37be17 | ||
|
|
09375ee11c | ||
|
|
b2e43b0907 | ||
|
|
44fc0e15de | ||
|
|
ef891b20c3 | ||
|
|
a6216c2ce6 | ||
|
|
1c58b516dd | ||
|
|
ae916574de | ||
|
|
14fb3368a3 | ||
|
|
f49929dd67 | ||
|
|
97143d55b8 | ||
|
|
e666f76d87 | ||
|
|
7fb6575f8d | ||
|
|
70db81dc03 |
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
env:
|
||||
BINARY_NAME: bichon
|
||||
BINARY_CTL: bichonctl
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -91,6 +92,7 @@ jobs:
|
||||
if: matrix.os != 'windows-latest' && matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}
|
||||
|
||||
- name: Pack artifact (Linux/macOS)
|
||||
if: matrix.os != 'windows-latest'
|
||||
@@ -99,7 +101,8 @@ jobs:
|
||||
mkdir -p release
|
||||
BINARY="target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
|
||||
cp README.md LICENSE release/
|
||||
cp "$BINARY" release/
|
||||
cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} release/
|
||||
cp target/${{ matrix.target }}/release/${{ env.BINARY_CTL }} release/
|
||||
tar -czvf "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" -C release .
|
||||
mv "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" release/
|
||||
|
||||
@@ -115,10 +118,13 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
mkdir -p release
|
||||
$BINARY = "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe"
|
||||
Copy-Item -Path $BINARY -Destination release/
|
||||
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" release/
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}.exe" release/
|
||||
|
||||
Copy-Item -Path README.md -Destination release/
|
||||
Copy-Item -Path LICENSE -Destination release/
|
||||
|
||||
Compress-Archive -Path release\* -DestinationPath "release/${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.zip" -Force
|
||||
|
||||
- name: Upload build artifact
|
||||
|
||||
603
Cargo.lock
generated
603
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
33
Cargo.toml
@@ -1,12 +1,15 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "bichon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bichonctl"
|
||||
path = "src/bin/bichonctl.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -20,7 +23,7 @@ codegen-units = 1
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.42"
|
||||
clap = { version = "4.5.53", features = ["derive", "env"] }
|
||||
clap = { version = "4.5.54", features = ["derive", "env"] }
|
||||
mimalloc = "0.1.48"
|
||||
native_db = "0.8.2"
|
||||
itertools = "0.14.0"
|
||||
@@ -37,9 +40,9 @@ poem-openapi = { version = "5.1.16", features = [
|
||||
] }
|
||||
ring = { version = "0.17.14", features = ["std"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
tracing = "0.1.43"
|
||||
serde_json = "1.0.148"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
|
||||
base64 = "0.22.1"
|
||||
@@ -68,7 +71,7 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
||||
timeago = "0.5.0"
|
||||
ahash = "0.8.12"
|
||||
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
|
||||
url = { version = "2.5.7", features = ["serde"] }
|
||||
url = { version = "2.5.8", features = ["serde"] }
|
||||
sysinfo = "0.37.2"
|
||||
num_cpus = "1.17.0"
|
||||
cacache = { version = "13.1.0", default-features = false, features = [
|
||||
@@ -83,11 +86,11 @@ async-imap = { version = "0.11.1", default-features = false, features = [
|
||||
] }
|
||||
webpki-roots = "1.0.4"
|
||||
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
|
||||
rustls-pki-types = "1.13.1"
|
||||
rustls-pki-types = "1.13.2"
|
||||
tokio-io-timeout = "1.2.1"
|
||||
bb8 = "0.9.1"
|
||||
semver = "1.0.27"
|
||||
governor = "0.10.2"
|
||||
governor = "0.10.4"
|
||||
lru = "0.16.2"
|
||||
mime_guess = "2.0.5"
|
||||
hex = "0.4.3"
|
||||
@@ -105,10 +108,18 @@ dashmap = "6.1.0"
|
||||
openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
|
||||
gethostname = "1.1.0"
|
||||
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
|
||||
itoa = "1.0.15"
|
||||
html2text = "0.16.4"
|
||||
itoa = "1.0.17"
|
||||
html2text = "0.16.5"
|
||||
bytes = "1.11.0"
|
||||
dialoguer = "0.12.0"
|
||||
console = "0.16.2"
|
||||
toml = "0.9.8"
|
||||
memmap2 = "0.9.9"
|
||||
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
|
||||
compressed-rtf = "1.0.0"
|
||||
codepage-strings = "1.0.2"
|
||||
mail-send = "0.5.2"
|
||||
[dev-dependencies]
|
||||
#bincode = "1.3.3"
|
||||
#secret-lib = "1.0.0"
|
||||
tempfile = "3.23.0"
|
||||
tempfile = "3.24.0"
|
||||
|
||||
37
README.md
37
README.md
@@ -65,7 +65,7 @@ Built in Rust, it requires no external dependencies and provides fast, efficient
|
||||
* **Internationalized WebUI** — Frontend available in 18 languages
|
||||
* **OpenAPI Access** — OpenAPI docs with access-token authentication
|
||||
* **Multi-User & Role-Based Access Control (RBAC)** — Supports multiple users with fine-grained, role-based permissions
|
||||
|
||||
* **Email Import (EML, MBOX & PST)** — Import existing mail archives via the bichonctl CLI
|
||||
|
||||
## 🐾 Why Create Bichon?
|
||||
|
||||
@@ -304,6 +304,20 @@ After logging in, the admin user can manage their profile directly in the WebUI:
|
||||
⚠️ **Security Notice:**
|
||||
For security reasons, you should **change the default admin password immediately after the first login**.
|
||||
|
||||
## 📦 Import Existing Mail Archives
|
||||
|
||||
If you already have existing emails stored as **EML** or **MBOX** files, you can import them into Bichon using the `bichonctl` CLI.
|
||||
|
||||
This allows you to:
|
||||
|
||||
- Index historical emails
|
||||
- Perform full-text search immediately
|
||||
- Manage imported data just like synced IMAP emails
|
||||
|
||||
📖 **Full documentation:**
|
||||
👉 https://github.com/rustmailer/bichon/wiki/Using-Bichonctl-For-Email-Import
|
||||
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
> Under construction. Documentation will be available soon.
|
||||
@@ -335,13 +349,14 @@ This data is provided solely as a **reference** for real-world usage. We encoura
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ✓ Multi-user support with account/password login
|
||||
- System-level roles (admin / user)
|
||||
- Per-mail-account permissions
|
||||
* [x] Multi-user support with account/password login
|
||||
* [x] System-level roles (admin / user)
|
||||
* [x] Per-mail-account permissions
|
||||
|
||||
* [ ] `bichon-cli` command-line tool
|
||||
* [x] `bichonctl` command-line tool
|
||||
|
||||
* Import emails from `eml`, `mbox`, `msg`, `pst`
|
||||
* [x] Import emails from `eml`, `mbox`, `pst` (Single file)
|
||||
* [ ] Import emails from `msg`
|
||||
|
||||
* [ ] Manual sync controls
|
||||
|
||||
@@ -455,9 +470,13 @@ This project is licensed under [AGPLv3](LICENSE).
|
||||
|
||||
## 💖 Support & Promotion
|
||||
|
||||
If this project has been helpful to you and you’d like to support its development, you can consider making a small donation or helping spread the word.
|
||||
Financial support is optional but deeply appreciated — it helps me dedicate more time and resources to building new features and improving the overall experience.
|
||||
Bichon is an open-source email platform focused on privacy, local ownership, and long-term stability.
|
||||
|
||||
You can also support the project by sharing it with others, writing about your experience, or recommending it within relevant communities. Every bit of visibility helps more people benefit from the tool!
|
||||
The project is freely available and fully functional for everyone.
|
||||
|
||||
Some members of the community choose to support the project financially. This support helps sustain ongoing development and long-term maintenance, while keeping the project independent and user-driven.
|
||||
|
||||
Support is always optional. You can also contribute by sharing feedback, reporting issues, or recommending Bichon to others.
|
||||
|
||||
[](https://buymeacoffee.com/rustmailer)
|
||||
|
||||
|
||||
2
config.toml
Normal file
2
config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
base_url = "http://localhost:15630"
|
||||
api_token = "UvkGJO0Mn1tO6igGZwQnTtI2"
|
||||
2
env/rustmailer.env → env/bichon.env
vendored
2
env/rustmailer.env → env/bichon.env
vendored
@@ -28,7 +28,7 @@ BICHON_ROOT_DIR=/data/bichon-data
|
||||
# Enable API access token validation
|
||||
BICHON_ENABLE_ACCESS_TOKEN=false
|
||||
|
||||
# IP address to bind the HTTP and gRPC servers to (default: 0.0.0.0)
|
||||
# IP address to bind the HTTP servers to (default: 0.0.0.0)
|
||||
BICHON_BIND_IP=
|
||||
|
||||
# Comma-separated list of allowed CORS origins (e.g. https://app.example.com)
|
||||
95
src/bin/bichonctl.rs
Normal file
95
src/bin/bichonctl.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use bichon::modules::cli::{
|
||||
BichonCli, BichonCtlConfig, auth::verify_user_and_get_account, eml::handle_eml_directory_import, mbox::handle_mbox_single_file_import, pst::handle_pst_import, thunderbird::handle_thunderbird_import
|
||||
};
|
||||
use clap::Parser;
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
|
||||
use std::fs;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = BichonCli::parse();
|
||||
let theme = ColorfulTheme::default();
|
||||
let config_path = &cli.config;
|
||||
let mut current_config: Option<BichonCtlConfig> = None;
|
||||
|
||||
if config_path.exists() {
|
||||
if let Ok(content) = fs::read_to_string(config_path) {
|
||||
if let Ok(config) = toml::from_str::<BichonCtlConfig>(&content) {
|
||||
println!("{}", style("✔ Existing configuration found:").green());
|
||||
println!(" Base URL: {}", style(&config.base_url).yellow());
|
||||
println!(" API Token: {}", style(&config.api_token).yellow());
|
||||
|
||||
// Confirm with user
|
||||
if Confirm::with_theme(&theme)
|
||||
.with_prompt("Do you want to use this configuration?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
current_config = Some(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let final_config = match current_config {
|
||||
Some(conf) => conf,
|
||||
None => {
|
||||
println!("\n{}", style("Please enter Bichon service details:").bold());
|
||||
|
||||
let url: String = Input::with_theme(&theme)
|
||||
.with_prompt("Bichon Base URL")
|
||||
.default("http://localhost:15630".into())
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let token: String = Input::with_theme(&theme)
|
||||
.with_prompt("API Token")
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let conf = BichonCtlConfig {
|
||||
base_url: url,
|
||||
api_token: token,
|
||||
};
|
||||
|
||||
// 3. Offer to save the new configuration
|
||||
if Confirm::with_theme(&theme)
|
||||
.with_prompt("Save this configuration for future use?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
let toml_str = toml::to_string(&conf).unwrap();
|
||||
fs::write(config_path, toml_str).expect("Failed to save config file");
|
||||
println!("{}", style("Configuration saved successfully!").green());
|
||||
}
|
||||
conf
|
||||
}
|
||||
};
|
||||
|
||||
let target_account_id = verify_user_and_get_account(&final_config, &theme).await;
|
||||
|
||||
let import_modes = &[
|
||||
"EML: Scan directory recursively (Maintains folder structure)",
|
||||
"MBOX: Single archive file (Stream from one file)",
|
||||
"Thunderbird: Import from local profile directory",
|
||||
"PST: Outlook Personal Storage (Single .pst file)",
|
||||
];
|
||||
|
||||
let mode_idx = Select::with_theme(&theme)
|
||||
.with_prompt("Select import method")
|
||||
.items(import_modes)
|
||||
.default(0)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
match mode_idx {
|
||||
0 => handle_eml_directory_import(&final_config, target_account_id, &theme).await,
|
||||
1 => handle_mbox_single_file_import(&final_config, target_account_id, &theme).await,
|
||||
2 => handle_thunderbird_import(&final_config, target_account_id, &theme).await,
|
||||
3 => handle_pst_import(&final_config, target_account_id, &theme).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
1
src/lib.rs
Normal file
1
src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod modules;
|
||||
23
src/main.rs
23
src/main.rs
@@ -16,23 +16,24 @@
|
||||
// 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 mimalloc::MiMalloc;
|
||||
use modules::{
|
||||
common::rustls::RustMailerTls,
|
||||
context::{executors::EmailClientExecutors, Initialize},
|
||||
error::BichonResult,
|
||||
logger,
|
||||
rest::start_http_server,
|
||||
tasks::PeriodicTasks,
|
||||
use bichon::{
|
||||
bichon_version,
|
||||
modules::{
|
||||
common::rustls::RustMailerTls,
|
||||
context::{executors::EmailClientExecutors, Initialize},
|
||||
error::BichonResult,
|
||||
logger,
|
||||
rest::start_http_server,
|
||||
tasks::PeriodicTasks,
|
||||
},
|
||||
};
|
||||
use mimalloc::MiMalloc;
|
||||
use tracing::info;
|
||||
|
||||
use crate::modules::{
|
||||
use bichon::modules::{
|
||||
common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager,
|
||||
};
|
||||
|
||||
mod modules;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
|
||||
@@ -195,13 +195,6 @@ impl AccountV3 {
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
// if !account.enabled {
|
||||
// return Err(raise_error!(
|
||||
// format!("Account id='{account_id}' is disabled"),
|
||||
// ErrorCode::AccountDisabled
|
||||
// ));
|
||||
// }
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
@@ -221,11 +214,6 @@ impl AccountV3 {
|
||||
.await
|
||||
}
|
||||
|
||||
// /// Saves the current `AccountEntity` by persisting it to storage.
|
||||
// pub async fn save(&self) -> BichonResult<()> {
|
||||
// insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
|
||||
// }
|
||||
|
||||
pub async fn create_account(
|
||||
user_id: u64,
|
||||
request: AccountCreateRequest,
|
||||
@@ -375,11 +363,13 @@ impl AccountV3 {
|
||||
list_all_impl(DB_MANAGER.meta_db()).await
|
||||
}
|
||||
|
||||
pub async fn minimal_list() -> BichonResult<Vec<MinimalAccount>> {
|
||||
pub async fn minimal_list(only_nosync: bool) -> BichonResult<Vec<MinimalAccount>> {
|
||||
let result = list_all_impl(DB_MANAGER.meta_db())
|
||||
.await?
|
||||
.into_iter()
|
||||
//.filter(|a: &AccountModel| a.enabled)
|
||||
.filter(|account: &AccountModel| {
|
||||
!only_nosync || matches!(account.account_type, AccountType::NoSync)
|
||||
})
|
||||
.map(|account: AccountModel| MinimalAccount {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
@@ -420,10 +410,23 @@ impl AccountV3 {
|
||||
new.date_since = None;
|
||||
}
|
||||
|
||||
if let Some(clear_date_range) = request.clear_date_range {
|
||||
if clear_date_range {
|
||||
new.date_since = None;
|
||||
new.date_before = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(folder_limit) = request.folder_limit {
|
||||
new.folder_limit = Some(folder_limit);
|
||||
}
|
||||
|
||||
if let Some(clear_folder_limit) = request.clear_folder_limit {
|
||||
if clear_folder_limit {
|
||||
new.folder_limit = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = &request.name {
|
||||
if name.trim().is_empty() {
|
||||
new.name = None;
|
||||
|
||||
@@ -37,7 +37,7 @@ pub struct AccountCreateRequest {
|
||||
pub account_type: AccountType,
|
||||
#[oai(validator(minimum(value = "100")))]
|
||||
pub folder_limit: Option<u32>,
|
||||
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
|
||||
#[oai(validator(minimum(value = "10")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
|
||||
pub sync_batch_size: Option<u32>,
|
||||
@@ -121,11 +121,13 @@ pub struct AccountUpdateRequest {
|
||||
/// - Reducing server load during resyncs
|
||||
pub date_since: Option<DateSince>,
|
||||
pub date_before: Option<RelativeDate>,
|
||||
pub clear_date_range: Option<bool>,
|
||||
/// Max emails to sync for this folder.
|
||||
/// If not set, sync all emails.
|
||||
/// otherwise sync up to `n` most recent emails (min 10).
|
||||
#[oai(validator(minimum(value = "100")))]
|
||||
pub folder_limit: Option<u32>,
|
||||
pub clear_folder_limit: Option<bool>,
|
||||
/// Configuration for selective folder (mailbox/label) synchronization
|
||||
///
|
||||
/// - For IMAP/SMTP accounts:
|
||||
@@ -141,7 +143,7 @@ pub struct AccountUpdateRequest {
|
||||
/// Modified folders will be automatically synced on the next update.
|
||||
pub sync_folders: Option<Vec<String>>,
|
||||
/// Incremental sync interval (seconds)
|
||||
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
|
||||
#[oai(validator(minimum(value = "10")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
|
||||
pub sync_batch_size: Option<u32>,
|
||||
@@ -165,6 +167,22 @@ impl AccountUpdateRequest {
|
||||
));
|
||||
}
|
||||
|
||||
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
|
||||
return Err(raise_error!(
|
||||
"clear_folder_limit cannot be combined with folder_limit".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if self.clear_date_range == Some(true)
|
||||
&& (self.date_since.is_some() || self.date_before.is_some())
|
||||
{
|
||||
return Err(raise_error!(
|
||||
"clear_date_range cannot be combined with date_since or date_before".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(date_since) = self.date_since.as_ref() {
|
||||
date_since.validate()?;
|
||||
}
|
||||
|
||||
41
src/modules/cache/imap/mailbox.rs
vendored
41
src/modules/cache/imap/mailbox.rs
vendored
@@ -16,13 +16,12 @@
|
||||
// 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::{
|
||||
decode_mailbox_name, encode_mailbox_name,
|
||||
modules::{
|
||||
database::{
|
||||
batch_delete_impl, batch_insert_impl, batch_upsert_impl, filter_by_secondary_key_impl,
|
||||
manager::DB_MANAGER,
|
||||
async_find_impl, batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl,
|
||||
filter_by_secondary_key_impl, manager::DB_MANAGER,
|
||||
},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
},
|
||||
@@ -90,25 +89,25 @@ impl MailBox {
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
// pub async fn get(id: u64) -> RustMailerResult<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 async fn 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 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 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 async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
|
||||
filter_by_secondary_key_impl(DB_MANAGER.envelope_db(), MailBoxKey::account_id, account_id)
|
||||
|
||||
169
src/modules/cli/auth.rs
Normal file
169
src/modules/cli/auth.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use std::process;
|
||||
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Select};
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::modules::{
|
||||
account::payload::MinimalAccount,
|
||||
cli::BichonCtlConfig,
|
||||
users::{permissions::Permission, view::UserView},
|
||||
};
|
||||
|
||||
pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &ColorfulTheme) -> u64 {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/api/v1/current-user", config.base_url);
|
||||
|
||||
let response = match client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"\n{} {}",
|
||||
style("✘ Network Error:").red().bold(),
|
||||
"Could not connect to Bichon service."
|
||||
);
|
||||
eprintln!("{} {}", style("Details:").dim(), e);
|
||||
eprintln!(
|
||||
"\n{} Please check if the Base URL is correct and the server is running.",
|
||||
style("Tip:").cyan()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "No error detail provided".to_string());
|
||||
|
||||
eprintln!(
|
||||
"\n{} Server returned an error (Status: {})",
|
||||
style("✘ API Error:").red().bold(),
|
||||
style(status).yellow()
|
||||
);
|
||||
|
||||
if status == 401 {
|
||||
eprintln!(
|
||||
"{} Your API Token seems to be invalid or expired.",
|
||||
style("Context:").dim()
|
||||
);
|
||||
} else if status == 404 {
|
||||
eprintln!(
|
||||
"{} The endpoint was not found. Please check your Base URL.",
|
||||
style("Context:").dim()
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!("{} {}", style("Response:").dim(), error_body);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let user: UserView = response.json().await.expect("Failed to parse user data");
|
||||
println!("Welcome, {}!", style(&user.username).cyan());
|
||||
|
||||
let account_list_url = format!(
|
||||
"{}/api/v1/minimal-account-list?only_nosync=true",
|
||||
config.base_url
|
||||
);
|
||||
let acc_response = client
|
||||
.get(&account_list_url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to fetch account list");
|
||||
|
||||
if !acc_response.status().is_success() {
|
||||
panic!(
|
||||
"Failed to retrieve accounts. Status: {}",
|
||||
acc_response.status()
|
||||
);
|
||||
}
|
||||
|
||||
let accounts: Vec<MinimalAccount> = acc_response
|
||||
.json()
|
||||
.await
|
||||
.expect("Failed to parse minimal account list");
|
||||
|
||||
if accounts.is_empty() {
|
||||
println!(
|
||||
"\n{}",
|
||||
style("Error: No 'nosync' accounts found.").red().bold()
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
style("Mail import is only supported for 'nosync' type accounts.").dim()
|
||||
);
|
||||
println!(
|
||||
"Please create a new {} account in the Bichon web interface first.",
|
||||
style("Nosync").bold().yellow()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
let required_permission = Permission::DATA_IMPORT_BATCH;
|
||||
let mut selectable_accounts = Vec::new();
|
||||
let mut options = Vec::new();
|
||||
|
||||
for acc in accounts {
|
||||
let has_permission = if let Some(perms) = user.account_permissions.get(&acc.id) {
|
||||
perms.iter().any(|p| p == required_permission)
|
||||
} else {
|
||||
user.global_permissions
|
||||
.iter()
|
||||
.any(|p| p == Permission::DATA_MANAGE_ALL || p == Permission::ROOT)
|
||||
};
|
||||
|
||||
let status_prefix = if has_permission {
|
||||
style(" [READY] ").green()
|
||||
} else {
|
||||
style(" [NO PERMISSION] ").red()
|
||||
};
|
||||
|
||||
options.push(format!(
|
||||
"{}{} - {}",
|
||||
status_prefix,
|
||||
style(&acc.email).bold(),
|
||||
style(format!("ID: {}", acc.id)).dim()
|
||||
));
|
||||
|
||||
selectable_accounts.push((acc, has_permission));
|
||||
}
|
||||
|
||||
let selection = Select::with_theme(theme)
|
||||
.with_prompt("Select the target account for import")
|
||||
.items(&options)
|
||||
.default(0)
|
||||
.max_length(10)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let (selected_acc, can_import) = &selectable_accounts[selection];
|
||||
|
||||
if !*can_import {
|
||||
eprintln!(
|
||||
"\n{} You do not have '{}' permission for account {}.",
|
||||
style("✘ Permission Denied:").red().bold(),
|
||||
style(required_permission).yellow(),
|
||||
style(&selected_acc.email).cyan()
|
||||
);
|
||||
eprintln!(
|
||||
"{} Please contact your administrator to upgrade your role for this account.",
|
||||
style("Tip:").dim()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"{} Targeting account: {}",
|
||||
style("✔").green(),
|
||||
style(&selected_acc.email).cyan().bold()
|
||||
);
|
||||
|
||||
selected_acc.id
|
||||
}
|
||||
133
src/modules/cli/eml/mod.rs
Normal file
133
src/modules/cli/eml/mod.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Input};
|
||||
use mail_parser::MessageParser;
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::{
|
||||
base64_encode_url_safe,
|
||||
modules::cli::{sender::send_batch_request, BichonCtlConfig},
|
||||
};
|
||||
|
||||
pub async fn handle_eml_directory_import(
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
theme: &ColorfulTheme,
|
||||
) {
|
||||
let root_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the ROOT directory to scan for .eml files")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if p.exists() && p.is_dir() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Directory not found.")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let root_path = std::path::PathBuf::from(root_str);
|
||||
let mut tasks: HashMap<String, Vec<PathBuf>> = HashMap::new();
|
||||
println!(
|
||||
"{}",
|
||||
style("🔍 Scanning recursively using std::fs...").dim()
|
||||
);
|
||||
if let Err(e) = scan_dir(&root_path, &root_path, &mut tasks) {
|
||||
eprintln!("Error scanning directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
if tasks.is_empty() {
|
||||
println!("{}", style("No .eml files found.").yellow());
|
||||
} else {
|
||||
process_and_upload(config, account_id, tasks).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_dir(
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
tasks: &mut HashMap<String, Vec<PathBuf>>,
|
||||
) -> std::io::Result<()> {
|
||||
if current.is_dir() {
|
||||
for entry in fs::read_dir(current)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
scan_dir(root, &path, tasks)?;
|
||||
} else if path.is_file() {
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("eml") {
|
||||
let rel_path = path.strip_prefix(root).unwrap_or(Path::new(""));
|
||||
let mailbox_name = rel_path
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
let folder = if mailbox_name.is_empty() {
|
||||
"Inbox".to_string()
|
||||
} else {
|
||||
mailbox_name
|
||||
};
|
||||
tasks.entry(folder).or_insert_with(|| Vec::new()).push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_and_upload(
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
tasks: HashMap<String, Vec<PathBuf>>,
|
||||
) {
|
||||
let client = Client::new();
|
||||
let batch_size = 50;
|
||||
|
||||
for (mailbox, files) in tasks {
|
||||
println!("\n🚀 Processing mailbox: {}", style(&mailbox).cyan().bold());
|
||||
|
||||
let mut current_batch = Vec::new();
|
||||
|
||||
for file_path in files {
|
||||
let body = match fs::read(&file_path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {} Failed to read file {:?}: {}",
|
||||
style("✘").red(),
|
||||
file_path,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if MessageParser::new().parse(&body).is_some() {
|
||||
let b64_content = base64_encode_url_safe!(&body);
|
||||
current_batch.push(b64_content);
|
||||
|
||||
if current_batch.len() >= batch_size {
|
||||
let to_send = current_batch;
|
||||
current_batch = Vec::with_capacity(batch_size);
|
||||
send_batch_request(&client, config, account_id, &mailbox, to_send).await;
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
" {} Invalid format, skipping: {:?}",
|
||||
style("⚠").yellow(),
|
||||
file_path
|
||||
);
|
||||
}
|
||||
}
|
||||
if !current_batch.is_empty() {
|
||||
send_batch_request(&client, config, account_id, &mailbox, current_batch).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/modules/cli/mbox/gmail.rs
Normal file
43
src/modules/cli/mbox/gmail.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub fn determine_folder(labels_raw: &str) -> String {
|
||||
let mut status_blacklist = HashSet::new();
|
||||
status_blacklist.insert("Opened");
|
||||
status_blacklist.insert("Unread");
|
||||
status_blacklist.insert("Archived");
|
||||
|
||||
let all_labels: Vec<&str> = labels_raw
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if all_labels.is_empty() {
|
||||
return "Unknown".to_string();
|
||||
}
|
||||
|
||||
let filtered: Vec<&str> = all_labels
|
||||
.iter()
|
||||
.filter(|&&l| !status_blacklist.contains(l))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
match filtered.len() {
|
||||
// Case A: If all labels were status labels, fallback to the first original label
|
||||
0 => all_labels[0].to_string(),
|
||||
// Case B: If only one label remains, that's our target destination
|
||||
1 => filtered[0].to_string(),
|
||||
// Case C: Multiple labels remain (e.g., ["Inbox", "medium"])
|
||||
_ => {
|
||||
// Prioritize custom business labels by excluding generic locations like "Inbox" or "Sent"
|
||||
let business_label = filtered.iter().find(|&&l| l != "Inbox" && l != "Sent");
|
||||
|
||||
match business_label {
|
||||
// Return the first non-generic label found
|
||||
Some(label) => label.to_string(),
|
||||
// If only generic labels remain (e.g., ["Sent", "Inbox"]), pick the first available
|
||||
None => filtered[0].to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
151
src/modules/cli/mbox/mod.rs
Normal file
151
src/modules/cli/mbox/mod.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::base64_encode_url_safe;
|
||||
use crate::modules::cli::mbox::gmail::determine_folder;
|
||||
use crate::modules::cli::mbox::reader::MboxFile;
|
||||
use crate::modules::cli::sender::send_batch_request;
|
||||
use crate::modules::cli::BichonCtlConfig;
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Input};
|
||||
use dialoguer::{Confirm, Select};
|
||||
use mail_parser::MessageParser;
|
||||
use reqwest::Client;
|
||||
|
||||
pub mod gmail;
|
||||
pub mod reader;
|
||||
|
||||
pub async fn handle_mbox_single_file_import(
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
theme: &ColorfulTheme,
|
||||
) {
|
||||
let path_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the path to your SINGLE .mbox file")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if !p.exists() {
|
||||
return Err("The specified path does not exist.");
|
||||
}
|
||||
if !p.is_file() {
|
||||
return Err("MBOX mode requires a SINGLE file, not a directory.");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let mbox_path = PathBuf::from(path_str);
|
||||
|
||||
let options = vec![
|
||||
"Use labels from mail headers (X-Gmail-Labels)",
|
||||
"Specify a single target folder for all emails",
|
||||
];
|
||||
|
||||
let selection = Select::with_theme(theme)
|
||||
.with_prompt("How should we determine the target folder?")
|
||||
.items(&options)
|
||||
.default(0)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let target_folder: Option<String> = match selection {
|
||||
0 => None,
|
||||
1 => {
|
||||
let folder: String = Input::with_theme(theme)
|
||||
.with_prompt("Target folder name")
|
||||
.default("INBOX".into())
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
Some(folder)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(ref folder) = target_folder {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Mode: Fixed folder ({})", folder)).dim()
|
||||
);
|
||||
} else {
|
||||
println!("{}", style("Mode: Dynamic (header-based)").dim());
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n{} Ready to process MBOX file: {}",
|
||||
style("✔").green(),
|
||||
style(mbox_path.display()).cyan()
|
||||
);
|
||||
|
||||
if let Ok(meta) = std::fs::metadata(&mbox_path) {
|
||||
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Processing file: {:.1} MB", size_mb)).dim()
|
||||
);
|
||||
}
|
||||
|
||||
if Confirm::with_theme(theme)
|
||||
.with_prompt("Start importing?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
run_import(account_id, &mbox_path, config, target_folder).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_import(
|
||||
account_id: u64,
|
||||
mbox_path: &PathBuf,
|
||||
config: &BichonCtlConfig,
|
||||
target_folder: Option<String>,
|
||||
) {
|
||||
let client = Client::new();
|
||||
let mbox = match MboxFile::from_file(mbox_path) {
|
||||
Ok(mbox) => mbox,
|
||||
Err(err) => {
|
||||
println!("Skipping invalid MBOX: {} ({})", mbox_path.display(), err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut folder_buffers: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let batch_limit = 50;
|
||||
|
||||
println!("Starting import process...");
|
||||
|
||||
for e in mbox.iter() {
|
||||
let body = e.data;
|
||||
let message = MessageParser::new().parse(body).unwrap();
|
||||
|
||||
let folder_name = match target_folder {
|
||||
Some(ref folder_name) => folder_name.clone(),
|
||||
None => {
|
||||
let labels = message
|
||||
.header("X-Gmail-Labels")
|
||||
.and_then(|h| h.as_text())
|
||||
.unwrap_or("Inbox");
|
||||
determine_folder(labels)
|
||||
}
|
||||
};
|
||||
let b64_eml = base64_encode_url_safe!(&body);
|
||||
let buffer = folder_buffers
|
||||
.entry(folder_name.clone())
|
||||
.or_insert_with(|| Vec::new());
|
||||
buffer.push(b64_eml);
|
||||
|
||||
if buffer.len() >= batch_limit {
|
||||
let emls_to_send = folder_buffers.remove(&folder_name).unwrap();
|
||||
send_batch_request(&client, config, account_id, &folder_name, emls_to_send).await;
|
||||
}
|
||||
}
|
||||
|
||||
for (folder_name, emls) in folder_buffers {
|
||||
if !emls.is_empty() {
|
||||
send_batch_request(&client, config, account_id, &folder_name, emls).await;
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", style("Import completed successfully!").green().bold());
|
||||
}
|
||||
208
src/modules/cli/mbox/reader.rs
Normal file
208
src/modules/cli/mbox/reader.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use memmap2::Mmap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct MboxFile {
|
||||
map: Mmap,
|
||||
}
|
||||
|
||||
impl MboxFile {
|
||||
pub fn from_file(name: &Path) -> io::Result<Self> {
|
||||
let file = fs::File::open(name)?;
|
||||
let metadata = file.metadata()?;
|
||||
if metadata.len() == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Empty MBOX file",
|
||||
));
|
||||
}
|
||||
let map = unsafe { Mmap::map(&file)? };
|
||||
Ok(Self { map })
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> MboxReader<'_> {
|
||||
MboxReader::new(&self.map)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Entry<'a> {
|
||||
pub offset: usize,
|
||||
pub data: &'a [u8],
|
||||
}
|
||||
|
||||
pub struct MboxReader<'a> {
|
||||
data: &'a [u8],
|
||||
len: usize,
|
||||
scan_pos: usize,
|
||||
body_start: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> MboxReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self {
|
||||
data,
|
||||
len: data.len(),
|
||||
scan_pos: 0,
|
||||
body_start: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_from_line(&self, i: usize) -> bool {
|
||||
if i + 5 > self.len {
|
||||
return false;
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
&self.data[0..5] == b"From "
|
||||
} else {
|
||||
self.data[i - 1] == b'\n' && &self.data[i..i + 5] == b"From "
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_from_line(&self, mut i: usize) -> usize {
|
||||
while i < self.len && self.data[i] != b'\n' {
|
||||
i += 1;
|
||||
}
|
||||
if i < self.len {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for MboxReader<'a> {
|
||||
type Item = Entry<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
while self.scan_pos < self.len {
|
||||
if self.is_from_line(self.scan_pos) {
|
||||
let from_pos = self.scan_pos;
|
||||
let body_pos = self.skip_from_line(from_pos);
|
||||
|
||||
if let Some(start) = self.body_start {
|
||||
let entry = Entry {
|
||||
offset: start,
|
||||
data: &self.data[start..from_pos],
|
||||
};
|
||||
self.body_start = Some(body_pos);
|
||||
self.scan_pos = body_pos;
|
||||
return Some(entry);
|
||||
} else {
|
||||
self.body_start = Some(body_pos);
|
||||
self.scan_pos = body_pos;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
self.scan_pos += 1;
|
||||
}
|
||||
if let Some(start) = self.body_start.take() {
|
||||
return Some(Entry {
|
||||
offset: start,
|
||||
data: &self.data[start..self.len],
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mail_parser::MessageParser;
|
||||
|
||||
use crate::modules::cli::mbox::gmail::determine_folder;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn collect_entries(data: &[u8]) -> Vec<&[u8]> {
|
||||
let reader = MboxReader::new(data);
|
||||
reader.map(|e| e.data).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_mails() {
|
||||
let data = b"From a\nmail1\nFrom b\nmail2\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e, vec![b"mail1\n", b"mail2\n"]);
|
||||
}
|
||||
#[test]
|
||||
fn no_trailing_newline() {
|
||||
let data = b"From a\nmail1";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e, vec![b"mail1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_inside_body() {
|
||||
let data = b"From a\nhello\nFrom is here\nbye\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_from_not_separator() {
|
||||
let data = b"From a\nhello From world\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realistic_mbox() {
|
||||
let data = b"From a\nH:1\n\nbody1\nFrom b\nH:2\n\nbody2\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body() {
|
||||
let data = b"From a\nFrom b\nbody\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e[0], b"");
|
||||
assert_eq!(e[1], b"body\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_from_line() {
|
||||
let data = b"From a\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0], b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_newlines() {
|
||||
let data = b"From a\r\nbody\r\nFrom b\r\nbody2\r\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_small_mails() {
|
||||
let mut data = Vec::new();
|
||||
for _ in 0..1000 {
|
||||
data.extend_from_slice(b"From a\nx\n");
|
||||
}
|
||||
let e = collect_entries(&data);
|
||||
assert_eq!(e.len(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test11() {
|
||||
let mbox = MboxFile::from_file(Path::new("e:\\test.mbox")).unwrap();
|
||||
|
||||
for e in mbox.iter() {
|
||||
let body = e.data;
|
||||
|
||||
let message = MessageParser::new().parse(body).unwrap();
|
||||
let labels = message.header("X-Gmail-Labels").unwrap().as_text().unwrap();
|
||||
//println!("offset={} X-Gmail-Labels={:?}", e.offset, labels);
|
||||
println!(
|
||||
"X-Gmail-Labels={:?}, determine_folder={}",
|
||||
labels,
|
||||
determine_folder(labels)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/modules/cli/mod.rs
Normal file
36
src/modules/cli/mod.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bichon_version;
|
||||
|
||||
pub mod auth;
|
||||
pub mod eml;
|
||||
pub mod mbox;
|
||||
pub mod pst;
|
||||
pub mod sender;
|
||||
pub mod thunderbird;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "bichonctl",
|
||||
author = "rustmailer",
|
||||
version = bichon_version!(),
|
||||
about = "A CLI tool to import email data into Bichon service"
|
||||
)]
|
||||
pub struct BichonCli {
|
||||
/// Path to the configuration file
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
default_value = "config.toml",
|
||||
value_name = "FILE",
|
||||
help = "Sets a custom config file"
|
||||
)]
|
||||
pub config: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct BichonCtlConfig {
|
||||
pub base_url: String,
|
||||
pub api_token: String,
|
||||
}
|
||||
45
src/modules/cli/pst/encoding/mod.rs
Normal file
45
src/modules/cli/pst/encoding/mod.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use compressed_rtf::*;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
pub fn decode_subject(value: &PropertyValue) -> Option<String> {
|
||||
match value {
|
||||
PropertyValue::String8(value) => {
|
||||
let offset = match value.buffer().first() {
|
||||
Some(1) => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let buffer: Vec<_> = value
|
||||
.buffer()
|
||||
.iter()
|
||||
.skip(offset)
|
||||
.map(|&b| u16::from(b))
|
||||
.collect();
|
||||
Some(String::from_utf16_lossy(&buffer))
|
||||
}
|
||||
PropertyValue::Unicode(value) => {
|
||||
let offset = match value.buffer().first() {
|
||||
Some(1) => 2,
|
||||
_ => 0,
|
||||
};
|
||||
Some(String::from_utf16_lossy(&value.buffer()[offset..]))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_html_body(buffer: &[u8], code_page: u16) -> Option<String> {
|
||||
match code_page {
|
||||
20127 => {
|
||||
let buffer: Vec<_> = buffer.iter().map(|&b| u16::from(b)).collect();
|
||||
Some(String::from_utf16_lossy(&buffer))
|
||||
}
|
||||
_ => {
|
||||
let coding = codepage_strings::Coding::new(code_page).ok()?;
|
||||
Some(coding.decode(buffer).ok()?.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_rtf_compressed(buffer: &[u8]) -> Option<String> {
|
||||
decompress_rtf(buffer).ok()
|
||||
}
|
||||
458
src/modules/cli/pst/mod.rs
Normal file
458
src/modules/cli/pst/mod.rs
Normal file
@@ -0,0 +1,458 @@
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
use dialoguer::Input;
|
||||
use mail_send::mail_builder::headers::text::Text;
|
||||
use mail_send::mail_builder::MessageBuilder;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
use crate::base64_encode_url_safe;
|
||||
use crate::modules::cli::pst::encoding::decode_subject;
|
||||
use crate::modules::cli::sender::send_batch_request;
|
||||
use crate::modules::cli::BichonCtlConfig;
|
||||
use dialoguer::Confirm;
|
||||
use outlook_pst::messaging::attachment::AttachmentProperties;
|
||||
use outlook_pst::messaging::folder::Folder;
|
||||
use outlook_pst::messaging::message::{Message, MessageProperties};
|
||||
use outlook_pst::ndb::node_id::NodeId;
|
||||
use reqwest::Client;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
|
||||
mod encoding;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EmailMetadata {
|
||||
pub message_id: Option<String>,
|
||||
pub subject: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub to: Option<Vec<String>>,
|
||||
pub cc: Option<Vec<String>>,
|
||||
pub bcc: Option<Vec<String>>,
|
||||
pub html: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub in_reply_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EmailAttachment {
|
||||
pub name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub data: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme: &ColorfulTheme) {
|
||||
let path_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the path to your SINGLE .pst file")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if !p.exists() {
|
||||
return Err("The specified path does not exist.");
|
||||
}
|
||||
|
||||
if !p.is_file() {
|
||||
return Err("PST mode requires a SINGLE file, not a directory.");
|
||||
}
|
||||
let is_pst = p
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("pst"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_pst {
|
||||
return Err("The selected file must have a .pst extension.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let pst_path = std::path::PathBuf::from(path_str);
|
||||
|
||||
println!(
|
||||
"\n{} Ready to process PST file: {}",
|
||||
console::style("✔").green(),
|
||||
console::style(pst_path.display()).cyan()
|
||||
);
|
||||
|
||||
if let Ok(meta) = std::fs::metadata(&pst_path) {
|
||||
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
|
||||
println!(
|
||||
"{}",
|
||||
console::style(format!("PST File Size: {:.1} MB", size_mb)).dim()
|
||||
);
|
||||
}
|
||||
|
||||
if Confirm::with_theme(theme)
|
||||
.with_prompt("Start importing emails from this PST?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
parse_pst(pst_path, config, account_id).await;
|
||||
} else {
|
||||
println!("{}", console::style("Operation cancelled by user.").red());
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_pst(pst_path: PathBuf, config: &BichonCtlConfig, account_id: u64) {
|
||||
let client = Client::new();
|
||||
|
||||
let pst_store = match outlook_pst::open_store(&pst_path) {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Failed to open PST file: {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ipm_sub_tree = match pst_store.properties().ipm_sub_tree_entry_id() {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Could not find IPM_SUBTREE (Mailbox Root): {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ipm_subtree_folder = match pst_store.open_folder(&ipm_sub_tree) {
|
||||
Ok(folder) => folder,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Failed to open the root mailbox folder: {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
process_folder_recursively(&client, &ipm_subtree_folder, "", config, account_id).await;
|
||||
}
|
||||
|
||||
fn process_folder_recursively<'a>(
|
||||
client: &'a Client,
|
||||
folder: &'a Rc<dyn Folder>,
|
||||
parent_path: &'a str,
|
||||
config: &'a BichonCtlConfig,
|
||||
account_id: u64,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
|
||||
Box::pin(async move {
|
||||
let folder_name = folder
|
||||
.properties()
|
||||
.display_name()
|
||||
.unwrap_or_else(|_| "Unknown".to_string());
|
||||
|
||||
let current_path = if parent_path.is_empty() {
|
||||
folder_name
|
||||
} else {
|
||||
format!("{}/{}", parent_path, folder_name)
|
||||
};
|
||||
|
||||
println!(
|
||||
"{} {}",
|
||||
console::style("📁 Folder:").dim(),
|
||||
console::style(¤t_path).cyan()
|
||||
);
|
||||
|
||||
let mut emls_batch = Vec::new();
|
||||
|
||||
if let Some(contents_table) = folder.contents_table() {
|
||||
for row in contents_table.rows_matrix() {
|
||||
let store = folder.store().clone();
|
||||
|
||||
let entry_id = match store
|
||||
.properties()
|
||||
.make_entry_id(NodeId::from(u32::from(row.id())))
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {} Skip row {}: {:?}",
|
||||
console::style("⚠").yellow(),
|
||||
row.unique(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match store.open_message(&entry_id, None) {
|
||||
Ok(message) => match build_eml_base64(message) {
|
||||
Some(base64_eml) => emls_batch.push(base64_eml),
|
||||
None => {}
|
||||
},
|
||||
Err(e) => eprintln!(" {} Open error: {:?}", console::style("⚠").yellow(), e),
|
||||
}
|
||||
|
||||
if emls_batch.len() >= 50 {
|
||||
let batch = emls_batch.clone();
|
||||
emls_batch.clear();
|
||||
send_to_bichon(client, config, account_id, ¤t_path, batch).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !emls_batch.is_empty() {
|
||||
send_to_bichon(client, config, account_id, ¤t_path, emls_batch).await;
|
||||
}
|
||||
|
||||
if let Some(hierarchy_table) = folder.hierarchy_table() {
|
||||
for row in hierarchy_table.rows_matrix() {
|
||||
let node = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
|
||||
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
|
||||
process_folder_recursively(
|
||||
client,
|
||||
&sub_folder,
|
||||
¤t_path,
|
||||
config,
|
||||
account_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
|
||||
let properties = message.properties();
|
||||
|
||||
let mut builder = MessageBuilder::new();
|
||||
if let Some(sub) = extract_subject(properties) {
|
||||
builder = builder.subject(sub);
|
||||
}
|
||||
if let Some(mid) = extract_string_property(properties, 0x1035) {
|
||||
builder = builder.message_id(mid);
|
||||
}
|
||||
if let Some(irt) = extract_string_property(properties, 0x1042) {
|
||||
builder = builder.in_reply_to(irt);
|
||||
}
|
||||
|
||||
if let Some(refs) = extract_string_property(properties, 0x1039) {
|
||||
builder = builder.header("References", Text::new(refs));
|
||||
}
|
||||
|
||||
if let Some(cid_val) = properties.get(0x3013) {
|
||||
if let PropertyValue::Binary(bin) = cid_val {
|
||||
builder = builder.header(
|
||||
"X-Bichon-Conversation-ID",
|
||||
Text::new(hex::encode(bin.buffer())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let from = extract_string_property(properties, 0x5D01)
|
||||
.or_else(|| extract_string_property(properties, 0x5D02))
|
||||
.or_else(|| extract_string_property(properties, 0x0C1F));
|
||||
|
||||
if let Some(f) = from {
|
||||
builder = builder.from(f);
|
||||
}
|
||||
|
||||
if let Some(filetime) = extract_i64_property(properties, &[0x0039, 0x0E06]) {
|
||||
let dt = filetime_to_datetime(filetime).timestamp();
|
||||
builder = builder.date(dt);
|
||||
}
|
||||
|
||||
let (to, cc, bcc) = extract_recipients_list(&message);
|
||||
if !to.is_empty() {
|
||||
builder = builder.to(to.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !cc.is_empty() {
|
||||
builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !bcc.is_empty() {
|
||||
builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
if let Some(html) = extract_html(properties) {
|
||||
builder = builder.html_body(html);
|
||||
}
|
||||
|
||||
if let Some(text) = extract_text(properties) {
|
||||
builder = builder.text_body(text);
|
||||
}
|
||||
|
||||
if let Some(attachment_table) = message.attachment_table() {
|
||||
for row in attachment_table.rows_matrix() {
|
||||
let node_id = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(attachment) = message.clone().read_attachment(node_id, None) {
|
||||
let att_props = attachment.properties();
|
||||
let name = extract_attachment_string_property(att_props, 0x3707);
|
||||
let mime = extract_attachment_string_property(att_props, 0x370E)
|
||||
.unwrap_or_else(|| "application/octet-stream".into());
|
||||
let cid = extract_attachment_string_property(att_props, 0x3712);
|
||||
let is_inline = att_props
|
||||
.get(0x3714)
|
||||
.and_then(|val| {
|
||||
if let PropertyValue::Integer32(f) = val {
|
||||
Some(f)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|flag| (flag & 0x4) != 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(PropertyValue::Binary(bin)) = att_props.get(0x3701) {
|
||||
let data = bin.buffer().to_vec();
|
||||
let file_name = name.unwrap_or_else(|| "unnamed_attachment".to_string());
|
||||
|
||||
if is_inline && cid.is_some() {
|
||||
let content_id = cid.unwrap();
|
||||
builder = builder.inline(mime, content_id, data);
|
||||
} else {
|
||||
builder = builder.attachment(mime, file_name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match builder.write_to_vec() {
|
||||
Ok(eml_vec) => Some(base64_encode_url_safe!(eml_vec)),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to generate EML: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filetime_to_datetime(filetime: i64) -> DateTime<Utc> {
|
||||
let unix_secs = (filetime / 10_000_000) - 11_644_473_600;
|
||||
let nsecs = (filetime % 10_000_000) * 100;
|
||||
Utc.timestamp_opt(unix_secs, nsecs as u32).unwrap()
|
||||
}
|
||||
|
||||
fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
let mut to = Vec::new();
|
||||
let mut cc = Vec::new();
|
||||
let mut bcc = Vec::new();
|
||||
|
||||
let recipient_table = message.recipient_table();
|
||||
let context = recipient_table.context();
|
||||
|
||||
for row in recipient_table.rows_matrix() {
|
||||
if let Ok(cols) = row.columns(context) {
|
||||
let mut r_type = 0;
|
||||
let mut email = String::new();
|
||||
|
||||
for (col, val) in context.columns().iter().zip(cols) {
|
||||
let prop_val = val
|
||||
.as_ref()
|
||||
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
|
||||
match col.prop_id() {
|
||||
0x0C15 => {
|
||||
if let Some(PropertyValue::Integer32(t)) = prop_val {
|
||||
r_type = t;
|
||||
}
|
||||
}
|
||||
0x39FE | 0x3003 => {
|
||||
if let Some(s) = prop_val.and_then(|v| extract_string(&v)) {
|
||||
email = s;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !email.is_empty() {
|
||||
match r_type {
|
||||
1 => to.push(email),
|
||||
2 => cc.push(email),
|
||||
3 => bcc.push(email),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(to, cc, bcc)
|
||||
}
|
||||
|
||||
async fn send_to_bichon(
|
||||
client: &Client,
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
folder_path: &str,
|
||||
emls: Vec<String>,
|
||||
) {
|
||||
send_batch_request(client, config, account_id, folder_path, emls).await;
|
||||
}
|
||||
|
||||
fn extract_subject(props: &MessageProperties) -> Option<String> {
|
||||
props.get(0x0037).and_then(|val| decode_subject(val))
|
||||
}
|
||||
|
||||
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_attachment_string_property(
|
||||
properties: &AttachmentProperties,
|
||||
prop_id: u16,
|
||||
) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_string(value: &PropertyValue) -> Option<String> {
|
||||
match value {
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_text(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1000).and_then(extract_string).or_else(|| {
|
||||
properties.get(0x1009).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => encoding::decode_rtf_compressed(value.buffer()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_html(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1013).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => {
|
||||
let code_page = properties
|
||||
.get(0x3FDE)
|
||||
.and_then(|v| {
|
||||
if let PropertyValue::Integer32(cpid) = v {
|
||||
Some(*cpid as u16)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(65001);
|
||||
encoding::decode_html_body(value.buffer(), code_page)
|
||||
}
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_i64_property(properties: &MessageProperties, prop_ids: &[u16]) -> Option<i64> {
|
||||
for &prop_id in prop_ids {
|
||||
if let Some(PropertyValue::Time(value)) = properties.get(prop_id) {
|
||||
return Some(*value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
54
src/modules/cli/sender.rs
Normal file
54
src/modules/cli/sender.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use console::style;
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::modules::{cli::BichonCtlConfig, import::BatchEmlRequest};
|
||||
|
||||
pub async fn send_batch_request(
|
||||
client: &Client,
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
folder: &str,
|
||||
emls: Vec<String>,
|
||||
) {
|
||||
let url = format!("{}/api/v1/import", config.base_url);
|
||||
let payload = BatchEmlRequest {
|
||||
account_id,
|
||||
mail_folder: folder.to_string(),
|
||||
emls,
|
||||
};
|
||||
|
||||
let count = payload.emls.len();
|
||||
|
||||
match client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(res) if res.status().is_success() => {
|
||||
println!(
|
||||
" {} Sent {} emails to [{}]",
|
||||
style("✔").green(),
|
||||
count,
|
||||
folder
|
||||
);
|
||||
}
|
||||
Ok(res) => {
|
||||
eprintln!(
|
||||
" {} Failed to send to [{}]. Status: {}",
|
||||
style("✘").red(),
|
||||
folder,
|
||||
res.status()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {} Network error on [{}]: {}",
|
||||
style("✘").red(),
|
||||
folder,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/modules/cli/thunderbird/mod.rs
Normal file
113
src/modules/cli/thunderbird/mod.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use crate::modules::cli::{mbox::run_import, BichonCtlConfig};
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
||||
|
||||
pub async fn handle_thunderbird_import(
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
theme: &ColorfulTheme,
|
||||
) {
|
||||
let root_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter your Thunderbird Mail/ImapMail directory")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if p.exists() && p.is_dir() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Directory not found.")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let root_path = std::path::PathBuf::from(&root_str);
|
||||
println!("{}", style("🔍 Scanning Thunderbird structure...").dim());
|
||||
|
||||
let mut mbox_tasks: HashMap<String, PathBuf> = HashMap::new();
|
||||
|
||||
fn scan_thunderbird_dir(
|
||||
root: &std::path::Path,
|
||||
current: &std::path::Path,
|
||||
tasks: &mut HashMap<String, PathBuf>,
|
||||
) {
|
||||
if let Ok(entries) = std::fs::read_dir(current) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
|
||||
|
||||
if path.is_dir() {
|
||||
scan_thunderbird_dir(root, &path, tasks);
|
||||
} else {
|
||||
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
match extension {
|
||||
"msf" | "dat" | "html" | "json" | "txt" | "sqlite" => continue,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if file_name == "filterlog.html" || file_name == "msgFilterRules.dat" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !extension.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(rel) = path.strip_prefix(root) {
|
||||
let mailbox = rel.to_string_lossy().replace(".sbd", "").replace('\\', "/");
|
||||
tasks.insert(mailbox, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scan_thunderbird_dir(&root_path, &root_path, &mut mbox_tasks);
|
||||
if mbox_tasks.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
style("No mailboxes found in the specified directory.").yellow()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n{}", style("🔍 Scanned Mailboxes:").bold().underlined());
|
||||
let mut sorted_keys: Vec<_> = mbox_tasks.keys().collect();
|
||||
sorted_keys.sort();
|
||||
|
||||
for name in &sorted_keys {
|
||||
let path = &mbox_tasks[*name];
|
||||
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
let size_mb = file_size as f64 / 1024.0 / 1024.0;
|
||||
|
||||
println!(
|
||||
" {} {} ({:.2} MB)",
|
||||
style("•").dim(),
|
||||
style(name).cyan(),
|
||||
size_mb
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
let prompt = format!("Ready to import {} mailboxes. Proceed?", mbox_tasks.len());
|
||||
if Confirm::with_theme(theme)
|
||||
.with_prompt(prompt)
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
for (mailbox_name, mbox_file) in mbox_tasks {
|
||||
println!("\n🚀 Importing: {}", style(&mailbox_name).cyan().bold());
|
||||
run_import(account_id, &mbox_file, config, Some(mailbox_name)).await;
|
||||
}
|
||||
println!(
|
||||
"\n{}",
|
||||
style("✨ All mailboxes imported successfully!")
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
} else {
|
||||
println!("{}", style("Import cancelled.").yellow());
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,13 @@
|
||||
// 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::modules::error::BichonResult;
|
||||
|
||||
pub mod controller;
|
||||
pub mod executors;
|
||||
pub mod status;
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait Initialize {
|
||||
async fn initialize() -> BichonResult<()>;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,12 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
|
||||
|
||||
let attachments: Vec<String> = message
|
||||
.attachments()
|
||||
.filter(|att| {
|
||||
let disp = att.content_disposition();
|
||||
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false);
|
||||
let has_filename = att.attachment_name().is_some();
|
||||
has_filename && !is_inline
|
||||
})
|
||||
.filter_map(|att| att.attachment_name())
|
||||
.map(|name| name.to_string())
|
||||
.collect();
|
||||
@@ -195,6 +201,12 @@ pub fn extract_envelope_from_eml(
|
||||
|
||||
let attachments: Vec<String> = message
|
||||
.attachments()
|
||||
.filter(|att| {
|
||||
let disp = att.content_disposition();
|
||||
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false);
|
||||
let has_filename = att.attachment_name().is_some();
|
||||
has_filename && !is_inline
|
||||
})
|
||||
.filter_map(|att| att.attachment_name())
|
||||
.map(|name| name.to_string())
|
||||
.collect();
|
||||
|
||||
@@ -79,12 +79,26 @@ impl ImapExecutor {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn append(
|
||||
&self,
|
||||
mailbox_name: impl AsRef<str>,
|
||||
flags: Option<&str>,
|
||||
internaldate: Option<&str>,
|
||||
content: impl AsRef<[u8]>,
|
||||
) -> BichonResult<()> {
|
||||
let mut session = self.get_connection().await?;
|
||||
session
|
||||
.append(mailbox_name, flags, internaldate, content)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
|
||||
}
|
||||
|
||||
pub async fn fetch_new_mail(
|
||||
&self,
|
||||
account: &AccountModel,
|
||||
mailbox: &MailBox,
|
||||
start_uid: u64,
|
||||
before: Option<&str>
|
||||
before: Option<&str>,
|
||||
) -> BichonResult<()> {
|
||||
assert!(start_uid > 0, "start_uid must be greater than 0");
|
||||
|
||||
@@ -93,12 +107,7 @@ impl ImapExecutor {
|
||||
None => format!("UID {start_uid}:*"),
|
||||
};
|
||||
|
||||
let uid_list = self
|
||||
.uid_search(
|
||||
&mailbox.encoded_name(),
|
||||
&query,
|
||||
)
|
||||
.await?;
|
||||
let uid_list = self.uid_search(&mailbox.encoded_name(), &query).await?;
|
||||
|
||||
let len = uid_list.len();
|
||||
if len == 0 {
|
||||
|
||||
@@ -100,7 +100,6 @@ impl ImportEmls {
|
||||
|
||||
let total = request.emls.len();
|
||||
for (index, eml_base64) in request.emls.into_iter().enumerate() {
|
||||
// 1. Decode Base64
|
||||
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
use crate::modules::cache::imap::mailbox::MailBox;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
@@ -155,9 +157,9 @@ impl Envelope {
|
||||
let id = create_hash(account_id, &message_id);
|
||||
let full_text = extract_string_field(doc, fields.f_text)?;
|
||||
|
||||
// Take up to the first 120 characters as a preview;
|
||||
let preview = if full_text.chars().count() > 120 {
|
||||
full_text.chars().take(120).collect::<String>() + "..."
|
||||
// Take up to the first 500 characters as a preview;
|
||||
let preview = if full_text.chars().count() > 500 {
|
||||
full_text.chars().take(500).collect::<String>() + "..."
|
||||
} else {
|
||||
full_text
|
||||
};
|
||||
@@ -204,3 +206,28 @@ impl Envelope {
|
||||
Ok(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
|
||||
let fields = SchemaTools::envelope_fields();
|
||||
let mut all_contacts = HashSet::new();
|
||||
|
||||
if let Ok(from_val) = extract_string_field(doc, fields.f_from) {
|
||||
if !from_val.is_empty() {
|
||||
all_contacts.insert(from_val);
|
||||
}
|
||||
}
|
||||
|
||||
let multi_fields = [fields.f_to, fields.f_cc, fields.f_bcc];
|
||||
|
||||
for field in multi_fields {
|
||||
if let Ok(vals) = extract_vec_string_field(doc, field) {
|
||||
for v in vals {
|
||||
if !v.is_empty() {
|
||||
all_contacts.insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_contacts)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::modules::message::tags::TagCount;
|
||||
use crate::modules::{
|
||||
indexer::envelope::extract_contacts,
|
||||
message::{search::SortBy, tags::TagCount},
|
||||
};
|
||||
use crate::{
|
||||
modules::{
|
||||
account::migration::AccountModel,
|
||||
@@ -34,8 +37,8 @@ use crate::{
|
||||
indexer::{
|
||||
envelope::Envelope,
|
||||
fields::{
|
||||
F_ACCOUNT_ID, F_FROM, F_HAS_ATTACHMENT, F_INTERNAL_DATE, F_MAILBOX_ID, F_SIZE,
|
||||
F_TAGS, F_THREAD_ID, F_UID,
|
||||
F_ACCOUNT_ID, F_DATE, F_FROM, F_HAS_ATTACHMENT, F_MAILBOX_ID, F_SIZE, F_TAGS,
|
||||
F_THREAD_ID, F_UID,
|
||||
},
|
||||
schema::SchemaTools,
|
||||
},
|
||||
@@ -57,7 +60,10 @@ use tantivy::{
|
||||
AggregationCollector, Key,
|
||||
},
|
||||
collector::{Count, FacetCollector, TopDocs},
|
||||
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
|
||||
query::{
|
||||
AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, RegexQuery,
|
||||
TermQuery,
|
||||
},
|
||||
schema::{Facet, IndexRecordOption, Value},
|
||||
store::{Compressor, ZstdCompressor},
|
||||
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
|
||||
@@ -306,11 +312,9 @@ impl EnvelopeIndexManager {
|
||||
(f.f_bcc, &filter.bcc),
|
||||
] {
|
||||
if let Some(ref v) = opt_value {
|
||||
let term = Term::from_field_text(field, v);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) {
|
||||
subqueries.push((Occur::Must, Box::new(query)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,21 +331,19 @@ impl EnvelopeIndexManager {
|
||||
}
|
||||
|
||||
if let Some(ref name) = filter.attachment_name {
|
||||
let term = Term::from_field_text(f.f_attachments, name);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachments) {
|
||||
subqueries.push((Occur::Must, Box::new(query)));
|
||||
}
|
||||
}
|
||||
|
||||
let start_bound = if let Some(from) = filter.since {
|
||||
Bound::Included(Term::from_field_i64(f.f_internal_date, from))
|
||||
Bound::Included(Term::from_field_i64(f.f_date, from))
|
||||
} else {
|
||||
Bound::Unbounded
|
||||
};
|
||||
|
||||
let end_bound = if let Some(to) = filter.before {
|
||||
Bound::Included(Term::from_field_i64(f.f_internal_date, to))
|
||||
Bound::Included(Term::from_field_i64(f.f_date, to))
|
||||
} else {
|
||||
Bound::Unbounded
|
||||
};
|
||||
@@ -351,20 +353,28 @@ impl EnvelopeIndexManager {
|
||||
subqueries.push((Occur::Must, Box::new(q)));
|
||||
}
|
||||
|
||||
if let Some(account_id) = filter.account_id {
|
||||
let term = Term::from_field_u64(f.f_account_id, account_id);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Some(account_ids) = filter.account_ids {
|
||||
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
|
||||
for id in account_ids {
|
||||
let term = Term::from_field_u64(f.f_account_id, id);
|
||||
should_queries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
}
|
||||
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
|
||||
}
|
||||
|
||||
if let Some(mailbox_id) = filter.mailbox_id {
|
||||
let term = Term::from_field_u64(f.f_mailbox_id, mailbox_id);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Some(mailbox_ids) = filter.mailbox_ids {
|
||||
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
|
||||
for id in mailbox_ids {
|
||||
let term = Term::from_field_u64(f.f_mailbox_id, id);
|
||||
should_queries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
}
|
||||
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
|
||||
}
|
||||
|
||||
let start_bound = if let Some(from) = filter.min_size {
|
||||
@@ -520,6 +530,48 @@ impl EnvelopeIndexManager {
|
||||
Ok(all_facets)
|
||||
}
|
||||
|
||||
pub async fn get_all_contacts(
|
||||
&self,
|
||||
accounts: Option<HashSet<u64>>,
|
||||
) -> BichonResult<HashSet<String>> {
|
||||
let searcher = self.create_searcher()?;
|
||||
|
||||
let query: Box<dyn Query> = match accounts {
|
||||
Some(ref ids) if !ids.is_empty() => {
|
||||
let mut subqueries = Vec::new();
|
||||
for &id in ids {
|
||||
let term =
|
||||
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
|
||||
subqueries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
|
||||
));
|
||||
}
|
||||
Box::new(BooleanQuery::new(subqueries))
|
||||
}
|
||||
Some(_) => Box::new(EmptyQuery),
|
||||
None => Box::new(AllQuery),
|
||||
};
|
||||
|
||||
let mut contacts_set: HashSet<String> = HashSet::new();
|
||||
|
||||
let top_docs = searcher
|
||||
.search(&query, &TopDocs::with_limit(1_000_000))
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
for (_score, doc_address) in top_docs {
|
||||
let doc: TantivyDocument = searcher
|
||||
.doc_async(doc_address)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let contacts = extract_contacts(&doc).await?;
|
||||
for value in contacts {
|
||||
contacts_set.insert(value);
|
||||
}
|
||||
}
|
||||
Ok(contacts_set)
|
||||
}
|
||||
|
||||
pub async fn delete_envelopes_multi_account(
|
||||
&self,
|
||||
deletes: &HashMap<u64, Vec<u64>>, // HashMap<account_id, envelope_ids>
|
||||
@@ -621,6 +673,7 @@ impl EnvelopeIndexManager {
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
desc: bool,
|
||||
sort_by: SortBy,
|
||||
) -> BichonResult<DataPage<Envelope>> {
|
||||
assert!(page > 0, "Page number must be greater than 0");
|
||||
assert!(page_size > 0, "Page size must be greater than 0");
|
||||
@@ -653,17 +706,36 @@ impl EnvelopeIndexManager {
|
||||
}
|
||||
|
||||
let order = if desc { Order::Desc } else { Order::Asc };
|
||||
let mailbox_docs: Vec<(i64, DocAddress)> = searcher
|
||||
.search(
|
||||
&query,
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_INTERNAL_DATE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let mailbox_docs: Vec<DocAddress>;
|
||||
|
||||
match sort_by {
|
||||
SortBy::DATE => {
|
||||
let date_docs: Vec<(i64, DocAddress)> = searcher
|
||||
.search(
|
||||
&query,
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_DATE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
mailbox_docs = date_docs.into_iter().map(|(_, addr)| addr).collect();
|
||||
}
|
||||
SortBy::SIZE => {
|
||||
let size_docs: Vec<(u64, DocAddress)> = searcher
|
||||
.search(
|
||||
&query,
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_SIZE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
mailbox_docs = size_docs.into_iter().map(|(_, addr)| addr).collect();
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for (_, doc_address) in mailbox_docs {
|
||||
for doc_address in mailbox_docs {
|
||||
let doc: TantivyDocument = searcher
|
||||
.doc_async(doc_address)
|
||||
.await
|
||||
@@ -721,7 +793,7 @@ impl EnvelopeIndexManager {
|
||||
query.as_ref(),
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_INTERNAL_DATE, order),
|
||||
.order_by_fast_field(F_DATE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let mut result = Vec::new();
|
||||
@@ -786,7 +858,7 @@ impl EnvelopeIndexManager {
|
||||
query.as_ref(),
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_INTERNAL_DATE, order),
|
||||
.order_by_fast_field(F_DATE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let mut result = Vec::new();
|
||||
@@ -808,6 +880,47 @@ impl EnvelopeIndexManager {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_envelope_by_id(
|
||||
&self,
|
||||
account_id: u64,
|
||||
message_id: u64,
|
||||
) -> BichonResult<Option<Envelope>> {
|
||||
let searcher = self.create_searcher()?;
|
||||
let f = SchemaTools::envelope_fields();
|
||||
|
||||
let query = BooleanQuery::new(vec![
|
||||
(
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(
|
||||
Term::from_field_u64(f.f_account_id, account_id),
|
||||
IndexRecordOption::Basic,
|
||||
)),
|
||||
),
|
||||
(
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(
|
||||
Term::from_field_u64(f.f_id, message_id),
|
||||
IndexRecordOption::Basic,
|
||||
)),
|
||||
),
|
||||
]);
|
||||
|
||||
let docs: Vec<(f32, DocAddress)> = searcher
|
||||
.search(&query, &TopDocs::with_limit(1))
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
if let Some((_, doc_address)) = docs.first() {
|
||||
let doc: TantivyDocument = searcher
|
||||
.doc_async(*doc_address)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let envelope = Envelope::from_tantivy_doc(&doc).await?;
|
||||
Ok(Some(envelope))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn top_10_largest_emails(
|
||||
&self,
|
||||
accounts: &Option<HashSet<u64>>,
|
||||
@@ -1006,7 +1119,7 @@ impl EnvelopeIndexManager {
|
||||
},
|
||||
"recent_30d_histogram": {
|
||||
"histogram": {
|
||||
"field": F_INTERNAL_DATE,
|
||||
"field": F_DATE,
|
||||
"interval": 86400000,
|
||||
"hard_bounds": {
|
||||
"min": week_ago_ms,
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
// 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::logger::file::setup_file_logger;
|
||||
use crate::modules::logger::file::setup_file_logger;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use chrono::Local;
|
||||
use std::process;
|
||||
|
||||
38
src/modules/mailbox/delete.rs
Normal file
38
src/modules/mailbox/delete.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::modules::{
|
||||
cache::imap::mailbox::MailBox,
|
||||
error::BichonResult,
|
||||
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
};
|
||||
|
||||
pub async fn delete_mailbox_impl(account_id: u64, mailbox_id: u64) -> BichonResult<()> {
|
||||
let mailbox = MailBox::get(mailbox_id).await?;
|
||||
|
||||
let name = mailbox.name;
|
||||
let delimiter = mailbox.delimiter.unwrap_or("/".to_owned());
|
||||
let all_mailboxes = MailBox::list_all(account_id).await?;
|
||||
|
||||
let prefix = format!("{}{}", name, delimiter);
|
||||
let ids_to_delete: Vec<u64> = all_mailboxes
|
||||
.into_iter()
|
||||
.filter(|m| m.id == mailbox_id || m.name.starts_with(&prefix))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
|
||||
if ids_to_delete.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for id in &ids_to_delete {
|
||||
MailBox::delete(*id).await?;
|
||||
}
|
||||
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.delete_mailbox_envelopes(account_id, ids_to_delete.clone())
|
||||
.await?;
|
||||
|
||||
EML_INDEX_MANAGER
|
||||
.delete_mailbox_envelopes(account_id, ids_to_delete)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,5 +16,5 @@
|
||||
// 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/>.
|
||||
|
||||
|
||||
pub mod delete;
|
||||
pub mod list;
|
||||
|
||||
104
src/modules/message/append.rs
Normal file
104
src/modules/message/append.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use crate::{
|
||||
encode_mailbox_name,
|
||||
modules::{
|
||||
account::migration::{AccountModel, AccountType},
|
||||
context::executors::MAIL_CONTEXT,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const MAX_RESTORE_COUNT: usize = 100;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct RestoreMessagesRequest {
|
||||
/// Message IDs to restore (max 100)
|
||||
pub message_ids: Vec<u64>,
|
||||
}
|
||||
|
||||
pub async fn restore_emails(account_id: u64, message_ids: Vec<u64>) -> BichonResult<()> {
|
||||
if message_ids.len() > MAX_RESTORE_COUNT {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Too many messages to restore: {} (max {})",
|
||||
message_ids.len(),
|
||||
MAX_RESTORE_COUNT
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
let account = AccountModel::check_account_exists(account_id).await?;
|
||||
if !matches!(account.account_type, AccountType::IMAP) {
|
||||
return Err(raise_error!(
|
||||
"Account type is not IMAP".into(),
|
||||
ErrorCode::Incompatible
|
||||
));
|
||||
}
|
||||
let executor = MAIL_CONTEXT.imap(account.id).await?;
|
||||
|
||||
let mut failed = Vec::new();
|
||||
|
||||
for message_id in message_ids {
|
||||
let result: BichonResult<()> = async {
|
||||
let envelope = ENVELOPE_INDEX_MANAGER
|
||||
.get_envelope_by_id(account_id, message_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Envelope not found: account_id={} message_id={}",
|
||||
account_id, message_id
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
let eml = EML_INDEX_MANAGER
|
||||
.get(account_id, message_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Email record not found: account_id={} id={}",
|
||||
account_id, message_id
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(mailbox_name) = envelope.mailbox_name {
|
||||
executor
|
||||
.append(encode_mailbox_name!(&mailbox_name), None, None, &eml)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
failed.push(message_id);
|
||||
tracing::warn!(
|
||||
account_id = account_id,
|
||||
message_id = message_id,
|
||||
error = ?err,
|
||||
"Failed to restore email"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !failed.is_empty() {
|
||||
tracing::info!(
|
||||
account_id = account_id,
|
||||
failed_count = failed.len(),
|
||||
failed_message_ids = ?failed,
|
||||
"Restore emails finished with partial failures"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
10
src/modules/message/contacts.rs
Normal file
10
src/modules/message/contacts.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct Contact {
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
// 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/>.
|
||||
|
||||
|
||||
pub mod append;
|
||||
pub mod contacts;
|
||||
pub mod content;
|
||||
pub mod delete;
|
||||
pub mod list;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use poem_openapi::Object;
|
||||
use poem_openapi::{Enum, Object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
@@ -39,8 +39,8 @@ pub struct SearchFilter {
|
||||
pub bcc: Option<String>,
|
||||
pub since: Option<i64>,
|
||||
pub before: Option<i64>,
|
||||
pub account_id: Option<u64>,
|
||||
pub mailbox_id: Option<u64>,
|
||||
pub account_ids: Option<Vec<u64>>,
|
||||
pub mailbox_ids: Option<Vec<u64>>,
|
||||
pub min_size: Option<u64>,
|
||||
pub max_size: Option<u64>,
|
||||
pub message_id: Option<String>,
|
||||
@@ -49,11 +49,20 @@ pub struct SearchFilter {
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
|
||||
pub enum SortBy {
|
||||
#[default]
|
||||
DATE,
|
||||
SIZE,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct SearchRequest {
|
||||
filter: SearchFilter,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
sort_by: Option<SortBy>,
|
||||
desc: Option<bool>,
|
||||
}
|
||||
impl SearchRequest {
|
||||
pub fn validate(&self) -> BichonResult<()> {
|
||||
@@ -84,7 +93,8 @@ pub async fn search_messages_impl(
|
||||
request.filter,
|
||||
request.page,
|
||||
request.page_size,
|
||||
true,
|
||||
request.desc.unwrap_or(true),
|
||||
request.sort_by.unwrap_or(SortBy::DATE),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
pub mod account;
|
||||
pub mod autoconfig;
|
||||
pub mod cache;
|
||||
pub mod cli;
|
||||
pub mod common;
|
||||
pub mod context;
|
||||
pub mod dashboard;
|
||||
|
||||
@@ -221,10 +221,13 @@ impl AccountApi {
|
||||
)]
|
||||
async fn minimal_accounts_list(
|
||||
&self,
|
||||
only_nosync: Query<Option<bool>>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Vec<MinimalAccount>>> {
|
||||
let is_admin = context.user.is_admin().await;
|
||||
let minimal_list = AccountModel::minimal_list().await?;
|
||||
let only_nosync = only_nosync.0.unwrap_or_default();
|
||||
|
||||
let minimal_list = AccountModel::minimal_list(only_nosync).await?;
|
||||
if is_admin {
|
||||
return Ok(Json(minimal_list));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
use crate::modules::cache::imap::mailbox::MailBox;
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::mailbox::delete::delete_mailbox_impl;
|
||||
use crate::modules::mailbox::list::get_account_mailboxes;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::ApiResult;
|
||||
@@ -58,4 +59,32 @@ impl MailBoxApi {
|
||||
let remote = remote.0.unwrap_or(false);
|
||||
Ok(Json(get_account_mailboxes(account_id, remote).await?))
|
||||
}
|
||||
|
||||
/// Deletes a mailbox for the specified account.
|
||||
///
|
||||
/// Requires `DATA_DELETE` permission on the target account.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `account_id`: Account identifier.
|
||||
/// - `mailbox_id`: Mailbox identifier.
|
||||
///
|
||||
#[oai(
|
||||
path = "/delete-mailbox/:account_id/:mailbox_id",
|
||||
method = "delete",
|
||||
operation_id = "delete_mailbox"
|
||||
)]
|
||||
async fn delete_mailbox(
|
||||
&self,
|
||||
/// The unique identifier of the account.
|
||||
account_id: Path<u64>,
|
||||
mailbox_id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let account_id = account_id.0;
|
||||
let mailbox_id = mailbox_id.0;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_DELETE)
|
||||
.await?;
|
||||
Ok(delete_mailbox_impl(account_id, mailbox_id).await?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::indexer::envelope::Envelope;
|
||||
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
|
||||
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
|
||||
use crate::modules::message::append::restore_emails;
|
||||
use crate::modules::message::append::RestoreMessagesRequest;
|
||||
use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
|
||||
use crate::modules::message::delete::delete_messages_impl;
|
||||
use crate::modules::message::list::{get_thread_messages, list_messages_impl};
|
||||
@@ -66,7 +68,7 @@ impl MessageApi {
|
||||
Ok(delete_messages_impl(request).await?)
|
||||
}
|
||||
|
||||
/// Lists messages in a specified mailbox for the given account.
|
||||
/// Lists messages in a mailbox. Requires `mailbox_id`, `page`, and `page_size` query parameters.
|
||||
#[oai(
|
||||
path = "/list-messages/:account_id",
|
||||
method = "get",
|
||||
@@ -74,7 +76,9 @@ impl MessageApi {
|
||||
)]
|
||||
async fn list_messages(
|
||||
&self,
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the mailbox to list messages from.
|
||||
mailbox_id: Query<u64>,
|
||||
page: Query<u64>,
|
||||
page_size: Query<u64>,
|
||||
@@ -90,7 +94,8 @@ impl MessageApi {
|
||||
))
|
||||
}
|
||||
|
||||
/// Lists messages in a specified mailbox for the given account.
|
||||
/// Searches messages across all mailboxes using various filter criteria.
|
||||
/// The search filters are provided in the request body.
|
||||
#[oai(
|
||||
path = "/search-messages",
|
||||
method = "post",
|
||||
@@ -112,7 +117,7 @@ impl MessageApi {
|
||||
Ok(Json(search_messages_impl(authorized_ids, payload.0).await?))
|
||||
}
|
||||
|
||||
/// Get thread's envelopes in a specified mailbox for the given account.
|
||||
/// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters.
|
||||
#[oai(
|
||||
path = "/get-thread-messages/:account_id",
|
||||
method = "get",
|
||||
@@ -140,9 +145,9 @@ impl MessageApi {
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetches the content of a specific email for the given account.
|
||||
/// Fetches the content of a specific email.
|
||||
#[oai(
|
||||
path = "/message-content/:account_id",
|
||||
path = "/message-content/:account_id/:message_id",
|
||||
method = "get",
|
||||
operation_id = "fetch_message_content"
|
||||
)]
|
||||
@@ -151,19 +156,54 @@ impl MessageApi {
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the message to fetch.
|
||||
message_id: Query<u64>,
|
||||
message_id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> 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, message_id.0).await?))
|
||||
Ok(Json(
|
||||
retrieve_email_content(account_id, message_id.0).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetches the full content of a specific email for the given account.
|
||||
/// Retrieves the envelope (metadata) of a specific message.
|
||||
#[oai(
|
||||
path = "/download-message/:account_id",
|
||||
path = "/envelope/:account_id/:message_id",
|
||||
method = "get",
|
||||
operation_id = "get_envelope"
|
||||
)]
|
||||
async fn get_envelope(
|
||||
&self,
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the message.
|
||||
message_id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Envelope>> {
|
||||
let account_id = account_id.0;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.await?;
|
||||
let envelope = ENVELOPE_INDEX_MANAGER
|
||||
.get_envelope_by_id(account_id, message_id.0)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Envelope not found: account_id={} message_id={}",
|
||||
account_id, message_id.0
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
Ok(Json(envelope))
|
||||
}
|
||||
|
||||
/// Downloads the raw EML file of a specific email.
|
||||
#[oai(
|
||||
path = "/download-message/:account_id/:message_id",
|
||||
method = "get",
|
||||
operation_id = "download_message"
|
||||
)]
|
||||
@@ -172,7 +212,7 @@ impl MessageApi {
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the message to download.
|
||||
message_id: Query<u64>,
|
||||
message_id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Attachment<Body>> {
|
||||
let account_id = account_id.0;
|
||||
@@ -189,9 +229,28 @@ impl MessageApi {
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
/// Downloads a specific attachment by filename.
|
||||
#[oai(
|
||||
path = "/download-attachment/:account_id",
|
||||
path = "/restore-messages/:account_id",
|
||||
method = "post",
|
||||
operation_id = "restore_messages"
|
||||
)]
|
||||
async fn restore_messages(
|
||||
&self,
|
||||
account_id: Path<u64>,
|
||||
/// Message IDs to restore.
|
||||
payload: Json<RestoreMessagesRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let account_id = account_id.0;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH)
|
||||
.await?;
|
||||
Ok(restore_emails(account_id, payload.0.message_ids).await?)
|
||||
}
|
||||
|
||||
/// Downloads a specific attachment from an email. Requires `name` query parameter.
|
||||
#[oai(
|
||||
path = "/download-attachment/:account_id/:message_id",
|
||||
method = "get",
|
||||
operation_id = "download_attachment"
|
||||
)]
|
||||
@@ -200,7 +259,7 @@ impl MessageApi {
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the message containing the attachment.
|
||||
message_id: Query<u64>,
|
||||
message_id: Path<u64>,
|
||||
/// The filename of the attachment to download.
|
||||
name: Query<String>,
|
||||
context: ClientContext,
|
||||
@@ -210,10 +269,9 @@ impl MessageApi {
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.await?;
|
||||
let message_id = message_id.0;
|
||||
let name = name.0.trim();
|
||||
let reader = EML_INDEX_MANAGER
|
||||
.get_attachment(account_id, message_id, name)
|
||||
.get_attachment(account_id, message_id.0, name)
|
||||
.await?;
|
||||
let body = Body::from_async_read(reader);
|
||||
let attachment = Attachment::new(body)
|
||||
@@ -265,4 +323,25 @@ impl MessageApi {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[oai(
|
||||
path = "/all-contacts",
|
||||
method = "get",
|
||||
operation_id = "get_all_contacts"
|
||||
)]
|
||||
async fn get_all_contacts(&self, context: ClientContext) -> 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_INDEX_MANAGER
|
||||
.get_all_contacts(authorized_ids)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ impl SystemApi {
|
||||
#[oai(path = "/proxy/:id", method = "delete", operation_id = "remove_proxy")]
|
||||
async fn remove_proxy(
|
||||
&self,
|
||||
/// The name of the OAuth2 configuration to retrieve
|
||||
/// The ID of the proxy configuration to delete.
|
||||
id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
@@ -96,11 +96,11 @@ impl SystemApi {
|
||||
Ok(Proxy::delete(id.0).await?)
|
||||
}
|
||||
|
||||
/// Retrieve a specific proxy configuration by ID
|
||||
/// Retrieve a specific proxy configuration by ID. Requires root permission.
|
||||
#[oai(path = "/proxy/:id", method = "get", operation_id = "get_proxy")]
|
||||
async fn get_proxy(
|
||||
&self,
|
||||
/// The name of the OAuth2 configuration to retrieve
|
||||
/// The ID of the proxy configuration to retrieve.
|
||||
id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Proxy>> {
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::modules::users::payload::{
|
||||
RoleCreateRequest, RoleUpdateRequest, UserCreateRequest, UserUpdateRequest,
|
||||
};
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use crate::modules::users::role::UserRole;
|
||||
use crate::modules::users::role::{RoleType, UserRole};
|
||||
use crate::modules::users::view::UserView;
|
||||
use crate::modules::users::UserModel;
|
||||
use poem::web::Path;
|
||||
@@ -101,10 +101,7 @@ impl UsersApi {
|
||||
let roles = UserRole::list_all().await?;
|
||||
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
|
||||
let users = UserModel::list_all().await?;
|
||||
let users = users
|
||||
.into_iter()
|
||||
.map(|u| u.to_view(&role_lookup))
|
||||
.collect();
|
||||
let users = users.into_iter().map(|u| u.to_view(&role_lookup)).collect();
|
||||
Ok(Json(users))
|
||||
}
|
||||
|
||||
@@ -214,4 +211,21 @@ impl UsersApi {
|
||||
|
||||
Ok(Json(minimal_list))
|
||||
}
|
||||
|
||||
#[oai(
|
||||
path = "/list-account-roles",
|
||||
method = "get",
|
||||
operation_id = "list_account_roles"
|
||||
)]
|
||||
async fn list_account_roles(&self, context: ClientContext) -> ApiResult<Json<Vec<UserRole>>> {
|
||||
context
|
||||
.require_permission(None, Permission::USER_VIEW)
|
||||
.await?;
|
||||
let all = UserRole::list_all().await?;
|
||||
Ok(Json(
|
||||
all.into_iter()
|
||||
.filter(|r| matches!(r.role_type, RoleType::Account))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,16 +46,16 @@ pub struct Settings {
|
||||
)]
|
||||
pub bichon_http_port: i32,
|
||||
|
||||
/// The IP address that the node binds to, in IPv4 format (e.g., 192.168.1.1).
|
||||
/// The IP address that the node binds to, in IPv4 or IPv6 format (e.g., 192.168.1.1 or ::1).
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
default_value = "0.0.0.0",
|
||||
help = "The IP address that the node binds to, in IPv4 format (e.g., 192.168.1.1). Required in cluster mode.",
|
||||
help = "The IP address that the node binds to, in IPv4 or IPv6 format (e.g., 192.168.1.1 or ::1).",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
// Ensure the input is a valid IPv4 address
|
||||
if s.parse::<std::net::Ipv4Addr>().is_err() {
|
||||
return Err("The bind IP address must be a valid IPv4 address.".to_string());
|
||||
// Ensure the input is a valid IPv4 or IPv6 address
|
||||
if s.parse::<std::net::Ipv4Addr>().is_err() && s.parse::<std::net::Ipv6Addr>().is_err() {
|
||||
return Err("The bind IP address must be a valid IPv4 or IPv6 address.".to_string());
|
||||
}
|
||||
|
||||
// If the address is valid, return it
|
||||
@@ -173,7 +173,44 @@ pub struct Settings {
|
||||
})
|
||||
)]
|
||||
pub bichon_root_dir: String,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
help = "Set the file path for email index directory",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
let path = PathBuf::from(s);
|
||||
if !path.is_absolute() {
|
||||
return Err("Path must be an absolute directory path".to_string());
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err(format!("Path {:?} does not exist", path));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err(format!("Path {:?} is not a directory", path));
|
||||
}
|
||||
Ok(s.to_string())
|
||||
})
|
||||
)]
|
||||
pub bichon_index_dir: Option<String>,
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
help = "Set the file path for email data directory",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
let path = PathBuf::from(s);
|
||||
if !path.is_absolute() {
|
||||
return Err("Path must be an absolute directory path".to_string());
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err(format!("Path {:?} does not exist", path));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err(format!("Path {:?} is not a directory", path));
|
||||
}
|
||||
Ok(s.to_string())
|
||||
})
|
||||
)]
|
||||
pub bichon_data_dir: Option<String>,
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
|
||||
@@ -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 crate::modules::context::Initialize;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::{
|
||||
@@ -35,7 +34,6 @@ const LOG_DIR: &str = "logs";
|
||||
const TLS_CERT: &str = "cert.pem";
|
||||
const TLS_KEY: &str = "key.pem";
|
||||
|
||||
|
||||
pub static DATA_DIR_MANAGER: LazyLock<DataDirManager> =
|
||||
LazyLock::new(|| DataDirManager::new(PathBuf::from(&SETTINGS.bichon_root_dir)));
|
||||
|
||||
@@ -49,7 +47,7 @@ pub struct DataDirManager {
|
||||
pub tls_key: PathBuf,
|
||||
pub envelope_dir: PathBuf,
|
||||
pub eml_dir: PathBuf,
|
||||
pub log_dir: PathBuf
|
||||
pub log_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Initialize for DataDirManager {
|
||||
@@ -66,6 +64,18 @@ impl Initialize for DataDirManager {
|
||||
|
||||
impl DataDirManager {
|
||||
pub fn new(root_dir: PathBuf) -> Self {
|
||||
let envelope_dir = if let Some(ref index_dir) = SETTINGS.bichon_index_dir {
|
||||
PathBuf::from(index_dir)
|
||||
} else {
|
||||
root_dir.join(ENVELOPE_DIR)
|
||||
};
|
||||
|
||||
let eml_dir = if let Some(ref data_dir) = SETTINGS.bichon_data_dir {
|
||||
PathBuf::from(data_dir)
|
||||
} else {
|
||||
root_dir.join(EML_DIR)
|
||||
};
|
||||
|
||||
Self {
|
||||
root_dir: root_dir.clone(),
|
||||
meta_db: root_dir.join(META_FILE),
|
||||
@@ -73,9 +83,9 @@ impl DataDirManager {
|
||||
tls_key: root_dir.join(TLS_KEY),
|
||||
tls_cert: root_dir.join(TLS_CERT),
|
||||
log_dir: root_dir.join(LOG_DIR),
|
||||
envelope_dir: root_dir.join(ENVELOPE_DIR),
|
||||
envelope_dir,
|
||||
temp_dir: root_dir.join(TMP_DIR),
|
||||
eml_dir: root_dir.join(EML_DIR),
|
||||
eml_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::modules::{
|
||||
database::{list_all_impl, manager::DB_MANAGER},
|
||||
error::BichonResult,
|
||||
users::BichonUser,
|
||||
users::UserModel,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
@@ -34,7 +34,7 @@ pub struct MinimalUser {
|
||||
|
||||
impl MinimalUser {
|
||||
pub async fn list_all() -> BichonResult<Vec<MinimalUser>> {
|
||||
let all_users = list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?;
|
||||
let all_users = list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?;
|
||||
let minimal_list = all_users
|
||||
.into_iter()
|
||||
.map(|user| MinimalUser {
|
||||
|
||||
@@ -469,7 +469,7 @@ impl BichonUserV2 {
|
||||
|
||||
delete_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get()
|
||||
.primary::<BichonUser>(id)
|
||||
.primary::<UserModel>(id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
@@ -498,12 +498,26 @@ impl BichonUserV2 {
|
||||
pub async fn update(id: u64, request: UserUpdateRequest) -> BichonResult<()> {
|
||||
let _ = &request.validate().await?;
|
||||
let password_changed = request.password.is_some();
|
||||
//
|
||||
let is_default_admin = id == DEFAULT_ADMIN_USER_ID;
|
||||
|
||||
if DEFAULT_ADMIN_USER_ID == id && request.global_roles.is_some() {
|
||||
return Err(raise_error!(
|
||||
format!("The role assignments for default admin (id={}) are immutable to ensure system accessibility.", id),
|
||||
ErrorCode::Forbidden
|
||||
));
|
||||
if is_default_admin {
|
||||
if let Some(roles) = request.global_roles.as_deref() {
|
||||
let is_valid = matches!(
|
||||
roles,
|
||||
[role] if *role == DEFAULT_ADMIN_ROLE_ID
|
||||
);
|
||||
|
||||
if !is_valid {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"The role assignments for default admin (id={}) are immutable to ensure system accessibility.",
|
||||
id
|
||||
),
|
||||
ErrorCode::Forbidden
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(username) = &request.username {
|
||||
|
||||
@@ -222,9 +222,9 @@ impl UserCreateRequest {
|
||||
let username_len = self.username.len();
|
||||
|
||||
// 1. Username constraints
|
||||
if username_len < 5 {
|
||||
if username_len < 3 {
|
||||
return Err(raise_error!(
|
||||
"Username must be at least 5 characters long.".into(),
|
||||
"Username must be at least 3 characters long.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
@@ -351,9 +351,9 @@ impl UserUpdateRequest {
|
||||
pub async fn validate(&self) -> BichonResult<()> {
|
||||
if let Some(username) = &self.username {
|
||||
let len = username.len();
|
||||
if len < 5 || len > 32 {
|
||||
if len < 3 || len > 32 {
|
||||
return Err(raise_error!(
|
||||
"Username must be 5-32 characters.".into(),
|
||||
"Username must be 3-32 characters.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@ impl Permission {
|
||||
/// Create, modify, and delete all users and their roles (Admin only).
|
||||
pub const USER_MANAGE: &str = "user:manage";
|
||||
|
||||
/// View the minimal user list and basic profiles (Managers and Admins).
|
||||
/// View the minimal user list, basic user profiles,
|
||||
/// including visibility into account-level roles (Managers and Admins).
|
||||
pub const USER_VIEW: &str = "user:view";
|
||||
|
||||
/// View and revoke all access tokens in the system.
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||
"@react-spring/web": "^10.0.3",
|
||||
|
||||
6
web/pnpm-lock.yaml
generated
6
web/pnpm-lock.yaml
generated
@@ -86,6 +86,12 @@ importers:
|
||||
'@radix-ui/react-toast':
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-toggle':
|
||||
specifier: ^1.1.10
|
||||
version: 1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-toggle-group':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
@@ -21,6 +21,7 @@ import axiosInstance from "@/api/axiosInstance";
|
||||
|
||||
|
||||
export interface MailboxData {
|
||||
account_id: number;
|
||||
attributes: { attr: string; extension: string | null }[];
|
||||
delimiter: string | null;
|
||||
exists: number;
|
||||
@@ -34,4 +35,10 @@ export interface MailboxData {
|
||||
export const list_mailboxes = async (accountId: number, remote: boolean) => {
|
||||
const response = await axiosInstance.get<MailboxData[]>(`/api/v1/list-mailboxes/${accountId}?remote=${remote}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const delete_mailbox = async (accountId: number, mailboxId: string) => {
|
||||
const response = await axiosInstance.delete(`/api/v1/delete-mailbox/${accountId}/${mailboxId}`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -48,7 +48,7 @@ export const get_thread_messages = async (accountId: number, thread_id: number,
|
||||
}
|
||||
|
||||
export const download_attachment = async (accountId: number, id: number, attachmentFileName: string) => {
|
||||
const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}?message_id=${id}&name=${attachmentFileName}`, { responseType: 'blob' });
|
||||
const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}/${id}?name=${attachmentFileName}`, { responseType: 'blob' });
|
||||
const blob = new Blob([response.data]);
|
||||
saveAs(blob, attachmentFileName);
|
||||
};
|
||||
@@ -83,7 +83,7 @@ export const getContent = (messageContent: MessageContentResponse): string | nul
|
||||
};
|
||||
|
||||
export const load_message = async (accountId: number, id: number) => {
|
||||
const response = await axiosInstance.get<MessageContentResponse>(`/api/v1/message-content/${accountId}?message_id=${id}`);
|
||||
const response = await axiosInstance.get<MessageContentResponse>(`/api/v1/message-content/${accountId}/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -93,7 +93,16 @@ export const delete_messages = async (payload: Record<string, number[]>) => {
|
||||
};
|
||||
|
||||
export const download_message = async (accountId: number, id: number) => {
|
||||
const response = await axiosInstance.get(`/api/v1/download-message/${accountId}?message_id=${id}`, { responseType: 'blob' });
|
||||
const response = await axiosInstance.get(`/api/v1/download-message/${accountId}/${id}`, { responseType: 'blob' });
|
||||
const blob = new Blob([response.data]);
|
||||
saveAs(blob, `${id}.eml`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const restore_message = async (accountId: number, messageIds: number[]) => {
|
||||
const response = await axiosInstance.post(`/api/v1/restore-messages/${accountId}`, {
|
||||
message_ids: messageIds,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
@@ -30,7 +30,7 @@ export interface TagCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const get_top_tags = async () => {
|
||||
export const get_tags = async () => {
|
||||
const response = await axiosInstance.get<TagCount[]>("/api/v1/all-tags");
|
||||
return response.data;
|
||||
}
|
||||
@@ -41,3 +41,9 @@ export const update_tags = async (data: Record<string, any>) => {
|
||||
};
|
||||
|
||||
|
||||
export const get_contacts = async () => {
|
||||
const response = await axiosInstance.get<string[]>("/api/v1/all-contacts");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -180,6 +180,11 @@ export const list_minimal_users = async () => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const list_account_roles = async () => {
|
||||
const response = await axiosInstance.get<UserRole[]>("/api/v1/list-account-roles");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const remove_user = async (id: number) => {
|
||||
const response = await axiosInstance.delete(`/api/v1/users/${id}`);
|
||||
return response.data;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
@@ -79,6 +80,7 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{confirmText ?? t('dialogs.continue')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function DatePicker({
|
||||
{selected ? (
|
||||
format(selected, 'PPP', { locale: dateLocale })
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
<span className='text-xs'>{placeholder}</span>
|
||||
)}
|
||||
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { NavGroup } from '@/components/layout/nav-group'
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import { useSidebarData } from './data/sidebar-data'
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { open } = useSidebar();
|
||||
@@ -36,22 +37,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarHeader>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
asChild
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
<div className='flex aspect-square size-16 items-center justify-center rounded-lg text-sidebar-primary-foreground'>
|
||||
<img
|
||||
className={open ? "relative ml-[12px] mr-[12px]" : "mr-[30px]"}
|
||||
src={Logo}
|
||||
width={open ? 60 : 40}
|
||||
height={open ? 60 : 40}
|
||||
alt='Logo'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid flex-1 text-left text-lg leading-tight'>
|
||||
<span className='truncate font-semibold'>
|
||||
Bichon
|
||||
</span>
|
||||
</div>
|
||||
<Link to="/">
|
||||
<div className='flex aspect-square size-16 items-center justify-center rounded-lg text-sidebar-primary-foreground'>
|
||||
<img
|
||||
className={open ? "relative ml-[12px] mr-[12px]" : "mr-[30px]"}
|
||||
src={Logo}
|
||||
width={open ? 60 : 40}
|
||||
height={open ? 60 : 40}
|
||||
alt='Logo'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid flex-1 text-left text-lg leading-tight'>
|
||||
<span className='truncate font-semibold'>
|
||||
Bichon
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DoubleArrowRightIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -33,14 +34,15 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { showNumbers } from '@/lib/utils'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface PaginationProps {
|
||||
totalItems: number
|
||||
pageIndex: number,
|
||||
pageSize: number,
|
||||
hasNextPage: () => boolean,
|
||||
setPageIndex: (pageIndex: number) => void,
|
||||
setPageSize: (pageSize: number) => void,
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
hasNextPage: () => boolean
|
||||
setPageIndex: (pageIndex: number) => void
|
||||
setPageSize: (pageSize: number) => void
|
||||
}
|
||||
|
||||
export function EnvelopeListPagination({
|
||||
@@ -52,8 +54,13 @@ export function EnvelopeListPagination({
|
||||
setPageSize,
|
||||
}: PaginationProps) {
|
||||
const { t } = useTranslation()
|
||||
const [pageInput, setPageInput] = useState(pageIndex + 1)
|
||||
const pageCount = Math.ceil(totalItems / pageSize)
|
||||
|
||||
useEffect(() => {
|
||||
setPageInput(pageIndex + 1)
|
||||
}, [pageIndex])
|
||||
|
||||
const handlePageSizeChange = (value: string) => {
|
||||
const newPageSize = Number(value)
|
||||
setPageSize(newPageSize)
|
||||
@@ -69,7 +76,7 @@ export function EnvelopeListPagination({
|
||||
setPageIndex(newPageIndex)
|
||||
}
|
||||
|
||||
const currentPage = pageIndex + 1;
|
||||
const currentPage = pageIndex + 1
|
||||
const pageNumbers = showNumbers(currentPage, pageCount)
|
||||
|
||||
return (
|
||||
@@ -88,7 +95,7 @@ export function EnvelopeListPagination({
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 30, 40, 50, 100].map((size) => (
|
||||
{[10, 20, 30, 40, 50, 100, 200].map((size) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
@@ -96,8 +103,20 @@ export function EnvelopeListPagination({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-center justify-center text-sm font-medium'>
|
||||
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
|
||||
<div className='hidden items-center justify-center text-sm font-medium sm:flex'>
|
||||
{t("table.page")}
|
||||
<Input
|
||||
type="number"
|
||||
value={pageInput}
|
||||
onBlur={() => {
|
||||
if (Number.isNaN(pageInput)) return
|
||||
if (pageInput > 0) setPageIndex(pageInput - 1)
|
||||
else setPageIndex(0)
|
||||
}}
|
||||
onChange={(e) => setPageInput(Number(e.target.value))}
|
||||
className='mx-2 h-8 w-20'
|
||||
/>
|
||||
{t("table.of")} {pageCount}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
@@ -154,4 +173,4 @@ export function EnvelopeListPagination({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { resetToken } from '@/stores/authStore'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface SignOutDialogProps {
|
||||
open: boolean
|
||||
@@ -10,7 +11,7 @@ interface SignOutDialogProps {
|
||||
export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const { t } = useTranslation()
|
||||
const handleSignOut = () => {
|
||||
resetToken()
|
||||
const currentPath = location.href
|
||||
@@ -25,12 +26,15 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title='Sign out'
|
||||
desc='Are you sure you want to sign out? You will need to sign in again to access your account.'
|
||||
confirmText='Sign out'
|
||||
title={t('sign_out.title', 'Sign out')}
|
||||
desc={t(
|
||||
'sign_out.desc',
|
||||
'Are you sure you want to sign out? You will need to sign in again to access your account.'
|
||||
)}
|
||||
confirmText={t('sign_out.confirm', 'Sign out')}
|
||||
destructive
|
||||
handleConfirm={handleSignOut}
|
||||
className='sm:max-w-sm'
|
||||
className="sm:max-w-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
interface ScrollAreaProps
|
||||
extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
orientation?: 'horizontal' | 'vertical' | 'both'
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
@@ -24,7 +24,12 @@ const ScrollArea = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar orientation={orientation} />
|
||||
{orientation === "both" ? (
|
||||
<>
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</>
|
||||
) : <ScrollBar orientation={orientation} />}
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
@@ -40,9 +45,9 @@ const ScrollBar = React.forwardRef<
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,13 +5,13 @@ const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className='relative w-full overflow-auto'>
|
||||
// <div className='relative w-full overflow-auto'>
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
// </div>
|
||||
))
|
||||
Table.displayName = 'Table'
|
||||
|
||||
@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
<thead ref={ref} className={cn('[&_th]:sticky [&_th]:top-0 [&_th]:bg-background [&_th]:z-20', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
|
||||
61
web/src/components/ui/toggle-group.tsx
Normal file
61
web/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
43
web/src/components/ui/toggle.tsx
Normal file
43
web/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -224,6 +224,7 @@ interface VirtualizedSelectProps {
|
||||
defaultValue?: string | string[];
|
||||
noItemsComponent?: React.ReactNode;
|
||||
multiple?: boolean;
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
export function VirtualizedSelect({
|
||||
@@ -232,6 +233,7 @@ export function VirtualizedSelect({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
size = 'default',
|
||||
isLoading,
|
||||
disabled = false,
|
||||
placeholder = 'Search items...',
|
||||
@@ -273,7 +275,7 @@ export function VirtualizedSelect({
|
||||
.filter(Boolean);
|
||||
|
||||
if (selectedLabels.length === 0) return placeholder;
|
||||
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`;
|
||||
return selectedLabels.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -281,13 +283,14 @@ export function VirtualizedSelect({
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size={size}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn('justify-between', className)}
|
||||
disabled={isLoading || disabled}
|
||||
>
|
||||
{getDisplayText()}
|
||||
<span className='truncate'>{getDisplayText()}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -20,7 +20,7 @@ import React from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, ShieldCheck, Users, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -52,9 +52,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { useRoles } from '@/hooks/use-roles'
|
||||
import { useMinimalUsers } from '@/hooks/use-minimal-users'
|
||||
import { access_assign, AccountModel } from '@/api/account/api'
|
||||
import { list_account_roles, list_minimal_users, MinimalUser, UserRole } from '@/api/users/api'
|
||||
|
||||
interface Props {
|
||||
currentRow: AccountModel
|
||||
@@ -71,12 +70,23 @@ export function AccountAccessAssignmentDialog({
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { accountRoles, isLoading: isLoadingRoles } = useRoles()
|
||||
const { users, isLoading: isLoadingUsers } = useMinimalUsers()
|
||||
const { data: roles, isLoading: isLoadingRoles } = useQuery<UserRole[]>({
|
||||
queryKey: ['account-role-list'],
|
||||
queryFn: list_account_roles,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
|
||||
const { data: users, isLoading: isLoadingUsers } = useQuery<MinimalUser[]>({
|
||||
queryKey: ['minimal-user-list'],
|
||||
queryFn: list_minimal_users,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const [keyword, setKeyword] = React.useState('')
|
||||
|
||||
// 1. 定义校验 Schema (集成国际化错误提示)
|
||||
const assignmentSchema = z.object({
|
||||
account_ids: z.array(z.number()),
|
||||
user_ids: z.array(z.number()).min(1, {
|
||||
@@ -101,7 +111,7 @@ export function AccountAccessAssignmentDialog({
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
if (!keyword.trim()) return users
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
return users.filter(
|
||||
return users!.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(lowerKeyword) ||
|
||||
user.email.toLowerCase().includes(lowerKeyword)
|
||||
@@ -170,7 +180,7 @@ export function AccountAccessAssignmentDialog({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{accountRoles.map((role) => (
|
||||
{roles && roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
@@ -206,12 +216,12 @@ export function AccountAccessAssignmentDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 space-y-1">
|
||||
{filteredUsers.length === 0 ? (
|
||||
{filteredUsers && (filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
{t('accounts.access_control.user_empty')}
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
filteredUsers!.map((user) => (
|
||||
<FormField
|
||||
key={user.id}
|
||||
control={form.control}
|
||||
@@ -242,7 +252,7 @@ export function AccountAccessAssignmentDialog({
|
||||
)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -128,6 +128,7 @@ const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
|
||||
.int()
|
||||
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
|
||||
.nullable()
|
||||
.optional(),
|
||||
sync_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
|
||||
sync_batch_size: z
|
||||
@@ -221,7 +222,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
|
||||
const accountSchema = getAccountSchema(isEdit, t);
|
||||
const form = useForm<Account>({
|
||||
mode: "all",
|
||||
mode: "onChange",
|
||||
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
|
||||
resolver: zodResolver(accountSchema),
|
||||
});
|
||||
@@ -290,7 +291,13 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
sync_batch_size: data.sync_batch_size,
|
||||
};
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(commonData);
|
||||
const isAllMode = !data.date_since && !data.date_before;
|
||||
const clear_folder_limit = !data.folder_limit;
|
||||
updateMutation.mutate({
|
||||
...commonData,
|
||||
...(isAllMode ? { clear_date_range: true } : {}),
|
||||
...(clear_folder_limit ? { clear_folder_limit: true } : {})
|
||||
});
|
||||
} else {
|
||||
createMutation.mutate({ ...commonData, account_type: "IMAP" });
|
||||
}
|
||||
@@ -351,7 +358,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
onOpenChange(state);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-5xl w-full p-0 overflow-hidden flex flex-col h-[90vh]">
|
||||
<DialogContent className="max-w-[95vw] md:max-w-5xl w-full p-0 overflow-hidden flex flex-col h-[50rem]">
|
||||
<div className="p-6 pb-2 flex-shrink-0">
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
|
||||
|
||||
@@ -116,11 +116,11 @@ export function useColumns(): ColumnDef<AccountModel>[] {
|
||||
cell: ({ row }) => {
|
||||
const { created_user_name, created_user_email } = row.original;
|
||||
return (
|
||||
<div className="flex flex-col py-1 text-center">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
<div className="flex flex-col items-center leading-[1.1]">
|
||||
<span className="text-[13px] font-medium text-foreground leading-none">
|
||||
{created_user_name}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground font-mono">
|
||||
<span className="text-[11px] text-muted-foreground font-mono leading-none">
|
||||
{created_user_email}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<NoSyncAccount>({
|
||||
mode: "all",
|
||||
mode: "onChange",
|
||||
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
|
||||
resolver: zodResolver(accountSchema(t)),
|
||||
});
|
||||
@@ -164,7 +164,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
{t('accounts.clickSaveWhenDone')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className='h-[23rem] w-full pr-4 -mr-4 py-1'>
|
||||
<ScrollArea className='h-[13rem] w-full pr-4 -mr-4 py-1'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='nosync-account-form'
|
||||
|
||||
@@ -85,7 +85,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<span className="text-blue-500 font-medium truncate">{currentRow.email}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[85vh] px-4 sm:px-6 pb-6">
|
||||
<ScrollArea className="max-h-[55rem] px-4 sm:px-6 pb-6">
|
||||
{isLoading && (
|
||||
<div className="space-y-4 py-6">
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
|
||||
@@ -178,7 +178,6 @@ export default function Step3() {
|
||||
onSelect={(date) => field.onChange(date?.toLocaleDateString('en-CA'))}
|
||||
disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
|
||||
locale={dateLocale}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -244,8 +243,11 @@ export default function Step3() {
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('accounts.folderLimitPlaceholder')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.onChange(value === '' ? null : Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function MailArchiveDashboard() {
|
||||
return (
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<Main higher>
|
||||
<div className="flex-1 space-y-6 p-6 md:p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -304,7 +304,6 @@ export default function MailArchiveDashboard() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Attachments */}
|
||||
<TabsContent value="attachment" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -459,7 +458,6 @@ export default function MailArchiveDashboard() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Footer / Copyright - New Addition */}
|
||||
<div className="p-6 md:p-8 pt-0 text-center text-xs text-muted-foreground">
|
||||
© 2025 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project
|
||||
</div>
|
||||
|
||||
105
web/src/features/mailbox/components/delete-mailbox-dialog.tsx
Normal file
105
web/src/features/mailbox/components/delete-mailbox-dialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMailboxContext } from '../context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { delete_mailbox } from '@/api/mailbox/api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useMailboxContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ accountId, mailboxId }: { accountId: number; mailboxId: string }) =>
|
||||
delete_mailbox(accountId, mailboxId),
|
||||
retry: false,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-mailboxes', `${selectedAccountId}`] });
|
||||
onOpenChange(false);
|
||||
setDeleteMailboxId(undefined);
|
||||
toast({
|
||||
title: t('mailbox.deleteMailboxDialog.successTitle'),
|
||||
description: t('mailbox.deleteMailboxDialog.successDesc'),
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('mailbox.deleteMailboxDialog.errorTitle'),
|
||||
description: error.message || "Delete failed",
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedAccountId && deleteMailboxId) {
|
||||
deleteMutation.mutate({
|
||||
accountId: selectedAccountId,
|
||||
mailboxId: deleteMailboxId
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
onOpenChange(isOpen);
|
||||
if (!isOpen) setDeleteMailboxId(undefined);
|
||||
}}
|
||||
handleConfirm={handleDelete}
|
||||
className="max-w-xl"
|
||||
isLoading={isLoading}
|
||||
title={
|
||||
<span className="text-destructive">
|
||||
<IconAlertTriangle
|
||||
className="mr-1 inline-block stroke-destructive"
|
||||
size={18}
|
||||
/>{' '}
|
||||
{t('mailbox.deleteMailboxDialog.title')}
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className="space-y-4">
|
||||
<p className="mb-2">
|
||||
{t('mailbox.deleteMailboxDialog.desc')}
|
||||
</p>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('mailbox.deleteMailboxDialog.warningTitle')}</AlertTitle>
|
||||
<AlertDescription>{t('mailbox.deleteMailboxDialog.warningDesc')}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText={t('mailbox.deleteMailboxDialog.confirm')}
|
||||
destructive
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { MailIcon, Paperclip, Trash2 } from "lucide-react"
|
||||
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { EmailEnvelope } from "@/api"
|
||||
import { useMailboxContext } from "../context"
|
||||
@@ -28,6 +28,8 @@ import { MailBulkActions } from "./bulk-actions"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
@@ -109,7 +111,7 @@ export function MailList({
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selected.size > 0
|
||||
? `${selected.size} ${t('common.selected')}`
|
||||
? `${t('search.bulkActions.selected', { count: selected.size })}`
|
||||
: t('common.selectAll')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -179,15 +181,44 @@ export function MailList({
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(item)
|
||||
}}
|
||||
className="p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-all"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelected(new Set([item.id]));
|
||||
setOpen("restore");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="ml-2 h-3.5 w-3.5" />
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore } from 'lucide-react';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileAudio, FileVideo, FileSpreadsheet, FileArchive, FileCode, FileIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -83,6 +83,35 @@ const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const getFileConfig = (mimeType: string) => {
|
||||
const type = mimeType.toLowerCase();
|
||||
if (type.includes('pdf')) {
|
||||
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
|
||||
}
|
||||
if (type.includes('image/')) {
|
||||
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
|
||||
}
|
||||
if (type.includes('audio/')) {
|
||||
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
|
||||
}
|
||||
|
||||
if (type.includes('video/')) {
|
||||
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
|
||||
}
|
||||
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
|
||||
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
|
||||
}
|
||||
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
|
||||
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
|
||||
}
|
||||
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
|
||||
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
|
||||
}
|
||||
|
||||
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
|
||||
};
|
||||
|
||||
export function MailMessageView({
|
||||
envelope,
|
||||
showActions = true,
|
||||
@@ -245,11 +274,24 @@ export function MailMessageView({
|
||||
const nonInline = attachments.filter((a) => !a.inline);
|
||||
return nonInline.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{nonInline.map((attachment, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className="flex items-center space-x-8">
|
||||
<span className="truncate text-xs">{attachment.filename}</span>
|
||||
<span className="text-xs px-2 py-1 rounded">[{attachment.file_type}]</span>
|
||||
{nonInline.map((attachment, i) => {
|
||||
const { icon, color } = getFileConfig(attachment.file_type);
|
||||
return <div key={i} className="flex items-center">
|
||||
<div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
|
||||
<div className={`flex-shrink-0 ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
|
||||
<span
|
||||
className="truncate text-xs font-medium text-foreground/90"
|
||||
title={attachment.filename}
|
||||
>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase tracking-tighter group-hover:text-foreground transition-colors">
|
||||
{attachment.file_type.split('/').pop()?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 ml-auto">
|
||||
<span className="text-gray-500 text-xs shrink-0">
|
||||
@@ -268,7 +310,7 @@ export function MailMessageView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 text-xs italic">
|
||||
|
||||
@@ -49,7 +49,12 @@ import { styled } from "@mui/material/styles"
|
||||
import { animated, useSpring } from "@react-spring/web"
|
||||
import { TransitionProps } from "@mui/material/transitions"
|
||||
import Collapse from "@mui/material/Collapse"
|
||||
import { FolderIcon } from "lucide-react"
|
||||
import { FolderIcon, MoreVertical, Trash2 } from "lucide-react"
|
||||
import { RestoreMessageDialog } from "./restore-message-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { MailBoxDeleteDialog } from "./delete-mailbox-dialog"
|
||||
|
||||
|
||||
interface MailProps {
|
||||
@@ -78,15 +83,14 @@ const useListMessages = ({ accountId, mailboxId, page, page_size }: ListMessages
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
interface CustomLabelProps {
|
||||
exists?: number;
|
||||
attributes?: { attr: string; extension: string | null }[],
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
icon?: React.ElementType;
|
||||
expandable?: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function CustomLabel({
|
||||
@@ -94,8 +98,11 @@ function CustomLabel({
|
||||
exists,
|
||||
attributes,
|
||||
children,
|
||||
id,
|
||||
onDelete,
|
||||
...other
|
||||
}: CustomLabelProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<TreeItemLabel
|
||||
{...other}
|
||||
@@ -104,31 +111,43 @@ function CustomLabel({
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<FolderIcon className="mr-2"/>
|
||||
<FolderIcon className="mr-2" />
|
||||
<span className="font-medium text-sm text-inherit">
|
||||
{children}
|
||||
</span>
|
||||
{/* <div className="flex gap-2 ml-auto mr-3 opacity-70 text-xs">
|
||||
{attributes?.map((attr) => {
|
||||
const text =
|
||||
attr.attr === 'Extension'
|
||||
? attr.extension
|
||||
: attr.attr;
|
||||
|
||||
return (
|
||||
<span key={attr.attr} className="text-inherit">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<div className="ml-auto flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-20">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onDelete(id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t('common.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{exists !== undefined && (
|
||||
<span
|
||||
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
|
||||
>
|
||||
{exists}
|
||||
</span>
|
||||
)} */}
|
||||
</TreeItemLabel>
|
||||
);
|
||||
}
|
||||
@@ -171,6 +190,8 @@ export function Mail({
|
||||
const [pageSize, setPageSize] = React.useState(30);
|
||||
const [deleteIds, setDeleteIds] = React.useState<Set<number>>(() => new Set());
|
||||
const [selected, setSelected] = React.useState<Set<number>>(() => new Set());
|
||||
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
|
||||
|
||||
const { theme } = useTheme()
|
||||
|
||||
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
|
||||
@@ -224,6 +245,11 @@ export function Mail({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setDeleteMailboxId(id);
|
||||
setOpen('delete');
|
||||
};
|
||||
|
||||
const CustomTreeItem = React.useMemo(() => {
|
||||
return React.forwardRef(function CustomTreeItem(
|
||||
props: CustomTreeItemProps,
|
||||
@@ -256,6 +282,8 @@ export function Mail({
|
||||
<CustomLabel
|
||||
{...getLabelProps({
|
||||
exists: item.exists,
|
||||
id: item.id,
|
||||
onDelete: handleDeleteClick,
|
||||
attributes: item.attributes,
|
||||
expandable: status.expandable && status.expanded,
|
||||
})}
|
||||
@@ -269,11 +297,22 @@ export function Mail({
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<MailboxProvider value={{ open, setOpen, currentMailbox: selectedMailbox, selectedAccountId, setCurrentMailbox: setSelectedMailbox, currentEnvelope: selectedEvelope, setCurrentEnvelope: setSelectedEvelope, deleteIds, setDeleteIds, selected, setSelected }}>
|
||||
<MailboxProvider value={{
|
||||
open,
|
||||
setOpen,
|
||||
currentMailbox: selectedMailbox,
|
||||
selectedAccountId,
|
||||
setCurrentMailbox: setSelectedMailbox,
|
||||
currentEnvelope: selectedEvelope,
|
||||
setCurrentEnvelope: setSelectedEvelope,
|
||||
deleteIds,
|
||||
setDeleteIds,
|
||||
selected,
|
||||
setSelected,
|
||||
deleteMailboxId,
|
||||
setDeleteMailboxId
|
||||
}}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
@@ -301,7 +340,7 @@ export function Mail({
|
||||
)}
|
||||
>
|
||||
<Separator className="mb-2" />
|
||||
<ScrollArea className='h-[50rem] w-full pr-4 -mr-4 py-1'>
|
||||
<ScrollArea className='h-[calc(100vh-8rem)] w-full pr-4 -mr-4 py-1'>
|
||||
<div>
|
||||
<AccountSwitcher onAccountSelect={(accountId) => {
|
||||
localStorage.setItem('mailbox:selectedAccountId', `${accountId}`);
|
||||
@@ -350,12 +389,12 @@ export function Mail({
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="mt-2">
|
||||
<ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'>
|
||||
<ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1'>
|
||||
<MailList
|
||||
isLoading={isMessagesLoading}
|
||||
items={(envelopes?.items ?? []).sort((a, b) => {
|
||||
const dateA = a.internal_date;
|
||||
const dateB = b.internal_date;
|
||||
const dateA = a.date;
|
||||
const dateB = b.date;
|
||||
return dateB - dateA;
|
||||
})}
|
||||
/>
|
||||
@@ -402,6 +441,18 @@ export function Mail({
|
||||
open={open === 'move-to-trash'}
|
||||
onOpenChange={() => setOpen('move-to-trash')}
|
||||
/>
|
||||
|
||||
<RestoreMessageDialog
|
||||
key='envelope-restore'
|
||||
open={open === 'restore'}
|
||||
onOpenChange={() => setOpen('restore')}
|
||||
/>
|
||||
<MailBoxDeleteDialog
|
||||
key='mailbox-delete'
|
||||
open={open === 'delete'}
|
||||
onOpenChange={() => setOpen('delete')}
|
||||
/>
|
||||
|
||||
</MailboxProvider >
|
||||
)
|
||||
}
|
||||
106
web/src/features/mailbox/components/restore-message-dialog.tsx
Normal file
106
web/src/features/mailbox/components/restore-message-dialog.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { restore_message } from '@/api/mailbox/envelope/api'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMailboxContext } from '../context'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
|
||||
interface RestoreMessageDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function RestoreMessageDialog({
|
||||
open,
|
||||
onOpenChange
|
||||
}: RestoreMessageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { selectedAccountId, selected, setSelected } = useMailboxContext();
|
||||
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (messageIds: number[]) =>
|
||||
restore_message(selectedAccountId!, messageIds),
|
||||
onSuccess: handleRestoreSuccess,
|
||||
onError: handleRestoreError,
|
||||
});
|
||||
|
||||
function handleRestoreSuccess() {
|
||||
toast({
|
||||
title: t('restore_message.success', 'Messages restored'),
|
||||
description: t(
|
||||
'restore_message.successDesc',
|
||||
'The selected messages have been restored to the IMAP server.'
|
||||
),
|
||||
action: (
|
||||
<ToastAction altText={t('common.close')}>
|
||||
{t('common.close')}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
setSelected(new Set());
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleRestoreError(error: AxiosError) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
t('restore_message.failed', 'Failed to restore messages');
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t(
|
||||
'restore_message.failedTitle',
|
||||
'Restore failed'
|
||||
),
|
||||
description: errorMessage,
|
||||
action: (
|
||||
<ToastAction altText={t('common.tryAgain')}>
|
||||
{t('common.tryAgain')}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('restore_message.title', 'Restore messages')}
|
||||
desc={t(
|
||||
'restore_message.desc',
|
||||
'This action will append the selected messages from Bichon to their corresponding mailboxes on the IMAP server.'
|
||||
)}
|
||||
confirmText={t('restore_message.confirm', 'Restore')}
|
||||
handleConfirm={() => restoreMutation.mutate(Array.from(selected))}
|
||||
className="sm:max-w-sm"
|
||||
isLoading={restoreMutation.isPending}
|
||||
disabled={restoreMutation.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -116,11 +116,11 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
|
||||
)}
|
||||
|
||||
{allMessages
|
||||
.sort((a, b) => a.internal_date - b.internal_date)
|
||||
.sort((a, b) => a.date - b.date)
|
||||
.map((msg) => {
|
||||
const isExpanded = expandedIds.has(msg.id);
|
||||
const preview = msg.text?.slice(0, 120) + (msg.text?.length > 120 ? '...' : '');
|
||||
const date = new Date(msg.internal_date);
|
||||
const date = new Date(msg.date);
|
||||
const formattedDate = isNaN(date.getTime())
|
||||
? t('mailbox.thread.invalidDate')
|
||||
: format(date, 'yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
@@ -21,7 +21,7 @@ import React from 'react'
|
||||
import { MailboxData } from '@/api/mailbox/api'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
|
||||
export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters'
|
||||
export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters' | 'restore' | 'delete'
|
||||
|
||||
interface MailboxContextType {
|
||||
open: MailboxDialogType | null
|
||||
@@ -30,6 +30,8 @@ interface MailboxContextType {
|
||||
currentMailbox: MailboxData | undefined
|
||||
currentEnvelope: EmailEnvelope | undefined
|
||||
setCurrentMailbox: React.Dispatch<React.SetStateAction<MailboxData | undefined>>
|
||||
deleteMailboxId: string | undefined,
|
||||
setDeleteMailboxId: React.Dispatch<React.SetStateAction<string | undefined>>
|
||||
setCurrentEnvelope: React.Dispatch<React.SetStateAction<EmailEnvelope | undefined>>
|
||||
deleteIds: Set<number>
|
||||
setDeleteIds: React.Dispatch<React.SetStateAction<Set<number>>>
|
||||
|
||||
@@ -32,7 +32,6 @@ export default function Mailboxes() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ===== Top Heading ===== */}
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<Mail
|
||||
|
||||
@@ -148,7 +148,7 @@ export function Oauth2Table({ columns, data }: DataTableProps) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTablePagination table={table} showSelected={true} showPageSizeSelector={true} />
|
||||
<DataTablePagination table={table} showPageSizeSelector={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
11
web/src/features/search/account-mailbox-filter.tsx
Normal file
11
web/src/features/search/account-mailbox-filter.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AccountPopover } from './account-popover'
|
||||
import { MailboxPopover } from './mailbox-popover'
|
||||
|
||||
export function AccountMailboxFilter() {
|
||||
return (
|
||||
<>
|
||||
<AccountPopover />
|
||||
<MailboxPopover />
|
||||
</>
|
||||
)
|
||||
}
|
||||
185
web/src/features/search/account-popover.tsx
Normal file
185
web/src/features/search/account-popover.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import * as React from 'react'
|
||||
import { AtSign, ChevronDown, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
|
||||
export function AccountPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [search, setSearch] = React.useState('')
|
||||
const { minimalList = [] } = useMinimalAccountList()
|
||||
|
||||
const selectedIds: number[] = filter.account_ids ?? []
|
||||
|
||||
const toggleAccount = (id: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
const set = new Set<number>(next.account_ids ?? [])
|
||||
|
||||
if (set.has(id)) {
|
||||
set.delete(id)
|
||||
} else {
|
||||
set.add(id)
|
||||
}
|
||||
|
||||
if (set.size === 0) {
|
||||
delete next.account_ids
|
||||
delete next.mailbox_ids
|
||||
} else {
|
||||
next.account_ids = Array.from(set).sort()
|
||||
delete next.mailbox_ids
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearAccounts = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.account_ids
|
||||
delete next.mailbox_ids
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
|
||||
return minimalList
|
||||
.filter(a =>
|
||||
!q ||
|
||||
a.email.toLowerCase().includes(q) ||
|
||||
String(a.id).includes(q)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aSel = selectedIds.includes(a.id)
|
||||
const bSel = selectedIds.includes(b.id)
|
||||
|
||||
if (aSel && !bSel) return -1
|
||||
if (!aSel && bSel) return 1
|
||||
return a.id - b.id
|
||||
})
|
||||
}, [minimalList, search, selectedIds])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 gap-1.5 px-3 rounded-none',
|
||||
selectedIds.length > 0 &&
|
||||
'bg-primary/10 border-primary text-primary'
|
||||
)}
|
||||
>
|
||||
<AtSign className="h-4 w-4" />
|
||||
{t('search_accounts.label')}
|
||||
{selectedIds.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-5 px-1.5 text-xs"
|
||||
>
|
||||
{selectedIds.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="w-96 p-1">
|
||||
<div className="p-1 pb-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('search_accounts.search_placeholder')}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{!search && selectedIds.length > 0 && (
|
||||
<div className="p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAccounts}
|
||||
className="flex h-8 w-full items-center justify-start gap-2 px-2 text-xs font-medium text-destructive hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="flex-1 text-left">
|
||||
{t('search_accounts.clear_accounts')}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60 font-mono">
|
||||
({selectedIds.length})
|
||||
</span>
|
||||
</Button>
|
||||
<div className="my-1 h-px bg-border/60" />
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('search_accounts.no_accounts_found')}
|
||||
</p>
|
||||
) : (
|
||||
filtered.map(account => {
|
||||
const checked = selectedIds.includes(account.id)
|
||||
const id = `account-${account.id}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
onClick={() => toggleAccount(account.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
|
||||
'hover:bg-accent transition-colors'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
toggleAccount(account.id)
|
||||
}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 truncate text-xs cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">
|
||||
{account.email}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
#{account.id}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -25,18 +25,17 @@ import { useState, useEffect } from 'react';
|
||||
import { useAvailableTags } from '@/hooks/use-available-tags';
|
||||
import { useUpdateTags } from '@/hooks/use-update-tags';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { EmailEnvelope } from '@/api';
|
||||
import { validateTag } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchContext } from './context';
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentEnvelope: EmailEnvelope | undefined
|
||||
}
|
||||
|
||||
export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
|
||||
export function EditTagsDialog({ open, onOpenChange }: Props) {
|
||||
const { tags: availableTags } = useAvailableTags();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate, isPending } = useUpdateTags();
|
||||
@@ -45,6 +44,8 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentEnvelope } = useSearchContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (open && currentEnvelope) {
|
||||
setSelectedTags(currentEnvelope.tags || []);
|
||||
|
||||
53
web/src/features/search/attachment-filter.tsx
Normal file
53
web/src/features/search/attachment-filter.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Paperclip, Check } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchContext } from './context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AttachmentFilter() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
|
||||
const hasAttachment = filter?.has_attachment === true
|
||||
|
||||
const toggleAttachment = () => {
|
||||
setFilter((prev) => {
|
||||
const next = { ...prev }
|
||||
if (next.has_attachment) {
|
||||
delete next.has_attachment
|
||||
} else {
|
||||
next.has_attachment = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={toggleAttachment}
|
||||
className={cn(
|
||||
"h-8 px-3 gap-2 transition-all rounded-none flex-shrink-0",
|
||||
hasAttachment
|
||||
? "bg-primary/10 border-primary text-primary hover:bg-primary/20 hover:text-primary z-10"
|
||||
: "text-muted-foreground border-r-0"
|
||||
)}
|
||||
>
|
||||
<Paperclip
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
hasAttachment ? "opacity-100" : "opacity-60"
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className="text-xs font-medium">
|
||||
{t('mail.attachments')}
|
||||
</span>
|
||||
|
||||
{hasAttachment && (
|
||||
<Check className="h-3 w-3 ml-0.5 stroke-[3px] animate-in zoom-in duration-200" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
217
web/src/features/search/contact-popover.tsx
Normal file
217
web/src/features/search/contact-popover.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { useSearchContext } from "./context"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Check, ChevronDown, Mail, X } from "lucide-react"
|
||||
import React from "react"
|
||||
import { useContacts } from "@/hooks/use-contacts"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
|
||||
export function MailFilterPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const fields = ['from', 'to', 'cc', 'bcc'] as const
|
||||
|
||||
const activeCount = fields.filter(k => !!filter[k]).length
|
||||
|
||||
const updateFilter = (field: string, email: string | undefined) => {
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
[field]: email
|
||||
}))
|
||||
}
|
||||
|
||||
const resetAll = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
fields.forEach(k => delete next[k])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5 transition-colors',
|
||||
activeCount > 0 && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||
)}
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 opacity-60" />
|
||||
<span>
|
||||
{activeCount > 0
|
||||
? t('search_contacts.label_with_count', { count: activeCount })
|
||||
: t('search_contacts.label')}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-fit min-w-[280px] max-w-[90vw] sm:max-w-[min(90vw,500px)] p-0 flex flex-col divide-y divide-border shadow-xl"
|
||||
>
|
||||
<div className="flex flex-col bg-muted/20 divide-y divide-border">
|
||||
{fields.map((field) => (
|
||||
<ContactSelectorField
|
||||
key={field}
|
||||
label={field}
|
||||
value={filter[field] as string | undefined}
|
||||
onSelect={(email) => updateFilter(field, email)}
|
||||
onReset={() => updateFilter(field, undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeCount > 0 && (
|
||||
<div className="px-1 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={resetAll}
|
||||
className="flex h-8 w-full items-center justify-start gap-2 px-2 text-xs font-medium text-destructive hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="flex-1 text-left">
|
||||
{t('search_contacts.reset_all')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function ContactSelectorField({
|
||||
label,
|
||||
value,
|
||||
onSelect,
|
||||
onReset
|
||||
}: {
|
||||
label: string
|
||||
value?: string
|
||||
onSelect: (email: string | undefined) => void
|
||||
onReset: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [searchTerm, setSearchTerm] = React.useState("")
|
||||
const { contacts, isLoading } = useContacts(searchTerm)
|
||||
|
||||
const handleToggle = (email: string) => {
|
||||
if (value === email) {
|
||||
onReset()
|
||||
} else {
|
||||
onSelect(email)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group flex items-center justify-between w-full px-4 py-3 hover:bg-background transition-all text-left relative",
|
||||
"min-h-[52px]",
|
||||
value && "bg-background/60 hover:bg-background/80"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start pr-6">
|
||||
<span className="text-[10px] font-bold uppercase opacity-50 tracking-tight leading-none">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 truncate max-w-[320px]",
|
||||
value
|
||||
? "text-xs font-semibold text-primary"
|
||||
: "text-xs text-muted-foreground/90"
|
||||
)}
|
||||
>
|
||||
{value || t('search_contacts.any')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReset()
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="p-0 w-auto min-w-[300px] max-w-[420px] shadow-2xl border-border/50"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t('search_contacts.search_placeholder', { field: label })}
|
||||
className="h-9"
|
||||
value={searchTerm}
|
||||
onValueChange={setSearchTerm}
|
||||
/>
|
||||
<CommandList className="max-h-[360px]">
|
||||
{isLoading && (
|
||||
<div className="p-4 text-xs text-center opacity-50">{t('search_contacts.loading')}</div>
|
||||
)}
|
||||
<CommandEmpty>{t('search_contacts.no_contact_found')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{contacts.slice(0, 100).map((email) => (
|
||||
<CommandItem
|
||||
key={email}
|
||||
onSelect={() => handleToggle(email)}
|
||||
className="flex items-center justify-between py-2.5 px-3 cursor-pointer whitespace-nowrap gap-4 text-xs"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium">
|
||||
{email.split('@')[0]}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[360px]">
|
||||
{email}
|
||||
</span>
|
||||
</div>
|
||||
{value === email && (
|
||||
<Check className="h-4 w-4 text-primary shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
{contacts.length > 100 && (
|
||||
<div className="px-3 py-2 text-[10px] text-center text-muted-foreground border-t border-border/50">
|
||||
{t('search_contacts.showing_limit', { total: contacts.length })}
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -19,8 +19,9 @@
|
||||
|
||||
import React from 'react'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { SortingState } from '@tanstack/react-table'
|
||||
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form'
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore'
|
||||
|
||||
interface SearchContextType {
|
||||
open: SearchDialogType | null
|
||||
@@ -32,6 +33,11 @@ interface SearchContextType {
|
||||
selected: Map<number, Set<number>>
|
||||
setSelected: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
|
||||
selectedTags: string[]
|
||||
sorting: SortingState
|
||||
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
|
||||
filter: Record<string, any>
|
||||
setFilter: React.Dispatch<React.SetStateAction<Record<string, any>>>
|
||||
handleTagToggle: (tag: string) => void
|
||||
}
|
||||
|
||||
const SearchContext = React.createContext<SearchContextType | null>(null)
|
||||
|
||||
38
web/src/features/search/filter-reset.tsx
Normal file
38
web/src/features/search/filter-reset.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useSearchContext } from "./context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function FilterResetButton() {
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const { t } = useTranslation()
|
||||
const { q, ...restFilters } = filter;
|
||||
|
||||
const activeFiltersCount = Object.keys(restFilters).filter(key => {
|
||||
const value = restFilters[key];
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
}).length;
|
||||
|
||||
if (activeFiltersCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFilter(q ? { q } : {})}
|
||||
className={cn(
|
||||
"h-8 px-2 text-xs gap-1.5 font-normal",
|
||||
"text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
)}
|
||||
title={t('search_reset.tooltip')}
|
||||
>
|
||||
<span>{t('search_reset.label')}</span>
|
||||
<div className="flex items-center justify-center w-4 h-4 rounded-full bg-muted-foreground/20 text-[10px]">
|
||||
{activeFiltersCount}
|
||||
</div>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -21,23 +21,18 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FixedHeader } from '@/components/layout/fixed-header';
|
||||
import { Main } from '@/components/layout/main';
|
||||
import { useSearchMessages } from '@/hooks/use-search-messages';
|
||||
import { SearchFormDialog } from './search-form';
|
||||
import { EnvelopeListPagination } from '@/components/pagination';
|
||||
import { MailList } from './mail-list';
|
||||
import React from 'react';
|
||||
import { EmailEnvelope } from '@/api';
|
||||
import { Filter, SearchIcon } from 'lucide-react';
|
||||
import { MailDisplayDrawer } from './mail-display-dialog';
|
||||
import { EnvelopeDeleteDialog } from './delete-dialog';
|
||||
import SearchProvider, { SearchDialogType } from './context';
|
||||
import useDialogState from '@/hooks/use-dialog-state';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EnvelopeTags } from './tag-facet';
|
||||
import { EditTagsDialog } from './add-tag-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import { RestoreMessageDialog } from './restore-message-dialog';
|
||||
import { MailListTable } from './mail-list-table';
|
||||
import { SortingState } from '@tanstack/react-table';
|
||||
|
||||
export default function Search() {
|
||||
const { t } = useTranslation()
|
||||
@@ -46,20 +41,21 @@ export default function Search() {
|
||||
const [toDelete, setToDelete] = React.useState<Map<number, Set<number>>>(new Map());
|
||||
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
|
||||
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
|
||||
|
||||
const {
|
||||
emails,
|
||||
total,
|
||||
totalPages,
|
||||
isLoading,
|
||||
isFetching,
|
||||
page,
|
||||
pageSize,
|
||||
setPage,
|
||||
setPageSize,
|
||||
onSubmit,
|
||||
reset,
|
||||
filter
|
||||
setSortBy,
|
||||
setSortOrder,
|
||||
filter,
|
||||
setFilter
|
||||
} = useSearchMessages();
|
||||
|
||||
const handleSetPageSize = (pageSize: number) => {
|
||||
@@ -67,12 +63,6 @@ export default function Search() {
|
||||
setPageSize(pageSize)
|
||||
}
|
||||
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setSelectedTags([]);
|
||||
};
|
||||
|
||||
const handleTagToggle = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag)
|
||||
@@ -85,45 +75,35 @@ export default function Search() {
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<SearchProvider value={{ open, setOpen, currentEnvelope: selectedEnvelope, selectedTags, setCurrentEnvelope: setSelectedEnvelope, toDelete, setToDelete, selected, setSelected }}>
|
||||
<SearchProvider
|
||||
value={{
|
||||
open,
|
||||
setOpen,
|
||||
currentEnvelope: selectedEnvelope,
|
||||
selectedTags,
|
||||
setCurrentEnvelope: setSelectedEnvelope,
|
||||
toDelete,
|
||||
setToDelete,
|
||||
selected,
|
||||
setSelected,
|
||||
sorting,
|
||||
setSorting,
|
||||
filter,
|
||||
setFilter,
|
||||
handleTagToggle
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto w-full px-4">
|
||||
<div className="mb-4 lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
{t('search.tagFilter')}
|
||||
{selectedTags.length > 0 && ` (${selectedTags.length})`}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-80">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('search.tagFilter')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6">
|
||||
<EnvelopeTags
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
{/* <aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<EnvelopeTags
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</aside> */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
<Button size="sm" onClick={() => setOpen("search-form")}>
|
||||
<SearchIcon className="mr-2 h-4 w-4" />
|
||||
{t('common.search')}
|
||||
</Button>
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
@@ -135,31 +115,16 @@ export default function Search() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{total === 0 && <div className="flex h-[750px] shrink-0 items-center justify-center rounded-md border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<img
|
||||
src={Logo}
|
||||
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
|
||||
alt="Bichon Logo"
|
||||
/>
|
||||
<h3 className="mt-4 text-lg font-semibold">{t('search.noEmailsFound')}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
{Object.keys(filter).length === 0
|
||||
? t('search.startSearching')
|
||||
: t('search.adjustSearch')}
|
||||
</p>
|
||||
</div>
|
||||
</div>}
|
||||
{total > 0 && <ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'>
|
||||
<MailList
|
||||
isLoading={isLoading}
|
||||
items={emails}
|
||||
onEnvelopeChanged={(envelope) => {
|
||||
setOpen('display');
|
||||
setSelectedEnvelope(envelope);
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>}
|
||||
<MailListTable
|
||||
isLoading={isLoading}
|
||||
items={emails}
|
||||
onEnvelopeChanged={(envelope) => {
|
||||
setOpen('display');
|
||||
setSelectedEnvelope(envelope);
|
||||
}}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
/>
|
||||
{total > 0 && <EnvelopeListPagination
|
||||
totalItems={total}
|
||||
hasNextPage={() => page < totalPages}
|
||||
@@ -187,17 +152,16 @@ export default function Search() {
|
||||
<EditTagsDialog
|
||||
key='edit-tags-dialog'
|
||||
open={open === 'edit-tags'}
|
||||
onOpenChange={() => setOpen('edit-tags')} currentEnvelope={selectedEnvelope}
|
||||
onOpenChange={() => setOpen('edit-tags')}
|
||||
/>
|
||||
|
||||
<SearchFormDialog
|
||||
key='search-form-dialog'
|
||||
onSubmit={onSubmit} isLoading={isLoading || isFetching} reset={handleReset}
|
||||
open={open === 'search-form'}
|
||||
onOpenChange={() => setOpen('search-form')}
|
||||
<RestoreMessageDialog
|
||||
key='restore-mail-dialog'
|
||||
open={open === 'restore'}
|
||||
onOpenChange={() => setOpen('restore')}
|
||||
/>
|
||||
</SearchProvider>
|
||||
</Main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
333
web/src/features/search/mail-list-table.tsx
Executable file
333
web/src/features/search/mail-list-table.tsx
Executable file
@@ -0,0 +1,333 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { MessageSquareText, Paperclip } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { EmailEnvelope } from "@/api"
|
||||
import { useSearchContext } from "./context"
|
||||
import { MailBulkActions } from "./bulk-actions"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import LongText from "@/components/long-text"
|
||||
import { DataTableColumnHeader } from "./table/data-table-column-header"
|
||||
import { SearchTable } from "./table/table"
|
||||
import { DataTableRowActions } from "./table/data-table-row-actions"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { DataTableToolbar } from "./table/toolbar"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
isLoading: boolean
|
||||
onEnvelopeChanged: (envelope: EmailEnvelope) => void
|
||||
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
||||
setSortOrder: (value: "desc" | "asc") => void
|
||||
}
|
||||
|
||||
export function MailListTable({
|
||||
items,
|
||||
isLoading,
|
||||
onEnvelopeChanged,
|
||||
setSortBy,
|
||||
setSortOrder
|
||||
}: MailListProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS
|
||||
const { selected, setSelected } = useSearchContext()
|
||||
|
||||
const columns: ColumnDef<EmailEnvelope>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: () => (
|
||||
<Checkbox
|
||||
checked={
|
||||
totalSelected === items.length && items.length > 0
|
||||
? true
|
||||
: totalSelected > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={handleToggleAll}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={hasSelected(row.original.account_id, row.original.id)}
|
||||
onCheckedChange={() => toggleSelected(row.original.account_id, row.original.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
),
|
||||
meta: { className: 'text-left text-sm' },
|
||||
minSize: 25,
|
||||
maxSize: 25,
|
||||
},
|
||||
{
|
||||
accessorKey: "account_email",
|
||||
header: t('search.account'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.account_email}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 150,
|
||||
maxSize: 156,
|
||||
},
|
||||
{
|
||||
accessorKey: "mailbox_name",
|
||||
header: t('search.mailbox'),
|
||||
cell: ({ row }) => {
|
||||
const mailbox = row.original.mailbox_name
|
||||
const tags = row.original.tags ?? []
|
||||
|
||||
if (!mailbox) return null
|
||||
|
||||
const visible = tags.slice(0, 3)
|
||||
const rest = tags.length - visible.length
|
||||
|
||||
const fullTags = tags.join(' · ')
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex flex-col leading-tight max-w-[130px] cursor-default">
|
||||
<span className="text-xs truncate">
|
||||
{mailbox}
|
||||
</span>
|
||||
|
||||
{visible.length > 0 && (
|
||||
<span className="text-[10px] text-primary/80 truncate">
|
||||
{visible.join(' · ')}
|
||||
{rest > 0 && ` · +${rest}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="max-w-xs"
|
||||
>
|
||||
<div className="text-xs font-medium mb-1">
|
||||
{mailbox}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground break-words">
|
||||
{fullTags}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 116,
|
||||
maxSize: 116,
|
||||
},
|
||||
{
|
||||
accessorKey: "from",
|
||||
header: t('search.from'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.from}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 150,
|
||||
maxSize: 156,
|
||||
},
|
||||
{
|
||||
accessorKey: "to",
|
||||
header: t('search.to'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.to.join(", ")}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 150,
|
||||
maxSize: 156,
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: t('search.subject'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 450,
|
||||
maxSize: 456,
|
||||
},
|
||||
{
|
||||
id: "text_preview",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const text = row.original.text
|
||||
|
||||
if (!text) return null
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200} closeDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-primary transition"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MessageSquareText size={16} />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="max-w-[520px] max-h-[420px] overflow-auto whitespace-pre-wrap text-xs leading-relaxed"
|
||||
>
|
||||
{text}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
},
|
||||
meta: { className: "text-center max-w-[80px]" },
|
||||
minSize: 36,
|
||||
maxSize: 36,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "attachment_count",
|
||||
header: () => <Paperclip size={16} />,
|
||||
cell: ({ row }) => <span className='text-xs'>{(row.original.attachments ?? []).length}</span>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 40,
|
||||
maxSize: 40
|
||||
},
|
||||
{
|
||||
accessorKey: 'size',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('search.size')} />
|
||||
),
|
||||
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('search.date')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.date)
|
||||
const title = format(date, 'yyyy-MM-dd HH:mm:ss')
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='text-xs whitespace-nowrap'>
|
||||
{formatDistanceToNow(date, { addSuffix: true, locale })}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{title}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('users.columns.actions'),
|
||||
cell: DataTableRowActions,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 50,
|
||||
maxSize: 60,
|
||||
},
|
||||
]
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const total = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
|
||||
|
||||
if (total === items.length && items.length > 0) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
for (const item of items) {
|
||||
const set = new Set(next.get(item.account_id) || [])
|
||||
set.add(item.id)
|
||||
next.set(item.account_id, set)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelected = (accountId: number, mailId: number) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
const set = new Set(next.get(accountId) || [])
|
||||
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId)
|
||||
if (set.size === 0) next.delete(accountId)
|
||||
else next.set(accountId, set)
|
||||
} else {
|
||||
set.add(mailId)
|
||||
next.set(accountId, set)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const totalSelected = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
|
||||
|
||||
const hasSelected = (accountId: number, mailId: number) => selected.get(accountId)?.has(mailId) ?? false
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Skeleton className="h-3 w-3" />
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-2.5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
onRowClick={(e, row) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('input[type="checkbox"], button')) return
|
||||
onEnvelopeChanged(row.original)
|
||||
}}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
>
|
||||
{(table) => {
|
||||
return <DataTableToolbar table={table} />
|
||||
}}
|
||||
|
||||
</SearchTable>
|
||||
{totalSelected > 0 && <MailBulkActions />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export function MailList({
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{totalSelected > 0
|
||||
? `${totalSelected} ${t('common.selected')}`
|
||||
? `${t('search.bulkActions.selected', { count: totalSelected })}`
|
||||
: t('common.selectAll')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -243,7 +243,17 @@ export function MailList({
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('search.editTag')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentEnvelope(item);
|
||||
setOpen("restore");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore } from 'lucide-react';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -84,6 +84,34 @@ const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines
|
||||
);
|
||||
};
|
||||
|
||||
const getFileConfig = (mimeType: string) => {
|
||||
const type = mimeType.toLowerCase();
|
||||
if (type.includes('pdf')) {
|
||||
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
|
||||
}
|
||||
if (type.includes('image/')) {
|
||||
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
|
||||
}
|
||||
if (type.includes('audio/')) {
|
||||
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
|
||||
}
|
||||
|
||||
if (type.includes('video/')) {
|
||||
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
|
||||
}
|
||||
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
|
||||
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
|
||||
}
|
||||
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
|
||||
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
|
||||
}
|
||||
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
|
||||
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
|
||||
}
|
||||
|
||||
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
|
||||
};
|
||||
|
||||
export function MailMessageView({
|
||||
envelope,
|
||||
showActions = true,
|
||||
@@ -184,7 +212,6 @@ export function MailMessageView({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header Info */}
|
||||
{showHeader && <div className="grid gap-1 text-xs">
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">{t('mail.account')}:</span>
|
||||
@@ -216,7 +243,7 @@ export function MailMessageView({
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
{/* Action Bar */}
|
||||
|
||||
{showActions && (
|
||||
<>
|
||||
<div className="flex items-center mt-2 space-x-2">
|
||||
@@ -257,7 +284,6 @@ export function MailMessageView({
|
||||
</>
|
||||
)}
|
||||
{showAttachments && <Separator className="my-2" />}
|
||||
{/* Attachments */}
|
||||
{showAttachments && (
|
||||
<div className="mb-2">
|
||||
{loading ? (
|
||||
@@ -265,13 +291,27 @@ export function MailMessageView({
|
||||
) : attachments && attachments.length > 0 ? (
|
||||
(() => {
|
||||
const nonInline = attachments.filter((a) => !a.inline);
|
||||
|
||||
return nonInline.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{nonInline.map((attachment, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className="flex items-center space-x-8">
|
||||
<span className="truncate text-xs">{attachment.filename}</span>
|
||||
<span className="text-xs px-2 py-1 rounded">[{attachment.file_type}]</span>
|
||||
{nonInline.map((attachment, i) => {
|
||||
const { icon, color } = getFileConfig(attachment.file_type);
|
||||
return <div key={i} className="flex items-center">
|
||||
<div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
|
||||
<div className={`flex-shrink-0 ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
|
||||
<span
|
||||
className="truncate text-xs font-medium text-foreground/90"
|
||||
title={attachment.filename}
|
||||
>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase tracking-tighter group-hover:text-foreground transition-colors">
|
||||
{attachment.file_type.split('/').pop()?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 ml-auto">
|
||||
<span className="text-gray-500 text-xs shrink-0">
|
||||
@@ -290,7 +330,7 @@ export function MailMessageView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 text-xs italic">
|
||||
@@ -304,7 +344,6 @@ export function MailMessageView({
|
||||
</div>
|
||||
)}
|
||||
{showAttachments && <Separator className="mb-2" />}
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
|
||||
263
web/src/features/search/mailbox-popover.tsx
Normal file
263
web/src/features/search/mailbox-popover.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronDown, Folders, X } from 'lucide-react'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { useSearchContext } from './context'
|
||||
|
||||
export function MailboxPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const { minimalList = [] } = useMinimalAccountList()
|
||||
|
||||
const [search, setSearch] = React.useState('')
|
||||
|
||||
const accountIds: number[] = filter.account_ids ?? []
|
||||
const selectedMailboxIds: number[] = filter.mailbox_ids ?? []
|
||||
|
||||
const { mailboxes, isLoading } = useQueries({
|
||||
queries: accountIds.map(id => ({
|
||||
queryKey: ['search-mailboxes', id],
|
||||
queryFn: () => list_mailboxes(id, false),
|
||||
enabled: accountIds.length > 0,
|
||||
})),
|
||||
combine: results => ({
|
||||
mailboxes: results.flatMap(r => r.data ?? []),
|
||||
isLoading: results.some(r => r.isLoading),
|
||||
}),
|
||||
})
|
||||
|
||||
const toggleMailbox = (id: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
const set = new Set<number>(next.mailbox_ids ?? [])
|
||||
|
||||
set.has(id) ? set.delete(id) : set.add(id)
|
||||
|
||||
const ids = Array.from(set)
|
||||
|
||||
if (ids.length === 0) delete next.mailbox_ids
|
||||
else next.mailbox_ids = ids
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearAllMailboxes = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.mailbox_ids
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const grouped = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const map = new Map<number, MailboxData[]>()
|
||||
|
||||
for (const mb of mailboxes) {
|
||||
if (q && !mb.name.toLowerCase().includes(q)) continue
|
||||
if (!map.has(mb.account_id)) map.set(mb.account_id, [])
|
||||
map.get(mb.account_id)!.push(mb)
|
||||
}
|
||||
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => {
|
||||
const aSel = selectedMailboxIds.includes(a.id)
|
||||
const bSel = selectedMailboxIds.includes(b.id)
|
||||
if (aSel && !bSel) return -1
|
||||
if (!aSel && bSel) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
}, [mailboxes, search, selectedMailboxIds])
|
||||
|
||||
const defaultOpen = grouped
|
||||
.filter(([, boxes]) =>
|
||||
boxes.some(m => selectedMailboxIds.includes(m.id))
|
||||
)
|
||||
.map(([id]) => id.toString())
|
||||
|
||||
const getAccountEmail = (id: number) =>
|
||||
minimalList.find(a => a.id === id)?.email ?? ''
|
||||
|
||||
const disabled = accountIds.length === 0
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5',
|
||||
selectedMailboxIds.length > 0 &&
|
||||
'bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<Folders className="h-4 w-4" />
|
||||
{t('search_mailbox.label')}
|
||||
{selectedMailboxIds.length > 0 && (
|
||||
<span className="ml-1 text-xs opacity-70">
|
||||
{selectedMailboxIds.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="min-w-[260px] w-fit max-w-[620px] p-1">
|
||||
<div className="p-1 pb-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('search_mailbox.search_placeholder')}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{selectedMailboxIds.length > 0 && (
|
||||
<div className="px-1 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllMailboxes}
|
||||
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="mr-2 h-3.5 w-3.5" />
|
||||
{t('search_mailbox.clear_mailboxes')} ({selectedMailboxIds.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{disabled ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('search_mailbox.select_account_first')}
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<div className="space-y-2 p-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 rounded bg-muted animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : grouped.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('search_mailbox.no_mailbox_found')}
|
||||
</p>
|
||||
) : (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={defaultOpen}
|
||||
className="space-y-1"
|
||||
>
|
||||
{grouped.map(([accountId, boxes]) => {
|
||||
const selectedCount = boxes.filter(b =>
|
||||
selectedMailboxIds.includes(b.id)
|
||||
).length
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
key={accountId}
|
||||
value={accountId.toString()}
|
||||
>
|
||||
<AccordionTrigger className="text-xs px-2 py-1.5">
|
||||
<span className="truncate">
|
||||
{getAccountEmail(accountId)}
|
||||
</span>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<span className="ml-2 text-[10px] text-primary">
|
||||
{selectedCount}
|
||||
</span>
|
||||
)}
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent>
|
||||
<div className="space-y-0.5">
|
||||
{boxes.map(mailbox => {
|
||||
const checked =
|
||||
selectedMailboxIds.includes(mailbox.id)
|
||||
|
||||
return (
|
||||
<TooltipProvider key={mailbox.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={() =>
|
||||
toggleMailbox(mailbox.id)
|
||||
}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
|
||||
'hover:bg-accent transition-colors',
|
||||
checked &&
|
||||
'bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
toggleMailbox(mailbox.id)
|
||||
}
|
||||
onClick={e =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
/>
|
||||
|
||||
<span className="text-xs truncate">
|
||||
{mailbox.name}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent side="right">
|
||||
<div className="text-sm break-all">
|
||||
{mailbox.name}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
194
web/src/features/search/more-filters-popover.tsx
Normal file
194
web/src/features/search/more-filters-popover.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import * as React from "react"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { ListFilter } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchContext } from "./context"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SIZES = {
|
||||
tiny: { min: undefined, max: 15 * 1024 },
|
||||
small: { min: undefined, max: 2 * 1024 * 1024 },
|
||||
medium: { min: 2 * 1024 * 1024, max: 10 * 1024 * 1024 },
|
||||
large: { min: 10 * 1024 * 1024, max: 20 * 1024 * 1024 },
|
||||
huge: { min: 20 * 1024 * 1024, max: undefined },
|
||||
};
|
||||
|
||||
const getPresetFromSize = (min?: number, max?: number) => {
|
||||
if (min === SIZES.huge.min) return 'huge';
|
||||
if (min === SIZES.large.min && max === SIZES.large.max) return 'large';
|
||||
if (min === SIZES.medium.min && max === SIZES.medium.max) return 'medium';
|
||||
if (!min && max === SIZES.small.max) return 'small';
|
||||
if (!min && max === SIZES.tiny.max) return 'tiny';
|
||||
return 'any';
|
||||
};
|
||||
|
||||
export function MoreFiltersPopover() {
|
||||
const { t } = useTranslation();
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [localState, setLocalState] = React.useState({
|
||||
attachment_name: filter?.attachment_name || '',
|
||||
message_id: filter?.message_id || '',
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
has_attachment: filter?.has_attachment || false
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setLocalState({
|
||||
attachment_name: filter?.attachment_name || '',
|
||||
message_id: filter?.message_id || '',
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
has_attachment: filter?.has_attachment || false
|
||||
});
|
||||
}
|
||||
}, [open, filter]);
|
||||
|
||||
const handleApply = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev };
|
||||
|
||||
if (localState.attachment_name) next.attachment_name = localState.attachment_name;
|
||||
else delete next.attachment_name;
|
||||
|
||||
if (localState.message_id) next.message_id = localState.message_id;
|
||||
else delete next.message_id;
|
||||
|
||||
if (localState.has_attachment) next.has_attachment = true;
|
||||
else delete next.has_attachment;
|
||||
|
||||
const range = SIZES[localState.size_preset as keyof typeof SIZES] || { min: undefined, max: undefined };
|
||||
if (range.min) next.min_size = range.min; else delete next.min_size;
|
||||
if (range.max) next.max_size = range.max; else delete next.max_size;
|
||||
|
||||
return next;
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const activeCount = [
|
||||
filter?.attachment_name,
|
||||
filter?.min_size,
|
||||
filter?.max_size,
|
||||
filter?.message_id,
|
||||
filter?.has_attachment
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-2 px-3 rounded-none border-l-0",
|
||||
activeCount > 0 && "bg-primary/10 border-primary text-primary"
|
||||
)}
|
||||
>
|
||||
<ListFilter className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{t('search_more.trigger_label')}</span>
|
||||
{activeCount > 0 && (
|
||||
<Badge className="ml-1 h-4 px-1 text-[10px] bg-primary text-primary-foreground border-none rounded-sm">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" className="w-72 p-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-medium">{t('search_more.title')}</h4>
|
||||
{activeCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 text-[10px] text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev };
|
||||
delete next.attachment_name;
|
||||
delete next.min_size;
|
||||
delete next.max_size;
|
||||
delete next.message_id;
|
||||
delete next.has_attachment;
|
||||
return next;
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('search_more.reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center space-x-2 px-1">
|
||||
<Checkbox
|
||||
id="has_attachment"
|
||||
checked={localState.has_attachment}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocalState(prev => ({ ...prev, has_attachment: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="has_attachment"
|
||||
className="text-xs font-normal cursor-pointer select-none"
|
||||
>
|
||||
{t('search_more.has_attachment')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.attachment_name_label')}</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={localState.attachment_name}
|
||||
onChange={(e) => setLocalState(prev => ({ ...prev, attachment_name: e.target.value }))}
|
||||
placeholder={t('search_more.attachment_name_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.message_size_label')}</Label>
|
||||
<Select
|
||||
value={localState.size_preset}
|
||||
onValueChange={(v) => setLocalState(prev => ({ ...prev, size_preset: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(SIZES).concat('any').map((key) => (
|
||||
<SelectItem key={key} className="text-xs" value={key}>
|
||||
{t(`search_more.size_presets.${key}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.message_id_label')}</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={localState.message_id}
|
||||
onChange={(e) => setLocalState(prev => ({ ...prev, message_id: e.target.value }))}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground opacity-70 leading-tight">
|
||||
{t('search_more.message_id_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button size="sm" className="w-full h-8 text-xs mt-2" onClick={handleApply}>
|
||||
{t('search_more.apply')}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
105
web/src/features/search/restore-message-dialog.tsx
Normal file
105
web/src/features/search/restore-message-dialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { restore_message } from '@/api/mailbox/envelope/api'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useSearchContext } from './context'
|
||||
|
||||
interface RestoreMessageDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function RestoreMessageDialog({
|
||||
open,
|
||||
onOpenChange
|
||||
}: RestoreMessageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentEnvelope } = useSearchContext()
|
||||
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
restore_message(currentEnvelope!.account_id, [currentEnvelope!.id]),
|
||||
onSuccess: handleRestoreSuccess,
|
||||
onError: handleRestoreError,
|
||||
});
|
||||
|
||||
function handleRestoreSuccess() {
|
||||
toast({
|
||||
title: t('restore_message.success', 'Messages restored'),
|
||||
description: t(
|
||||
'restore_message.successDesc',
|
||||
'The selected messages have been restored to the IMAP server.'
|
||||
),
|
||||
action: (
|
||||
<ToastAction altText={t('common.close')}>
|
||||
{t('common.close')}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleRestoreError(error: AxiosError) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
t('restore_message.failed', 'Failed to restore messages');
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t(
|
||||
'restore_message.failedTitle',
|
||||
'Restore failed'
|
||||
),
|
||||
description: errorMessage,
|
||||
action: (
|
||||
<ToastAction altText={t('common.tryAgain')}>
|
||||
{t('common.tryAgain')}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('restore_message.title', 'Restore messages')}
|
||||
desc={t(
|
||||
'restore_message.desc',
|
||||
'This action will append the selected messages from Bichon to their corresponding mailboxes on the IMAP server.'
|
||||
)}
|
||||
confirmText={t('restore_message.confirm', 'Restore')}
|
||||
handleConfirm={() => restoreMutation.mutate()}
|
||||
className="sm:max-w-sm"
|
||||
isLoading={restoreMutation.isPending}
|
||||
disabled={restoreMutation.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { DatePicker } from "@/components/date-picker";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ChevronDown, ChevronUp, Filter, RotateCcw } from "lucide-react";
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { VirtualizedSelect } from "@/components/virtualized-select";
|
||||
import useMinimalAccountList from "@/hooks/use-minimal-account-list";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { list_mailboxes, MailboxData } from "@/api/mailbox/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchContext } from "./context";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const getSearchFilterSchema = (t: (key: string) => string) => z.object({
|
||||
text: z.string().optional().or(z.literal("")),
|
||||
from: z
|
||||
.string()
|
||||
.email({ message: t('validation.invalidEmail') })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
to: z
|
||||
.string()
|
||||
.email({ message: t('validation.invalidEmail') })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
cc: z
|
||||
.string()
|
||||
.email({ message: t('validation.invalidEmail') })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
bcc: z
|
||||
.string()
|
||||
.email({ message: t('validation.invalidEmail') })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
has_attachment: z.boolean().optional(),
|
||||
attachment_name: z.string().optional().or(z.literal("")),
|
||||
since: z.date().optional(),
|
||||
before: z.date().optional(),
|
||||
account_id: z.number().optional().or(z.literal("")),
|
||||
mailbox_id: z.number().optional().or(z.literal("")),
|
||||
size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large']).optional(),
|
||||
message_id: z.string().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
type SearchFilterForm = z.infer<ReturnType<typeof getSearchFilterSchema>>;
|
||||
|
||||
|
||||
interface Props {
|
||||
onSubmit: (values: Record<string, any>) => void,
|
||||
isLoading: boolean,
|
||||
reset: () => void,
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const isEmptyValue = (value: any): boolean => {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (value === '') return true;
|
||||
if (typeof value === 'string' && value.trim() === '') return true;
|
||||
if (typeof value === 'number' && isNaN(value)) return true;
|
||||
if (value === false) return true;
|
||||
if (value === 0) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const cleanEmpty = <T extends Record<string, any>>(obj: T): Partial<T> => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_, value]) => !isEmptyValue(value))
|
||||
) as Partial<T>;
|
||||
};
|
||||
|
||||
function withSizePreset(values: Record<string, any>) {
|
||||
const { size_preset, ...rest } = values;
|
||||
|
||||
switch (size_preset) {
|
||||
case 'tiny':
|
||||
return { ...rest, max_size: 15 * 1024 };
|
||||
case 'small':
|
||||
return { ...rest, max_size: 2 * 1024 * 1024 };
|
||||
case 'medium':
|
||||
return { ...rest, max_size: 20 * 1024 * 1024 };
|
||||
case 'large':
|
||||
return { ...rest, min_size: 20 * 1024 * 1024 };
|
||||
default:
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
|
||||
export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<number | undefined>(undefined);
|
||||
const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList();
|
||||
const { selectedTags } = useSearchContext();
|
||||
|
||||
const searchFilterSchema = getSearchFilterSchema(t)
|
||||
const form = useForm<SearchFilterForm>({
|
||||
resolver: zodResolver(searchFilterSchema),
|
||||
defaultValues: {
|
||||
text: "",
|
||||
from: "",
|
||||
to: "",
|
||||
cc: "",
|
||||
bcc: "",
|
||||
attachment_name: "",
|
||||
message_id: "",
|
||||
size_preset: 'any',
|
||||
has_attachment: false,
|
||||
since: undefined,
|
||||
before: undefined,
|
||||
account_id: undefined,
|
||||
mailbox_id: undefined,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
|
||||
queryKey: ['search-account-mailboxes', `${selectedAccountId}`],
|
||||
queryFn: () => list_mailboxes(selectedAccountId!, false),
|
||||
enabled: !!selectedAccountId,
|
||||
})
|
||||
|
||||
|
||||
const mailboxesOptions = mailboxes?.map((mailbox: MailboxData) => ({
|
||||
value: mailbox.id.toString(),
|
||||
label: mailbox.name,
|
||||
})) || [];
|
||||
|
||||
|
||||
const handleSubmit = (values: Record<string, any>) => {
|
||||
let cleaned = cleanEmpty(values);
|
||||
const payload = withSizePreset(cleaned);
|
||||
|
||||
|
||||
const finalPayload =
|
||||
selectedTags.length > 0
|
||||
? { ...payload, tags: selectedTags }
|
||||
: payload;
|
||||
|
||||
if (Object.keys(finalPayload).length > 0) {
|
||||
onSubmit(finalPayload);
|
||||
} else {
|
||||
toast({
|
||||
title: t('search.pleaseSelectAtLeastOne'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
form.reset({
|
||||
text: "",
|
||||
from: "",
|
||||
to: "",
|
||||
cc: "",
|
||||
bcc: "",
|
||||
has_attachment: false,
|
||||
attachment_name: "",
|
||||
since: undefined,
|
||||
before: undefined,
|
||||
account_id: undefined,
|
||||
mailbox_id: undefined,
|
||||
size_preset: 'any',
|
||||
message_id: "",
|
||||
});
|
||||
setSelectedAccountId(undefined);
|
||||
}
|
||||
|
||||
return (<Sheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<SheetContent className='w-full md:max-w-4xl mx-auto'>
|
||||
<SheetHeader className="p-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{t('search.searchArchivedEmails')}
|
||||
</SheetTitle>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetDescription>
|
||||
{t('search.fullTextMultiAccount')}
|
||||
</SheetDescription>
|
||||
<Form {...form}>
|
||||
<form id="email-search-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="account_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.account')}:</FormLabel>
|
||||
<FormControl className="flex-1">
|
||||
<VirtualizedSelect
|
||||
options={accountsOptions}
|
||||
isLoading={accountsIsLoading}
|
||||
onSelectOption={(values) => {
|
||||
const account_id = parseInt(values[0], 10);
|
||||
setSelectedAccountId(account_id);
|
||||
field.onChange(account_id);
|
||||
}}
|
||||
value={field.value?.toString() ?? ""}
|
||||
placeholder={t('search.selectAccount')}
|
||||
className="h-10 w-full"
|
||||
noItemsComponent={
|
||||
<div className="p-2">
|
||||
<p className="text-xs">{t('search.noActiveEmailAccount')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: "/accounts" })}
|
||||
>
|
||||
{t('search.addEmailAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mailbox_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.mailbox')}:</FormLabel>
|
||||
<FormControl className="flex-1">
|
||||
<VirtualizedSelect
|
||||
options={mailboxesOptions}
|
||||
isLoading={isMailboxesLoading}
|
||||
onSelectOption={(values) => field.onChange(parseInt(values[0], 10))}
|
||||
value={field.value?.toString() ?? ""}
|
||||
placeholder={t('search.selectMailbox')}
|
||||
className="h-10 w-full"
|
||||
noItemsComponent={
|
||||
<div className="p-2">
|
||||
<p className="text-xs">
|
||||
{t('search.noMailboxSelectAccount')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('search.searchInSubjectBody')}
|
||||
className="h-11 text-base"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 sm:ml-auto sm:self-center">
|
||||
<Button type="submit" className="h-11 px-6" disabled={isLoading}>
|
||||
{isLoading ? t('search.searchingButton') : <>{t('search.searchButton')}</>}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<Filter className="w-4 h-4 mr-1" />
|
||||
{t('search.advanced')}
|
||||
{showAdvanced ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={() => { handleClear(); reset(); }}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
{t('search.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="since"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.since')}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
placeholder={t('search.selectDate')}
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="before"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.before')}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
placeholder={t('search.selectDate')}
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="has_attachment"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="attach"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<FormLabel htmlFor="attach" className="cursor-pointer text-sm font-normal">
|
||||
{t('search.hasAttachment')}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{showAdvanced && <Accordion type="multiple" className="space-y-3">
|
||||
{/* Sender & Recipients */}
|
||||
<AccordionItem value="people">
|
||||
<AccordionTrigger className="text-sm">
|
||||
{t('search.sender')} / {t('search.recipient')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 pt-2">
|
||||
{(['from', 'to', 'cc', 'bcc'] as const).map((key) => (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="capitalize text-xs">
|
||||
{key === 'from' ? t('search.from') : key === 'to' ? t('search.to') : key === 'cc' ? t('search.cc') : t('search.bcc')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={`${key}@example.com`}
|
||||
className="h-9"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="attachment">
|
||||
<AccordionTrigger className="text-sm">
|
||||
{t('search.attachmentsSize')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attachment_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">{t('search.attachmentName')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="invoice.pdf" className="h-9" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="size_preset"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">
|
||||
{t('search.size')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => field.onChange(value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder={t('search.any')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">{t('search.any')}</SelectItem>
|
||||
<SelectItem value="tiny">{t('search.tiny')}</SelectItem>
|
||||
<SelectItem value="small">{t('search.small')}</SelectItem>
|
||||
<SelectItem value="medium">{t('search.medium')}</SelectItem>
|
||||
<SelectItem value="large">{t('search.large')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
<FormDescription className="text-xs">
|
||||
{t('search.sizeDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="ids">
|
||||
<AccordionTrigger className="text-sm">
|
||||
{t('search.messageId')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormControl>
|
||||
<Input placeholder="<abc123@example.com>" className="h-9" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
{t('search.originalMessageIdHeader')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>);
|
||||
}
|
||||
83
web/src/features/search/table/data-table-column-header.tsx
Executable file
83
web/src/features/search/table/data-table-column-header.tsx
Executable file
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CaretSortIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { Column } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className=' h-8 data-[state=open]:bg-accent'
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className='ml-2 h-4 w-4' />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className='ml-2 h-4 w-4' />
|
||||
) : (
|
||||
<CaretSortIcon className='ml-2 h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
{t('table.asc')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
{t('table.desc')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
121
web/src/features/search/table/data-table-row-actions.tsx
Executable file
121
web/src/features/search/table/data-table-row-actions.tsx
Executable file
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { Row } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MoreVertical, TagIcon, Trash2 } from 'lucide-react'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { useSearchContext } from '../context'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<EmailEnvelope>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentEnvelope, setToDelete } = useSearchContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const toggleToDelete = (accountId: number, mailId: number) => {
|
||||
setToDelete(prev => {
|
||||
const next = new Map(prev)
|
||||
const set = new Set(next.get(accountId) || [])
|
||||
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId)
|
||||
if (set.size === 0) next.delete(accountId)
|
||||
else next.set(accountId, set)
|
||||
} else {
|
||||
set.add(mailId)
|
||||
next.set(accountId, set)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = (envelope: EmailEnvelope) => {
|
||||
setToDelete(new Map())
|
||||
toggleToDelete(envelope.account_id, envelope.id)
|
||||
setOpen("delete")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
|
||||
>
|
||||
<MoreVertical size={10} />
|
||||
<span className='sr-only'>Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[160px]'>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCurrentEnvelope(row.original)
|
||||
setOpen("edit-tags")
|
||||
}}
|
||||
>
|
||||
{t('search.editTag')}
|
||||
<DropdownMenuShortcut>
|
||||
<TagIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCurrentEnvelope(row.original)
|
||||
setOpen("restore")
|
||||
}}
|
||||
>
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
<DropdownMenuShortcut>
|
||||
<TagIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(row.original)
|
||||
}}
|
||||
className='!text-red-500'
|
||||
>
|
||||
{t('common.delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
168
web/src/features/search/table/table.tsx
Executable file
168
web/src/features/search/table/table.tsx
Executable file
@@ -0,0 +1,168 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { useState, MouseEvent as ReactMouseEvent, useEffect } from 'react'
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
Row,
|
||||
RowData,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import {
|
||||
Table as ShadcnTable,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from '../context'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
className: string
|
||||
}
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
columns: ColumnDef<EmailEnvelope>[]
|
||||
data: EmailEnvelope[]
|
||||
onRowClick: (e: ReactMouseEvent<HTMLTableRowElement, MouseEvent>, row: Row<EmailEnvelope>) => void
|
||||
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
||||
setSortOrder: (value: "desc" | "asc") => void
|
||||
children?: (table: Table<EmailEnvelope>) => React.ReactNode
|
||||
}
|
||||
|
||||
export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder, children }: DataTableProps) {
|
||||
const { sorting, setSorting } = useSearchContext()
|
||||
const { t } = useTranslation()
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
|
||||
useEffect(() => {
|
||||
const [value] = sorting
|
||||
setSortBy(value.id.toUpperCase() as "DATE" | "SIZE")
|
||||
setSortOrder(value.desc ? "desc" : "asc")
|
||||
}, [sorting])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
{children && (<>{children(table)}</>)}
|
||||
<ScrollArea className='h-[calc(100vh-15rem)] rounded-md border' orientation='both'>
|
||||
<ShadcnTable>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className='group/row'>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={header.column.columnDef.meta?.className ?? ''}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={cn("group/row cursor-pointer transition-colors hover:bg-accent/50")}
|
||||
onClick={(e) => onRowClick(e, row)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cell.column.columnDef.meta?.className ?? ''}
|
||||
style={{
|
||||
width: cell.column.columnDef.size,
|
||||
minWidth: cell.column.columnDef.minSize,
|
||||
maxWidth: cell.column.columnDef.maxSize
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
{t('common.table.noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
</TableBody>
|
||||
</ShadcnTable>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
web/src/features/search/table/toolbar.tsx
Normal file
41
web/src/features/search/table/toolbar.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
import { TagFilterPopover } from '../tag-filter-popover'
|
||||
import { AccountMailboxFilter } from '../account-mailbox-filter'
|
||||
import { TimePopover } from '../time-popover'
|
||||
import { MailFilterPopover } from '../contact-popover'
|
||||
import { TextSearchInput } from '../text-search-input'
|
||||
import { MoreFiltersPopover } from '../more-filters-popover'
|
||||
import { FilterResetButton } from '../filter-reset'
|
||||
|
||||
type DataTableToolbarProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-1 py-1 lg:flex-row lg:items-center lg:gap-1">
|
||||
<div className="flex-1">
|
||||
<TextSearchInput />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap lg:justify-end">
|
||||
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap">
|
||||
<TagFilterPopover />
|
||||
<AccountMailboxFilter />
|
||||
<MailFilterPopover />
|
||||
<TimePopover />
|
||||
<MoreFiltersPopover />
|
||||
<FilterResetButton />
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-auto lg:ml-0">
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
web/src/features/search/table/view-options.tsx
Normal file
80
web/src/features/search/table/view-options.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
|
||||
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type DataTableViewOptionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const defaultColumns = (t: (key: string) => string) => [
|
||||
{ label: t('search.account'), value: "account_email" },
|
||||
{ label: t('search.mailbox'), value: "mailbox_name" },
|
||||
{ label: t('search.from'), value: "from" },
|
||||
{ label: t('search.to'), value: "to" },
|
||||
{ label: t('search.subject'), value: "subject" },
|
||||
{ label: t('search.size'), value: "size" },
|
||||
{ label: t('search.date'), value: "date" },
|
||||
]
|
||||
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
const columnLabels = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
defaultColumns(t).map(col => [col.value, col.label])
|
||||
)
|
||||
}, [t]);
|
||||
|
||||
|
||||
const visibleColumnKeys = React.useMemo(() => {
|
||||
return new Set(defaultColumns(t).map(c => c.value))
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='ms-auto hidden h-8 lg:flex rounded-none'
|
||||
>
|
||||
<MixerHorizontalIcon className='size-4' />
|
||||
{t('search_view.button_label')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[150px]'>
|
||||
<DropdownMenuLabel className='text-xs'>{t('search_view.menu_title')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(column => visibleColumnKeys.has(column.id))
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className='capitalize text-xs'
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{columnLabels[column.id] ?? column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { ChevronDown, ChevronUp, Tag } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useAvailableTags } from '@/hooks/use-available-tags';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EnvelopeTagsProps {
|
||||
selectedTags: string[];
|
||||
onTagToggle: (tag: string) => void;
|
||||
}
|
||||
|
||||
export function EnvelopeTags({ selectedTags, onTagToggle }: EnvelopeTagsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = React.useState(true);
|
||||
|
||||
const {
|
||||
tagsCount: tagsCount = [],
|
||||
isLoading: tagsIsLoading,
|
||||
} = useAvailableTags();
|
||||
|
||||
const sortedTags = React.useMemo(() => {
|
||||
return [...tagsCount].sort((a, b) => b.count - a.count);
|
||||
}, [tagsCount]);
|
||||
|
||||
if (tagsIsLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-2 py-1.5">
|
||||
<div className="h-4 w-4 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 flex-1 bg-muted animate-pulse rounded" />
|
||||
<div className="h-5 w-10 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="space-y-2">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-sm font-medium hover:text-primary transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
{t('mail.tags')}
|
||||
{selectedTags.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-xs">
|
||||
{selectedTags.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{open ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="space-y-0">
|
||||
{sortedTags.length === 0 ? (
|
||||
<p className="py-2 pl-2 text-sm text-muted-foreground">{t('mail.noTagsYet')}</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[45rem] w-full pr-4 -mr-4">
|
||||
{sortedTags.map(({ tag: facet, count }) => {
|
||||
const checked = selectedTags.includes(facet);
|
||||
const id = `tag-${facet}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={facet}
|
||||
className="flex items-center gap-3 px-2 py-0.5 hover:bg-accent/80 rounded-md transition-colors cursor-pointer group"
|
||||
onClick={() => onTagToggle(facet)}
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={() => onTagToggle(facet)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 max-w-[140px] lg:max-w-[120px] cursor-pointer truncate text-sm font-medium"
|
||||
title={facet}
|
||||
>
|
||||
{facet}
|
||||
</Label>
|
||||
<div className="shrink-0 ml-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-xs font-medium min-w-[1.75rem] text-center"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user