24 Commits
0.0.4 ... 0.1.1

Author SHA1 Message Date
rustmailer
34ec3a7d5b bump versions 2025-11-30 05:53:39 +08:00
rustmailer
82397ab0cd fix(ui): fix sync folder selection jump issue; add auto-select children/parents and expand/collapse all folders button #21 2025-11-30 05:51:17 +08:00
rustmailer
1cfc12324f fix(ui): Handle IMAP connection failure gracefully during folder sync #23 2025-11-29 11:42:11 +08:00
rustmailer
dffdac3eb6 Update README.md 2025-11-29 01:24:00 +08:00
rustmailer
7d02e58e4e Update README.md 2025-11-29 00:35:16 +08:00
rustmailer
847cc6825a Update README.md 2025-11-29 00:34:22 +08:00
rustmailer
6ce1420714 feat(import): introduce /api/v1/import endpoint to support batch EML email import 2025-11-27 22:11:05 +08:00
rustmailer
9a72ce9154 update 2025-11-27 15:18:12 +08:00
rustmailer
9f713ef044 update 2025-11-27 15:12:31 +08:00
rustmailer
275cde180b update issue templates 2025-11-27 15:01:14 +08:00
rustmailer
c82fb4f301 Update issue templates 2025-11-27 14:49:37 +08:00
rustmailer
736270b08c feat(dashboard): Display system version and Git hash, link to release tag #19 2025-11-27 03:55:20 +08:00
rustmailer
855c9a1ffc update 2025-11-27 02:01:09 +08:00
rustmailer
b845eda58a update 2025-11-27 01:49:20 +08:00
rustmailer
6571966228 update 2025-11-27 01:36:42 +08:00
rustmailer
c727460251 update 2025-11-27 01:27:16 +08:00
rustmailer
7c7e353114 feat: add option to trust any TLS certificate for IMAP connections 2025-11-27 00:57:43 +08:00
rustmailer
78b33f8994 Update README.md 2025-11-26 22:28:54 +08:00
rustmailer
324051bdf0 Revise README to enhance project description
Updated README to clarify Bichon's purpose and features.
2025-11-26 20:40:40 +08:00
rustmailer
33a16c1af9 Merge branch 'main' of https://github.com/rustmailer/bichon 2025-11-26 09:35:29 +08:00
rustmailer
1bfecc2ff1 update 2025-11-26 09:21:42 +08:00
rustmailer
a8282a95f3 Update README.md 2025-11-25 08:33:22 +08:00
rustmailer
9fe617aed7 Revise internationalization details in README
Updated internationalization section to clarify language support.
2025-11-25 00:11:10 +08:00
rustmailer
22e12b68a8 Add internationalization section to README
Added internationalization support details to README.
2025-11-25 00:08:50 +08:00
60 changed files with 2958 additions and 2471 deletions

21
.github/ISSUE_TEMPLATE/bug.md.md vendored Normal file
View File

@@ -0,0 +1,21 @@
---
name: Bug Report
about: Report a problem you encountered
title: "[BUG] "
labels: ["bug"]
assignees: ""
---
> **Please write and communicate in English.**
### Version
Which version are you using?
### Steps to Reproduce
Describe the steps to reproduce the issue clearly.
### Issue Description
What is the problem you encountered?
### Screenshots or Logs (optional)
Attach any screenshots or logs if available.

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

18
.github/ISSUE_TEMPLATE/feature.md vendored Normal file
View File

@@ -0,0 +1,18 @@
---
name: Feature Request
about: Suggest a new feature or improvement
title: "[FEATURE] "
labels: ["enhancement"]
assignees: ""
---
> **Please write and communicate in English.**
### Description
What feature would you like to see?
### Purpose / Use Case
Why is this feature needed? What problem does it solve?
### Additional Information (optional)
Any extra ideas or context.

15
.github/ISSUE_TEMPLATE/other.md vendored Normal file
View File

@@ -0,0 +1,15 @@
---
name: Other Issue
about: Any other question or topic
title: ""
labels: ["question"]
assignees: ""
---
> **Please write and communicate in English.**
### Description
Describe your question or topic.
### Additional Information (optional)
Provide any additional context if needed.

2316
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "bichon"
version = "0.0.4"
version = "0.1.1"
edition = "2021"
[[bin]]
@@ -39,28 +39,28 @@ 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.41"
tracing = "0.1.43"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
base64 = "0.22.1"
snafu = "0.8.9"
reqwest = { version = "0.12.24", default-features = false, features = [
"json",
"stream",
"native-tls",
# "rustls-tls",
#"native-tls",
"rustls-tls",
"blocking",
"socks",
] }
tokio-socks = "0.5.2"
http = "1.3.1"
http = "1.4.0"
regex = "1.12.2"
email_address = "0.2.9"
futures = "0.3.31"
utf7-imap = "0.3.2"
imap-proto = "0.16.6"
mail-parser = { version = '0.11.1', features = ["serde"] }
mail-send = "0.5.2"
# mail-send = "0.5.2"
tokio-rustls = { version = "0.26.4", default-features = false, features = [
"ring",
"tls12",
@@ -83,9 +83,9 @@ 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.0"
rustls-pki-types = "1.13.1"
tokio-io-timeout = "1.2.1"
bb8 = "0.9.0"
bb8 = "0.9.1"
semver = "1.0.27"
governor = "0.10.2"
lru = "0.16.2"

View File

@@ -31,9 +31,24 @@
</p>
</div>
Bichon is a minimal, high-performance, standalone Rust email archiver with a built-in WebUI.
Its name is inspired by the puppy my daughter adopted last month.
It runs as a single binary, requires no external dependencies, and provides fast, efficient email archiving, management, and search.
Bichon is an open-source email archiving system that **synchronizes emails from IMAP servers**, **indexes them for full-text search**, and provides a **REST API** for programmatic access.
**Unlike email clients**, Bichon is designed for **archiving and searching** rather than sending/receiving emails. It runs as a **standalone server application** that continuously synchronizes configured email accounts and maintains a **searchable local archive**.
Built in Rust, it requires no external dependencies and provides fast, efficient email archiving, management, and search through a built-in WebUI. Its name is inspired by the puppy my daughter adopted last month.
## Key Differences from Email Clients
### Core Comparison
| Feature | Email Clients | Bichon |
|---------|---------------|--------|
| **Primary Purpose** | Send/receive emails, real-time communication | Archive, search, manage historical emails |
| **Sending Capability** | ✅ Supports sending emails | ❌ No email sending support |
| **Runtime Mode** | Desktop/mobile applications | Server-side application |
| **Data Storage** | Local cache + server | Local archive store |
| **Search Capability** | Basic search | Full-text indexing, advanced search |
| **API Interface** | Typically not provided | Complete REST API |
| **Multi-account Management** | Limited | Supports unified search across accounts |
## 🚀 Features
@@ -72,6 +87,11 @@ It runs as a single binary, requires no external dependencies, and provides fast
### 📊 Dashboard & Analytics
- Visualize email statistics: **counts**, **time distribution**, **top senders**, **largest emails**, **account rankings**.
### 🌐 Internationalization (i18n)
* WebUI fully supports **17 languages** for all interface elements.
* Backend responses (e.g., system messages, API data) are **not yet internationalized**.
* Frontend is ready to support more languages in the future with minimal effort.
### 🛠️ OpenAPI Support
- Provides **OpenAPI documentation**.
- **Access token authentication** for programmatic access.
@@ -103,6 +123,12 @@ Its not perfect, but I hope it brings you value.
<img width="1915" height="903" alt="image" src="https://github.com/user-attachments/assets/242817d7-3e12-4cbb-afb0-c5ef7366178d" />
<img width="1920" height="910" alt="image" src="https://github.com/user-attachments/assets/14561b74-ed53-4017-9c5b-a64920ec3526" />
<img width="1913" height="909" alt="image" src="https://github.com/user-attachments/assets/6fd54cb0-c86f-4ceb-a955-c81107614fc4" />
<img width="1916" height="814" alt="image" src="https://github.com/user-attachments/assets/6a079d98-ff6c-46f4-9ec6-e76d320bff5d" />
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=rustmailer/bichon&type=date&legend=top-left)](https://www.star-history.com/#rustmailer/bichon&type=date&legend=top-left)
## 🚀 Quick Start
@@ -273,10 +299,14 @@ You can change the password via the WebUI:
- Used exclusively for lightweight configuration and account metadata.
- **Email Protocols**: IMAP (Supports standard Password & OAuth2)
## 🤝 Contributing
Issues and Pull Requests are welcome!
Contributions of all kinds are welcome!
Whether youd like to submit code, report a bug, or share practical suggestions that can help improve the project, your input is highly appreciated.
Feel free to open an Issue or a Pull Request anytime. You can also reach out on Discord if youd like to discuss ideas or improvements.
<a href="https://discord.gg/evFnSpdpaE">
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
</a>
## 🧑‍💻 Developer Guide
@@ -293,7 +323,7 @@ To build or contribute to Bichon, the following environment is recommended:
```bash
git clone https://github.com/rustmailer/bichon.git
cd bichon
````
```
#### 2. Build the WebUI
@@ -342,3 +372,12 @@ This project is licensed under [AGPLv3](LICENSE).
- [Issue Tracker](https://github.com/rustmailer/bichon/issues)
- [Discord](https://discord.gg/evFnSpdpaE)
## 💖 Support & Promotion
If this project has been helpful to you and youd 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.
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!
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Support%20the%20Project-FFDD00?logo=buy-me-a-coffee)](https://buymeacoffee.com/rustmailer)

View File

@@ -55,7 +55,7 @@ use crate::modules::rest::response::DataPage;
use crate::modules::token::AccessToken;
use crate::raise_error;
pub type AccountModel = AccountV1;
pub type AccountModel = AccountV2;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
pub enum AccountType {
@@ -86,11 +86,41 @@ pub struct AccountV1 {
pub updated_at: i64,
pub use_proxy: Option<u64>,
}
impl AccountV1 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 4, version = 2, from = AccountV1)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV2 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV2 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
Ok(Self {
@@ -109,12 +139,14 @@ impl AccountV1 {
updated_at: utc_now!(),
use_proxy: request.use_proxy,
folder_limit: request.folder_limit,
use_dangerous: request.use_dangerous,
pgp_key: request.pgp_key,
})
}
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
let account =
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
.await?
.ok_or_else(|| {
raise_error!(
@@ -144,7 +176,7 @@ impl AccountV1 {
}
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
.await
}
@@ -198,7 +230,7 @@ impl AccountV1 {
async fn delete_account(account_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move|rw|{
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound))
}).await
}
@@ -228,7 +260,7 @@ impl AccountV1 {
sync_folders: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -243,7 +275,7 @@ impl AccountV1 {
known_folders: BTreeSet<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -258,7 +290,7 @@ impl AccountV1 {
capabilities: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -287,7 +319,7 @@ impl AccountV1 {
}
pub async fn count() -> BichonResult<usize> {
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id)
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id)
.await
}
@@ -355,7 +387,62 @@ impl AccountV1 {
if let Some(enabled) = request.enabled {
new.enabled = enabled;
}
if let Some(use_dangerous) = request.use_dangerous {
new.use_dangerous = use_dangerous;
}
if let Some(pgp_key) = request.pgp_key {
new.pgp_key = Some(pgp_key);
}
new.updated_at = utc_now!();
Ok(new)
}
}
impl From<AccountV1> for AccountV2 {
fn from(value: AccountV1) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: false,
pgp_key: None,
}
}
}
impl From<AccountV2> for AccountV1 {
fn from(value: AccountV2) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
}
}
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use crate::modules::account::entity::ImapConfig;
@@ -43,6 +42,8 @@ pub struct AccountCreateRequest {
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
pub sync_interval_min: Option<i64>,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountCreateRequest {
@@ -132,6 +133,10 @@ pub struct AccountUpdateRequest {
/// - If `None` or not provided, the client will connect directly to the API server.
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests.
pub use_proxy: Option<u64>,
pub use_dangerous: Option<bool>,
pub pgp_key: Option<String>,
}
impl AccountUpdateRequest {

View File

@@ -16,19 +16,14 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use super::error::code::ErrorCode;
use super::error::BichonError;
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
use mail_send::mail_builder::headers::address::Address as SmtpAddress;
use mail_send::mail_builder::headers::address::EmailAddress as SmtpEmailAddress;
use poem::error::ResponseError;
use poem::Body;
use poem::{http::StatusCode, Error, Response};
use poem_openapi::Object;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::ops::Deref;
use tracing::error;
@@ -53,38 +48,6 @@ pub struct Addr {
pub address: Option<String>,
}
impl Addr {
pub fn parse(s: &str) -> Self {
let re = Regex::new(r#"(?:(?P<name>.*)\s*)?<(?P<email>[^<>]+)>"#).unwrap();
if let Some(caps) = re.captures(s) {
let name: Option<String> = caps.name("name").map(|m| m.as_str().trim().into());
let email: Option<String> = caps.name("email").map(|m| m.as_str().trim().into());
Addr {
name: if let Some(n) = name {
if n.is_empty() {
None
} else {
Some(n)
}
} else {
None
},
address: email,
}
} else {
let s_trimmed = s.trim();
Addr {
name: None,
address: if s_trimmed.is_empty() {
None
} else {
Some(s_trimmed.into())
},
}
}
}
}
impl std::fmt::Display for Addr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (&self.name, &self.address) {
@@ -129,50 +92,6 @@ impl<'x> From<&ImapAddress<'x>> for AddrVec {
}
}
impl<'x> From<SmtpEmailAddress<'x>> for Addr {
fn from(email: SmtpEmailAddress<'x>) -> Self {
Addr {
name: email.name.map(|n| n.into_owned()),
address: Some(email.email.into_owned()),
}
}
}
impl<'x> From<&SmtpAddress<'x>> for AddrVec {
fn from(address: &SmtpAddress<'x>) -> Self {
fn collect_addresses<'x>(address: &SmtpAddress<'x>, result: &mut Vec<Addr>) {
match address {
SmtpAddress::Address(email) => {
let addr = Addr::from(email.clone());
result.push(addr);
}
SmtpAddress::Group(group) => {
for addr in &group.addresses {
collect_addresses(addr, result);
}
}
SmtpAddress::List(list) => {
for addr in list {
collect_addresses(addr, result);
}
}
}
}
let mut addresses = Vec::new();
collect_addresses(address, &mut addresses);
AddrVec(addresses)
}
}
impl<'x> From<Addr> for SmtpAddress<'x> {
fn from(addr: Addr) -> Self {
SmtpAddress::Address(SmtpEmailAddress {
name: addr.name.map(Cow::Owned),
email: Cow::Owned(addr.address.unwrap_or_default()),
})
}
}
// #[derive(Serialize)]
// pub struct ErrorResponse {
// pub message: String,

View File

@@ -16,7 +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::modules::account::migration::AccountType;
use crate::modules::context::Initialize;
use crate::modules::error::code::ErrorCode;
use crate::raise_error;
@@ -33,8 +33,7 @@ use dashmap::DashMap;
use std::sync::{Arc, LazyLock};
use tracing::info;
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> =
LazyLock::new(EmailClientExecutors::new);
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> = LazyLock::new(EmailClientExecutors::new);
pub struct EmailClientExecutors {
start_at: i64,
@@ -88,15 +87,17 @@ impl EmailClientExecutors {
pub async fn start_account_syncers(&self) -> BichonResult<()> {
let accounts = AccountModel::list_all().await?;
let active_accounts: Vec<AccountModel> =
accounts.into_iter().filter(|a| a.enabled).collect();
let active_accounts: Vec<AccountModel> = accounts
.into_iter()
.filter(|a| a.enabled && matches!(a.account_type, AccountType::IMAP))
.collect();
if active_accounts.is_empty() {
info!("No active accounts found for account initialization.");
return Ok(());
}
info!(
"System has {} active accounts to initialize.",
"System has {} active IMAP accounts to initialize.",
active_accounts.len()
);
for account in active_accounts {

View File

@@ -16,12 +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 poem_openapi::Object;
use serde::{Deserialize, Serialize};
use tantivy::{schema::Value, TantivyDocument};
use crate::{
bichon_version,
modules::{
account::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
@@ -45,6 +45,8 @@ pub struct DashboardStats {
pub with_attachment_count: u64, // Emails with attachments
pub without_attachment_count: u64, // Emails without attachments
pub top_largest_emails: Vec<LargestEmail>, // Top 10 largest emails
pub system_version: String, // The semantic version string of the currently running backend service
pub commit_hash: String, // Git commit hash used to build this system version
}
impl DashboardStats {
@@ -57,6 +59,8 @@ impl DashboardStats {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.system_version = bichon_version!().to_string();
stat.commit_hash = env!("GIT_HASH").to_string();
Ok(stat)
}
}

View File

@@ -16,7 +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::modules::account::migration::AccountModel;
use crate::modules::cache::imap::MAILBOX_MODELS;
use crate::modules::error::{code::ErrorCode, BichonError};
use crate::modules::settings::cli::SETTINGS;
@@ -71,6 +71,8 @@ impl DatabaseManager {
let rw = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<AccountModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;

View File

@@ -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::modules::account::migration::AccountV1;
use crate::modules::account::migration::{AccountV1, AccountV2};
use crate::modules::autoconfig::CachedMailSettings;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
@@ -63,6 +62,7 @@ impl ModelsAdapter {
self.register_model::<SystemSetting>();
self.register_model::<CachedMailSettings>();
self.register_model::<AccountV1>();
self.register_model::<AccountV2>();
self.register_model::<OAuth2>();
self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>();

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::AddrVec;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
@@ -33,12 +32,12 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
.map(|d| d.timestamp_millis())
.unwrap_or(0);
let uid = fetch.uid.unwrap_or(0);
let size = fetch.size.unwrap_or(0);
let body = fetch
.body()
.ok_or_else(|| raise_error!("No body available".into(), ErrorCode::InternalError))?;
let size = fetch.size.unwrap_or(body.len() as u32);
let message = MessageParser::new().parse(body).ok_or_else(|| {
raise_error!(
"Email header parse result is not available".into(),
@@ -118,6 +117,92 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
Ok(envelope)
}
pub fn extract_envelope_from_eml(
body: &[u8],
account_id: u64,
mailbox_id: u64,
) -> BichonResult<Envelope> {
let uid = 0;
let size = body.len() as u32;
let message = MessageParser::new().parse(body).ok_or_else(|| {
raise_error!(
"Email header parse result is not available".into(),
ErrorCode::InternalError
)
})?;
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
text
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
from_read(html.as_bytes(), 0)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} else {
String::new()
};
let message_id = message
.message_id()
.map(String::from)
.unwrap_or(generate_message_id());
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let subject = message.subject().map(String::from).unwrap_or("".into());
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let cc: Option<Vec<String>> = message.cc().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let to: Option<Vec<String>> = message.to().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let from = message
.from()
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
.and_then(|add| add.address)
.unwrap_or_else(|| "unknown".to_string());
let attachments: Vec<String> = message
.attachments()
.filter_map(|att| att.attachment_name())
.map(|name| name.to_string())
.collect();
let envelope = Envelope {
id: create_hash(account_id, &message_id),
message_id,
account_id,
mailbox_id,
uid,
subject,
text,
from,
to: to.unwrap_or_default(),
cc: cc.unwrap_or_default(),
bcc: bcc.unwrap_or_default(),
date,
internal_date: date,
size,
thread_id,
attachments,
tags: None,
};
Ok(envelope)
}
pub fn compute_thread_id(
in_reply_to: Option<String>,
references: Option<Vec<String>>,

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::entity::Encryption;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
@@ -100,15 +99,17 @@ impl Client {
encryption: &Encryption,
port: u16,
use_proxy: Option<u64>,
dangerous: bool,
) -> BichonResult<Self> {
let resolved_addr = Self::resolve_to_socket_addr(domain, port)?;
debug!("Attempting IMAP connection to {domain} ({resolved_addr}).");
match encryption {
Encryption::Ssl => {
Self::establish_secure_connection(resolved_addr, domain, use_proxy).await
Self::establish_secure_connection(resolved_addr, domain, use_proxy, dangerous).await
}
Encryption::StartTls => {
Self::establish_starttls_connection(resolved_addr, domain, use_proxy).await
Self::establish_starttls_connection(resolved_addr, domain, use_proxy, dangerous)
.await
}
Encryption::None => Self::establish_insecure_connection(resolved_addr, use_proxy).await,
}
@@ -118,11 +119,17 @@ impl Client {
address: SocketAddr,
server_hostname: &str,
use_proxy: Option<u64>,
dangerous: bool,
) -> BichonResult<Self> {
// Establish the TLS connection with the specified parameters
let tls_stream =
establish_tls_connection(address, server_hostname, alpn(address.port()), use_proxy)
.await?;
let tls_stream = establish_tls_connection(
address,
server_hostname,
alpn(address.port()),
use_proxy,
dangerous,
)
.await?;
let stats_stream = StatsWrapper::new(tls_stream);
// Wrap the TLS stream in a buffered writer for efficient IO
let buffered_stream = BufWriter::new(stats_stream);
@@ -137,7 +144,7 @@ impl Client {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
.ok_or_else(|| {
raise_error!(
"failed to read greeting".into(),
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is SSL.".into(),
ErrorCode::ImapCommandFailed
)
})?;
@@ -180,6 +187,7 @@ impl Client {
address: SocketAddr,
server_hostname: &str,
use_proxy: Option<u64>,
dangerous: bool,
) -> BichonResult<Self> {
// Establish the initial TCP connection
let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?;
@@ -197,7 +205,7 @@ impl Client {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
.ok_or_else(|| {
raise_error!(
"failed to read greeting".into(),
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is STARTTLS.".into(),
ErrorCode::ImapCommandFailed
)
})?;
@@ -217,7 +225,7 @@ impl Client {
let buffered_tcp_stream = client.into_inner();
let tcp_stream = buffered_tcp_stream.into_inner();
// Wrap the TCP stream in TLS encryption
let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream).await?;
let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream, dangerous).await?;
// Wrap the TLS stream in a buffered writer
let buffered_stream = BufWriter::new(tls_stream);
// Create a SessionStream trait object for further communication

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::dispatcher::STATUS_DISPATCHER;
use crate::modules::account::entity::AuthType;
use crate::modules::account::migration::{AccountModel, AccountType};
@@ -51,7 +50,14 @@ impl ImapConnectionManager {
async fn create_client(&self, account: &AccountModel) -> BichonResult<Client> {
assert_eq!(account.account_type, AccountType::IMAP);
let imap = account.imap.as_ref().unwrap();
Client::connection(&imap.host, &imap.encryption, imap.port, imap.use_proxy).await
Client::connection(
&imap.host,
&imap.encryption,
imap.port,
imap.use_proxy,
account.use_dangerous,
)
.await
}
async fn authenticate(

View File

@@ -16,19 +16,21 @@
// 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 mail_parser::MessageParser;
use crate::{base64_encode_url_safe, modules::{account::entity::Encryption, imap::client::Client}};
use crate::{
base64_encode_url_safe,
modules::{account::entity::Encryption, imap::client::Client},
};
#[tokio::test]
async fn testxx() {
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
.unwrap();
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None)
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None, false)
.await
.unwrap();
let mut session = client.login("pollybase@zohomail.com", "xxx").await.unwrap();
let mut session = client.login("xx@zohomail.com", "xxx").await.unwrap();
session.select("INBOX").await.unwrap();
let result = session.uid_search("LARGER 1024").await.unwrap();
println!("{:#?}", result);

162
src/modules/import/mod.rs Normal file
View File

@@ -0,0 +1,162 @@
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use tantivy::doc;
use crate::{
base64_decode_url_safe,
modules::{
account::migration::{AccountModel, AccountType},
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
envelope::extractor::extract_envelope_from_eml,
error::{code::ErrorCode, BichonResult},
indexer::{
manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
schema::SchemaTools,
},
utils::create_hash,
},
raise_error,
};
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct BatchEmlRequest {
pub account_id: u64,
pub mail_folder: String,
/// A list of emails in base64-encoded format. Each element represents one .eml file.
pub emls: Vec<String>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct FailedEmlDetail {
/// The 0-based index of the failed EML in the request list
pub index: usize,
/// The error message that caused the import to fail
pub error_message: String,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct BatchEmlResult {
/// Total number of emails processed
pub total: usize,
/// Number of emails successfully imported
pub success: usize,
/// Number of emails failed to import
pub failed: usize,
/// A list of details for failed imports
pub failed_details: Vec<FailedEmlDetail>,
}
pub struct ImportEmls;
impl ImportEmls {
pub async fn do_import(request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
let account = AccountModel::check_account_exists(request.account_id).await?;
if !account.enabled {
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
}
let mailbox_id = match account.account_type {
AccountType::IMAP => {
let all_mailboxes = MailBox::list_all(account.id).await?;
let mailbox = all_mailboxes.into_iter().find(|m| m.name == request.mail_folder);
match mailbox {
Some(mailbox) => mailbox.id,
None => return Err(raise_error!(
format!("Mail folder '{}' not found for account ID {}. The target folder must exist before importing.",
request.mail_folder,
request.account_id).into(),
ErrorCode::ResourceNotFound
)),
}
},
AccountType::NoSync => {
let mailbox = MailBox {
id: create_hash(request.account_id, &request.mail_folder),
account_id: request.account_id,
name: request.mail_folder.clone(),
delimiter: Some("/".to_string()),
attributes: vec![Attribute {
attr: AttributeEnum::Extension,
extension: Some("CreatedByBichon".into()),
}],
exists: 0,
unseen: None,
uid_next: None,
uid_validity: None,
};
let mailbox_id = mailbox.id;
// Upsert the mailbox, creating it if it doesn't exist
MailBox::batch_upsert(&[mailbox]).await?;
mailbox_id
},
};
let fields = SchemaTools::eml_fields();
let account_id = account.id;
let mut success_count = 0;
let mut failed_details: Vec<FailedEmlDetail> = Vec::new(); // Store failure details
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) => {
let error_msg =
format!("Failed to decode base64 EML at index {}: {:?}", index, e);
tracing::error!("{}", error_msg);
failed_details.push(FailedEmlDetail {
index,
error_message: error_msg,
});
continue;
}
};
let envelope = match extract_envelope_from_eml(&decoded, account_id, mailbox_id) {
Ok(env) => env,
Err(e) => {
let error_msg = format!(
"Failed to extract envelope from EML at index {}: {:?}",
index, e
);
tracing::error!("{}", error_msg);
failed_details.push(FailedEmlDetail {
index,
error_message: error_msg,
});
continue;
}
};
ENVELOPE_INDEX_MANAGER
.add_document(envelope.id, envelope.to_document(mailbox_id).unwrap())
.await;
EML_INDEX_MANAGER
.add_document(
envelope.id,
doc!(
fields.f_id => envelope.id,
fields.f_account_id => account_id,
fields.f_mailbox_id => mailbox_id,
fields.f_eml => decoded
),
)
.await;
success_count += 1;
}
let failed_count = failed_details.len();
Ok(BatchEmlResult {
total,
success: success_count,
failed: failed_count,
failed_details, // Return the list of failure details
})
}
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod account;
pub mod autoconfig;
pub mod cache;
@@ -27,6 +26,7 @@ pub mod database;
pub mod envelope;
pub mod error;
pub mod imap;
pub mod import;
pub mod indexer;
pub mod logger;
pub mod mailbox;

View File

@@ -0,0 +1,49 @@
//
// 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/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::import::BatchEmlResult;
use crate::modules::import::{BatchEmlRequest, ImportEmls};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct ImportApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Import")]
impl ImportApi {
/// Batch import one or more EML files into a specified account and mail folder.
///
/// This endpoint accepts a JSON payload containing:
/// - `account_id`: the target account to import emails into
/// - `mail_folder`: the mailbox/folder name
/// - `emls`: a list of base64-encoded .eml files
///
/// Returns a summary of the import result, including total processed, successful, and failed emails.
#[oai(path = "/import", method = "post", operation_id = "do_batch_import")]
async fn do_batch_import(
&self,
/// JSON payload with account info and EML files to import
payload: Json<BatchEmlRequest>,
context: ClientContext,
) -> ApiResult<Json<BatchEmlResult>> {
context.require_root()?;
Ok(Json(ImportEmls::do_import(payload.0).await?))
}
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use access_token::AccessTokenApi;
use account::AccountApi;
use auto_config::AutoConfigApi;
@@ -26,11 +25,12 @@ use oauth2::OAuth2Api;
use poem_openapi::{OpenApiService, Tags};
use system::SystemApi;
use crate::bichon_version;
use crate::{bichon_version, modules::rest::api::import::ImportApi};
pub mod access_token;
pub mod account;
pub mod auto_config;
pub mod import;
pub mod mailbox;
pub mod message;
pub mod oauth2;
@@ -45,6 +45,7 @@ pub enum ApiTags {
OAuth2,
Message,
System,
Import,
}
type RustMailOpenApi = (
@@ -55,6 +56,7 @@ type RustMailOpenApi = (
MailBoxApi,
OAuth2Api,
MessageApi,
ImportApi,
);
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
@@ -67,6 +69,7 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
MailBoxApi,
OAuth2Api,
MessageApi,
ImportApi,
),
"BichonApi",
bichon_version!(),

View File

@@ -195,19 +195,19 @@ impl OAuth2Api {
/// Configures an external OAuth2 token for a specified account.
///
/// This endpoint allows two usage modes:
/// 1. If only an `access_token` is provided, RustMailer will store it directly.
/// - In this mode, RustMailer **cannot refresh** the token, since it has no
/// 1. If only an `access_token` is provided, Bichon will store it directly.
/// - In this mode, Bichon **cannot refresh** the token, since it has no
/// associated OAuth2 configuration or refresh token.
/// - The caller is responsible for periodically updating the access token
/// by calling this endpoint again.
/// 2. If both `oauth2_id` and `refresh_token` are provided, it means the external
/// OAuth2 authorization flow has been completed outside RustMailer.
/// OAuth2 authorization flow has been completed outside Bichon.
/// - Since the OAuth2 configuration (including client_id and client_secret)
/// is already stored in RustMailer, the service can use the refresh token
/// is already stored in Bichon, the service can use the refresh token
/// to obtain new access tokens automatically.
///
/// Note: The `oauth2_id` must reference a valid OAuth2 configuration
/// already created in RustMailer.
/// already created in Bichon.
#[oai(
path = "/store-external-oauth2-token/:account_id",
method = "post",

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::code::ErrorCode;
use crate::modules::settings::proxy::Proxy;
use crate::modules::utils::tls::establish_tls_stream;
@@ -56,17 +55,19 @@ pub(crate) async fn establish_tcp_connection_with_timeout(
Ok(Box::pin(timeout_stream))
}
pub(crate) async fn establish_tls_connection(
pub async fn establish_tls_connection(
address: SocketAddr,
server_hostname: &str,
alpn_protocols: &[&str],
use_proxy: Option<u64>,
dangerous: bool,
) -> BichonResult<impl SessionStream> {
// Establish the TCP connection with timeout
let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?;
// Wrap the TCP stream with TLS encryption
let tls_stream = establish_tls_stream(server_hostname, alpn_protocols, tcp_stream).await?;
let tls_stream =
establish_tls_stream(server_hostname, alpn_protocols, tcp_stream, dangerous).await?;
// Return the TLS stream wrapped in a SessionStream
Ok(tls_stream)

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
@@ -24,38 +23,44 @@ use crate::{
},
raise_error,
};
use rustls::RootCertStore;
use rustls::{
client::danger::{ServerCertVerified, ServerCertVerifier},
RootCertStore,
};
use std::sync::Arc;
pub async fn establish_tls_stream(
server_hostname: &str,
alpn_protocols: &[&str],
stream: impl SessionStream + 'static,
dangerous: bool,
) -> BichonResult<impl SessionStream> {
let tls_stream = establish_rustls_stream(server_hostname, alpn_protocols, stream).await?;
let tls_stream =
establish_rustls_stream(server_hostname, alpn_protocols, stream, dangerous).await?;
let boxed_stream: Box<dyn SessionStream> = Box::new(tls_stream);
Ok(boxed_stream)
}
pub async fn establish_rustls_stream(
async fn establish_rustls_stream(
server_hostname: &str,
alpn_protocols: &[&str],
stream: impl SessionStream,
dangerous: bool,
) -> BichonResult<impl SessionStream> {
// Create a root certificate store and add default trusted roots
let root_store = RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.into(),
};
// Configure the Rustls client with the root certs and no client authentication
let mut config = rustls::ClientConfig::builder()
//builder_with_provider(
// rustls::crypto::ring::default_provider().into(),
// )
// .with_protocol_versions(&[&rustls::version::TLS13])
// .unwrap()
.with_root_certificates(root_store)
.with_no_client_auth();
let mut config = if dangerous {
rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(SkipCertVerification))
.with_no_client_auth()
} else {
rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth()
};
// Set the ALPN protocols
config.alpn_protocols = alpn_protocols
@@ -76,3 +81,56 @@ pub async fn establish_rustls_stream(
Ok(tls_stream)
}
#[derive(Debug)]
struct SkipCertVerification;
impl ServerCertVerifier for SkipCertVerification {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
// Always return a valid certificate verification result
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA1,
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}

View File

@@ -13,7 +13,11 @@
"knip": "knip"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@hookform/resolvers": "^3.9.1",
"@mui/material": "^7.3.5",
"@mui/x-tree-view": "^8.19.0",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
@@ -37,6 +41,7 @@
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.4",
"@radix-ui/react-visually-hidden": "^1.1.0",
"@react-spring/web": "^10.0.3",
"@tabler/icons-react": "^3.24.0",
"@tanstack/react-query": "^5.62.3",
"@tanstack/react-router": "^1.86.1",

581
web/pnpm-lock.yaml generated
View File

@@ -8,9 +8,21 @@ importers:
.:
dependencies:
'@emotion/react':
specifier: ^11.14.0
version: 11.14.0(@types/react@18.3.18)(react@18.3.1)
'@emotion/styled':
specifier: ^11.14.1
version: 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
'@hookform/resolvers':
specifier: ^3.9.1
version: 3.9.1(react-hook-form@7.54.0(react@18.3.1))
'@mui/material':
specifier: ^7.3.5
version: 7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mui/x-tree-view':
specifier: ^8.19.0
version: 8.19.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-accordion':
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)
@@ -80,6 +92,9 @@ importers:
'@radix-ui/react-visually-hidden':
specifier: ^1.1.0
version: 1.1.0(@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)
'@react-spring/web':
specifier: ^10.0.3
version: 10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tabler/icons-react':
specifier: ^3.24.0
version: 3.24.0(react@18.3.1)
@@ -350,10 +365,6 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.26.0':
resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
@@ -378,6 +389,70 @@ packages:
resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==}
engines: {node: '>=6.9.0'}
'@base-ui-components/utils@0.1.2':
resolution: {integrity: sha512-aEitDGpMsYO2qnSpYOwZNykn9Rzn2ioyEVk2fyDRH7t+TIHVKpp9CeV7SPTq43M9mMSDxQ+7UeZJVkrj2dCVIQ==}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@emotion/babel-plugin@11.13.5':
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
'@emotion/cache@11.14.0':
resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==}
'@emotion/hash@0.9.2':
resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
'@emotion/is-prop-valid@1.4.0':
resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==}
'@emotion/memoize@0.9.0':
resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==}
'@emotion/react@11.14.0':
resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==}
peerDependencies:
'@types/react': '*'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
'@emotion/serialize@1.3.3':
resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==}
'@emotion/sheet@1.4.0':
resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==}
'@emotion/styled@11.14.1':
resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==}
peerDependencies:
'@emotion/react': ^11.0.0-rc.0
'@types/react': '*'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
'@emotion/unitless@0.10.0':
resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==}
'@emotion/use-insertion-effect-with-fallbacks@1.2.0':
resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==}
peerDependencies:
react: '>=16.8.0'
'@emotion/utils@1.4.2':
resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==}
'@emotion/weak-memoize@0.4.0':
resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
'@esbuild/aix-ppc64@0.23.1':
resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==}
engines: {node: '>=18'}
@@ -722,6 +797,9 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@floating-ui/utils@0.2.8':
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
@@ -775,6 +853,108 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@mui/core-downloads-tracker@7.3.5':
resolution: {integrity: sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==}
'@mui/material@7.3.5':
resolution: {integrity: sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.5.0
'@emotion/styled': ^11.3.0
'@mui/material-pigment-css': ^7.3.5
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/react':
optional: true
'@emotion/styled':
optional: true
'@mui/material-pigment-css':
optional: true
'@types/react':
optional: true
'@mui/private-theming@7.3.5':
resolution: {integrity: sha512-cTx584W2qrLonwhZLbEN7P5pAUu0nZblg8cLBlTrZQ4sIiw8Fbvg7GvuphQaSHxPxrCpa7FDwJKtXdbl2TSmrA==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@mui/styled-engine@7.3.5':
resolution: {integrity: sha512-zbsZ0uYYPndFCCPp2+V3RLcAN6+fv4C8pdwRx6OS3BwDkRCN8WBehqks7hWyF3vj1kdQLIWrpdv/5Y0jHRxYXQ==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.4.1
'@emotion/styled': ^11.3.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/react':
optional: true
'@emotion/styled':
optional: true
'@mui/system@7.3.5':
resolution: {integrity: sha512-yPaf5+gY3v80HNkJcPi6WT+r9ebeM4eJzrREXPxMt7pNTV/1eahyODO4fbH3Qvd8irNxDFYn5RQ3idHW55rA6g==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.5.0
'@emotion/styled': ^11.3.0
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/react':
optional: true
'@emotion/styled':
optional: true
'@types/react':
optional: true
'@mui/types@7.4.8':
resolution: {integrity: sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@mui/utils@7.3.5':
resolution: {integrity: sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@mui/x-internals@8.19.0':
resolution: {integrity: sha512-mMmiyJAN5fW27srXJjhXhXJa+w2xGO45rwcjws6OQc9rdXGdJqRXhBwJd+OT7J1xwSdFIIUhjZRTz1KAfCSGBg==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || ^19.0.0
'@mui/x-tree-view@8.19.0':
resolution: {integrity: sha512-1JPIczqK5qdDmo4p8KEpR4XZKxDHkw5dBoY1o+FNUdKmOipklQvg0+XL5wY75pjwzADngZR/6RYW08bhkt+Z3Q==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.9.0
'@emotion/styled': ^11.8.1
'@mui/material': ^5.15.14 || ^6.0.0 || ^7.0.0
'@mui/system': ^5.15.14 || ^6.0.0 || ^7.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/react':
optional: true
'@emotion/styled':
optional: true
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -879,6 +1059,9 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
@@ -1648,6 +1831,33 @@ packages:
'@radix-ui/rect@1.1.0':
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
'@react-spring/animated@10.0.3':
resolution: {integrity: sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@react-spring/core@10.0.3':
resolution: {integrity: sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@react-spring/rafz@10.0.3':
resolution: {integrity: sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==}
'@react-spring/shared@10.0.3':
resolution: {integrity: sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@react-spring/types@10.0.3':
resolution: {integrity: sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==}
'@react-spring/web@10.0.3':
resolution: {integrity: sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@rollup/rollup-android-arm-eabi@4.26.0':
resolution: {integrity: sha512-gJNwtPDGEaOEgejbaseY6xMFu+CPltsc8/T+diUTTbOQLqD+bnrJq9ulH6WD69TqwqWmrfRAtUv30cCFZlbGTQ==}
cpu: [arm]
@@ -2020,14 +2230,25 @@ packages:
'@types/node@22.10.1':
resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==}
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
'@types/prop-types@15.7.14':
resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/react-dom@18.3.5':
resolution: {integrity: sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==}
peerDependencies:
'@types/react': ^18.0.0
'@types/react-transition-group@4.4.12':
resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==}
peerDependencies:
'@types/react': '*'
'@types/react@18.3.18':
resolution: {integrity: sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==}
@@ -2243,6 +2464,10 @@ packages:
babel-dead-code-elimination@1.0.6:
resolution: {integrity: sha512-JxFi9qyRJpN0LjEbbjbN8g0ux71Qppn9R8Qe3k6QzHg2CaKsbUQtbn307LQGiDLGjV6JCtEFqfxzVig9MyDCHQ==}
babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'}
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@@ -2358,9 +2583,16 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
convert-source-map@1.9.0:
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cosmiconfig@7.1.0:
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
engines: {node: '>=10'}
cross-spawn@7.0.5:
resolution: {integrity: sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==}
engines: {node: '>= 8'}
@@ -2491,6 +2723,9 @@ packages:
resolution: {integrity: sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==}
engines: {node: '>=10.13.0'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
@@ -2624,6 +2859,9 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-root@1.1.0:
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -2731,6 +2969,9 @@ packages:
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
@@ -2780,6 +3021,9 @@ packages:
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -3166,6 +3410,10 @@ packages:
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
@@ -3185,6 +3433,10 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3377,6 +3629,9 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-is@19.2.0:
resolution: {integrity: sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
@@ -3456,9 +3711,6 @@ packages:
react: ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -3475,6 +3727,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -3592,6 +3847,9 @@ packages:
style-to-object@1.0.8:
resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==}
stylis@4.2.0:
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
sucrase@3.35.0:
resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -3891,6 +4149,10 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
yaml@2.6.0:
resolution: {integrity: sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==}
engines: {node: '>= 14'}
@@ -4049,10 +4311,6 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/runtime@7.26.0':
dependencies:
regenerator-runtime: 0.14.1
'@babel/runtime@7.28.4': {}
'@babel/template@7.25.9':
@@ -4098,6 +4356,100 @@ snapshots:
'@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@base-ui-components/utils@0.1.2(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@floating-ui/utils': 0.2.10
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
reselect: 5.1.1
use-sync-external-store: 1.6.0(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.18
'@emotion/babel-plugin@11.13.5':
dependencies:
'@babel/helper-module-imports': 7.25.9
'@babel/runtime': 7.28.4
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
'@emotion/serialize': 1.3.3
babel-plugin-macros: 3.1.0
convert-source-map: 1.9.0
escape-string-regexp: 4.0.0
find-root: 1.1.0
source-map: 0.5.7
stylis: 4.2.0
transitivePeerDependencies:
- supports-color
'@emotion/cache@11.14.0':
dependencies:
'@emotion/memoize': 0.9.0
'@emotion/sheet': 1.4.0
'@emotion/utils': 1.4.2
'@emotion/weak-memoize': 0.4.0
stylis: 4.2.0
'@emotion/hash@0.9.2': {}
'@emotion/is-prop-valid@1.4.0':
dependencies:
'@emotion/memoize': 0.9.0
'@emotion/memoize@0.9.0': {}
'@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@emotion/babel-plugin': 11.13.5
'@emotion/cache': 11.14.0
'@emotion/serialize': 1.3.3
'@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1)
'@emotion/utils': 1.4.2
'@emotion/weak-memoize': 0.4.0
hoist-non-react-statics: 3.3.2
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.18
transitivePeerDependencies:
- supports-color
'@emotion/serialize@1.3.3':
dependencies:
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
'@emotion/unitless': 0.10.0
'@emotion/utils': 1.4.2
csstype: 3.1.3
'@emotion/sheet@1.4.0': {}
'@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@emotion/babel-plugin': 11.13.5
'@emotion/is-prop-valid': 1.4.0
'@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1)
'@emotion/serialize': 1.3.3
'@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1)
'@emotion/utils': 1.4.2
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.18
transitivePeerDependencies:
- supports-color
'@emotion/unitless@0.10.0': {}
'@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@18.3.1)':
dependencies:
react: 18.3.1
'@emotion/utils@1.4.2': {}
'@emotion/weak-memoize@0.4.0': {}
'@esbuild/aix-ppc64@0.23.1':
optional: true
@@ -4303,6 +4655,8 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@floating-ui/utils@0.2.10': {}
'@floating-ui/utils@0.2.8': {}
'@hookform/resolvers@3.9.1(react-hook-form@7.54.0(react@18.3.1))':
@@ -4354,6 +4708,115 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@mui/core-downloads-tracker@7.3.5': {}
'@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@mui/core-downloads-tracker': 7.3.5
'@mui/system': 7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
'@mui/types': 7.4.8(@types/react@18.3.18)
'@mui/utils': 7.3.5(@types/react@18.3.18)(react@18.3.1)
'@popperjs/core': 2.11.8
'@types/react-transition-group': 4.4.12(@types/react@18.3.18)
clsx: 2.1.1
csstype: 3.1.3
prop-types: 15.8.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-is: 19.2.0
react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
optionalDependencies:
'@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
'@types/react': 18.3.18
'@mui/private-theming@7.3.5(@types/react@18.3.18)(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@mui/utils': 7.3.5(@types/react@18.3.18)(react@18.3.1)
prop-types: 15.8.1
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.18
'@mui/styled-engine@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@emotion/cache': 11.14.0
'@emotion/serialize': 1.3.3
'@emotion/sheet': 1.4.0
csstype: 3.1.3
prop-types: 15.8.1
react: 18.3.1
optionalDependencies:
'@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
'@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@mui/private-theming': 7.3.5(@types/react@18.3.18)(react@18.3.1)
'@mui/styled-engine': 7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(react@18.3.1)
'@mui/types': 7.4.8(@types/react@18.3.18)
'@mui/utils': 7.3.5(@types/react@18.3.18)(react@18.3.1)
clsx: 2.1.1
csstype: 3.1.3
prop-types: 15.8.1
react: 18.3.1
optionalDependencies:
'@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
'@types/react': 18.3.18
'@mui/types@7.4.8(@types/react@18.3.18)':
dependencies:
'@babel/runtime': 7.28.4
optionalDependencies:
'@types/react': 18.3.18
'@mui/utils@7.3.5(@types/react@18.3.18)(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@mui/types': 7.4.8(@types/react@18.3.18)
'@types/prop-types': 15.7.15
clsx: 2.1.1
prop-types: 15.8.1
react: 18.3.1
react-is: 19.2.0
optionalDependencies:
'@types/react': 18.3.18
'@mui/x-internals@8.19.0(@types/react@18.3.18)(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@mui/utils': 7.3.5(@types/react@18.3.18)(react@18.3.1)
react: 18.3.1
reselect: 5.1.1
use-sync-external-store: 1.6.0(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
'@mui/x-tree-view@8.19.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@base-ui-components/utils': 0.1.2(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mui/material': 7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mui/system': 7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
'@mui/utils': 7.3.5(@types/react@18.3.18)(react@18.3.1)
'@mui/x-internals': 8.19.0(@types/react@18.3.18)(react@18.3.1)
'@types/react-transition-group': 4.4.12(@types/react@18.3.18)
clsx: 2.1.1
prop-types: 15.8.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
optionalDependencies:
'@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -4430,6 +4893,8 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@popperjs/core@2.11.8': {}
'@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.0': {}
@@ -5201,6 +5666,38 @@ snapshots:
'@radix-ui/rect@1.1.0': {}
'@react-spring/animated@10.0.3(react@18.3.1)':
dependencies:
'@react-spring/shared': 10.0.3(react@18.3.1)
'@react-spring/types': 10.0.3
react: 18.3.1
'@react-spring/core@10.0.3(react@18.3.1)':
dependencies:
'@react-spring/animated': 10.0.3(react@18.3.1)
'@react-spring/shared': 10.0.3(react@18.3.1)
'@react-spring/types': 10.0.3
react: 18.3.1
'@react-spring/rafz@10.0.3': {}
'@react-spring/shared@10.0.3(react@18.3.1)':
dependencies:
'@react-spring/rafz': 10.0.3
'@react-spring/types': 10.0.3
react: 18.3.1
'@react-spring/types@10.0.3': {}
'@react-spring/web@10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@react-spring/animated': 10.0.3(react@18.3.1)
'@react-spring/core': 10.0.3(react@18.3.1)
'@react-spring/shared': 10.0.3(react@18.3.1)
'@react-spring/types': 10.0.3
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@rollup/rollup-android-arm-eabi@4.26.0':
optional: true
@@ -5526,12 +6023,20 @@ snapshots:
dependencies:
undici-types: 6.20.0
'@types/parse-json@4.0.2': {}
'@types/prop-types@15.7.14': {}
'@types/prop-types@15.7.15': {}
'@types/react-dom@18.3.5(@types/react@18.3.18)':
dependencies:
'@types/react': 18.3.18
'@types/react-transition-group@4.4.12(@types/react@18.3.18)':
dependencies:
'@types/react': 18.3.18
'@types/react@18.3.18':
dependencies:
'@types/prop-types': 15.7.14
@@ -5822,6 +6327,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
babel-plugin-macros@3.1.0:
dependencies:
'@babel/runtime': 7.28.4
cosmiconfig: 7.1.0
resolve: 1.22.8
bail@2.0.2: {}
balanced-match@1.0.2: {}
@@ -5934,8 +6445,18 @@ snapshots:
concat-map@0.0.1: {}
convert-source-map@1.9.0: {}
convert-source-map@2.0.0: {}
cosmiconfig@7.1.0:
dependencies:
'@types/parse-json': 4.0.2
import-fresh: 3.3.0
parse-json: 5.2.0
path-type: 4.0.0
yaml: 1.10.2
cross-spawn@7.0.5:
dependencies:
path-key: 3.1.1
@@ -6024,7 +6545,7 @@ snapshots:
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.26.0
'@babel/runtime': 7.28.4
csstype: 3.1.3
eastasianwidth@0.2.0: {}
@@ -6046,6 +6567,10 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.2.1
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
es-module-lexer@1.7.0:
optional: true
@@ -6237,6 +6762,8 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
find-root@1.1.0: {}
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -6350,6 +6877,10 @@ snapshots:
dependencies:
'@types/hast': 3.0.4
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
@@ -6391,6 +6922,8 @@ snapshots:
is-alphabetical: 2.0.1
is-decimal: 2.0.1
is-arrayish@0.2.1: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@@ -6450,8 +6983,7 @@ snapshots:
json-buffer@3.0.1: {}
json-parse-even-better-errors@2.3.1:
optional: true
json-parse-even-better-errors@2.3.1: {}
json-schema-traverse@0.4.1: {}
@@ -6965,6 +7497,13 @@ snapshots:
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse-json@5.2.0:
dependencies:
'@babel/code-frame': 7.26.2
error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse-ms@4.0.0: {}
path-exists@4.0.0: {}
@@ -6978,6 +7517,8 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
path-type@4.0.0: {}
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -7098,6 +7639,8 @@ snapshots:
react-is@18.3.1: {}
react-is@19.2.0: {}
react-markdown@10.1.0(@types/react@18.3.18)(react@18.3.1):
dependencies:
'@types/hast': 3.0.4
@@ -7159,7 +7702,7 @@ snapshots:
react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.26.0
'@babel/runtime': 7.28.4
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -7198,8 +7741,6 @@ snapshots:
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
regenerator-runtime@0.14.1: {}
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
@@ -7237,6 +7778,8 @@ snapshots:
require-from-string@2.0.2:
optional: true
reselect@5.1.1: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -7371,6 +7914,8 @@ snapshots:
dependencies:
inline-style-parser: 0.2.4
stylis@4.2.0: {}
sucrase@3.35.0:
dependencies:
'@jridgewell/gen-mapping': 0.3.5
@@ -7701,6 +8246,8 @@ snapshots:
yallist@3.1.1: {}
yaml@1.10.2: {}
yaml@2.6.0: {}
yocto-queue@0.1.0: {}

View File

@@ -54,6 +54,8 @@ export interface DashboardStats {
with_attachment_count: number; // Emails with attachments
without_attachment_count: number; // Emails without attachments
top_largest_emails: LargestEmail[]; // Top 10 largest emails
system_version: string, //The semantic version string of the currently running backend service
commit_hash: string //Git commit hash used to build this system version
}
export interface TimeBucket {

View File

@@ -82,7 +82,7 @@ export function EnvelopeListPagination({
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((size) => (
{[10, 20, 30, 40, 50, 100].map((size) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
@@ -91,7 +91,7 @@ export function EnvelopeListPagination({
</Select>
</div>
<div className='flex items-center justify-center text-sm font-medium'>
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
</div>
<div className='flex items-center space-x-2'>
<Button

View File

@@ -1,529 +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 React from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronRight } from 'lucide-react'
import { cva } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const treeVariants = cva(
'group hover:before:opacity-100 before:absolute before:rounded-lg before:left-0 px-2 before:w-full before:opacity-0 before:bg-accent/70 before:h-[2rem] before:-z-10'
)
const selectedTreeVariants = cva(
'before:opacity-100 before:bg-accent/70 text-accent-foreground'
)
interface TreeDataItem {
id: string
name: string
icon?: React.ComponentType<{ className?: string }>
openIcon?: React.ComponentType<{ className?: string }>
children?: TreeDataItem[]
badge?: React.ReactNode,
attributes?: React.ReactNode,
onClick?: () => void
}
type TreeProps = React.HTMLAttributes<HTMLDivElement> & {
data: TreeDataItem[] | TreeDataItem
onSelectChange?: (item: TreeDataItem | undefined) => void
onSelectItemsChange?: (items: TreeDataItem[]) => void
expandAll?: boolean
multiple?: boolean
clickRowToSelect?: boolean
initialSelectedItemIds?: string[]
defaultNodeIcon?: React.ComponentType<{ className?: string }>
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeView = React.forwardRef<HTMLDivElement, TreeProps>(
(
{
data,
onSelectChange,
onSelectItemsChange,
expandAll,
defaultLeafIcon,
defaultNodeIcon,
clickRowToSelect = true,
className,
multiple,
initialSelectedItemIds = [],
...props
},
ref
) => {
const [selectedItemIds, setSelectedItemIds] = React.useState<Set<string>>(
new Set(initialSelectedItemIds)
);
const callbacksRef = React.useRef({
onSelectChange,
onSelectItemsChange
});
React.useEffect(() => {
callbacksRef.current = {
onSelectChange,
onSelectItemsChange
};
}, [onSelectChange, onSelectItemsChange]);
const handleSelectChange = React.useCallback(
(item: TreeDataItem | undefined) => {
if (!item) return;
setSelectedItemIds(prev => {
const newSet = new Set(prev);
if (newSet.has(item.id)) {
newSet.delete(item.id);
} else {
if (!multiple) {
newSet.clear();
}
newSet.add(item.id);
}
setTimeout(() => {
if (callbacksRef.current.onSelectChange) {
callbacksRef.current.onSelectChange(newSet.has(item.id) ? item : undefined);
}
if (callbacksRef.current.onSelectItemsChange) {
const selectedItems = Array.from(newSet)
.map(id => findItemById(data, id))
.filter(Boolean) as TreeDataItem[];
callbacksRef.current.onSelectItemsChange(selectedItems);
}
}, 0);
return newSet;
});
},
[multiple, onSelectChange, onSelectItemsChange, data]
);
const expandedItemIds = React.useMemo(() => {
if (!initialSelectedItemIds || initialSelectedItemIds.length === 0) {
return [] as string[]
}
const ids: string[] = []
function walkTreeItems(
items: TreeDataItem[] | TreeDataItem,
targetIds: string[]
) {
if (Array.isArray(items)) {
for (let i = 0; i < items.length; i++) {
ids.push(items[i]!.id)
if (walkTreeItems(items[i]!, targetIds) && !expandAll) {
return true
}
if (!expandAll) ids.pop()
}
} else if (!expandAll && targetIds.includes(items.id)) {
return true
} else if (items.children) {
return walkTreeItems(items.children, targetIds)
}
}
walkTreeItems(data, initialSelectedItemIds)
return ids
}, [data, expandAll, initialSelectedItemIds])
return (
<div className={cn('overflow-hidden relative p-2', className)}>
<TreeItem
data={data}
ref={ref}
clickRowToSelect={clickRowToSelect}
selectedItemIds={selectedItemIds}
handleSelectChange={handleSelectChange}
expandedItemIds={expandedItemIds}
defaultLeafIcon={defaultLeafIcon}
defaultNodeIcon={defaultNodeIcon}
{...props}
/>
</div>
)
}
)
TreeView.displayName = 'TreeView'
// Helper function to find item by ID in tree
function findItemById(items: TreeDataItem[] | TreeDataItem, id: string): TreeDataItem | undefined {
if (Array.isArray(items)) {
for (const item of items) {
const found = findItemById(item, id);
if (found) return found;
}
} else {
if (items.id === id) return items;
if (items.children) {
return findItemById(items.children, id);
}
}
return undefined;
}
type TreeItemProps = TreeProps & {
selectedItemIds: Set<string>
handleSelectChange: (item: TreeDataItem | undefined) => void
expandedItemIds: string[]
clickRowToSelect?: boolean
defaultNodeIcon?: React.ComponentType<{ className?: string }>
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
(
{
className,
data,
selectedItemIds,
handleSelectChange,
clickRowToSelect,
expandedItemIds,
defaultNodeIcon,
defaultLeafIcon,
...props
},
ref
) => {
if (!Array.isArray(data)) {
data = [data]
}
return (
<div ref={ref} role="tree" className={className} {...props}>
<ul>
{data.map((item) => (
<li key={item.id}>
{item.children ? (
<TreeNode
item={item}
selectedItemIds={selectedItemIds}
expandedItemIds={expandedItemIds}
clickRowToSelect={clickRowToSelect}
handleSelectChange={handleSelectChange}
defaultNodeIcon={defaultNodeIcon}
defaultLeafIcon={defaultLeafIcon}
/>
) : (
<TreeLeaf
item={item}
clickRowToSelect={clickRowToSelect}
selectedItemIds={selectedItemIds}
handleSelectChange={handleSelectChange}
defaultLeafIcon={defaultLeafIcon}
/>
)}
</li>
))}
</ul>
</div>
)
}
)
TreeItem.displayName = 'TreeItem'
interface TreeNodeProps {
item: TreeDataItem
handleSelectChange: (item: TreeDataItem | undefined) => void
expandedItemIds: string[]
clickRowToSelect?: boolean
selectedItemIds: Set<string>
defaultNodeIcon?: React.ComponentType<{ className?: string }>
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeNode = ({
item,
handleSelectChange,
expandedItemIds,
clickRowToSelect,
selectedItemIds,
defaultNodeIcon,
defaultLeafIcon
}: TreeNodeProps) => {
const [value, setValue] = React.useState(
expandedItemIds.includes(item.id) ? [item.id] : []
)
const isSelected = selectedItemIds.has(item.id);
return (
<AccordionPrimitive.Root
type="multiple"
value={value}
onValueChange={(s) => setValue(s)}
>
<AccordionPrimitive.Item value={item.id}>
<AccordionTrigger
className={cn(
"flex items-center w-full py-2",
treeVariants(),
isSelected && selectedTreeVariants()
)}
onClick={(e) => {
e.stopPropagation();
if (clickRowToSelect) {
handleSelectChange(item);
}
item.onClick?.();
}}
>
<div className="flex items-center min-w-0 flex-shrink-0">
<TreeIcon
item={item}
isSelected={isSelected}
isOpen={value.includes(item.id)}
default={defaultNodeIcon}
onCheck={() => { handleSelectChange(item) }}
/>
<span className="ml-2 text-sm truncate">
{item.name}
</span>
</div>
{item.attributes && (
<span className="mx-auto text-sm text-muted-foreground whitespace-nowrap">
{item.attributes}
</span>
)}
{item.badge && (
<TreeBadge isSelected={isSelected}>
{item.badge}
</TreeBadge>
)}
</AccordionTrigger>
<AccordionContent className="ml-4 pl-1 border-l">
<TreeItem
data={item.children ? item.children : item}
selectedItemIds={selectedItemIds}
clickRowToSelect={clickRowToSelect}
handleSelectChange={handleSelectChange}
expandedItemIds={expandedItemIds}
defaultLeafIcon={defaultLeafIcon}
defaultNodeIcon={defaultNodeIcon}
/>
</AccordionContent>
</AccordionPrimitive.Item>
</AccordionPrimitive.Root>
)
}
// function hasSelectedChildrenRecursive(item: TreeDataItem, selectedItemIds: Set<string>): boolean {
// if (!item.children) return false;
// return item.children.some(child =>
// selectedItemIds.has(child.id) ||
// (child.children && hasSelectedChildrenRecursive(child, selectedItemIds)));
// }
interface TreeLeafProps extends React.HTMLAttributes<HTMLDivElement> {
item: TreeDataItem
selectedItemIds: Set<string>
clickRowToSelect?: boolean
handleSelectChange: (item: TreeDataItem | undefined) => void
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeLeaf = React.forwardRef<HTMLDivElement, TreeLeafProps>(
(
{
className,
item,
clickRowToSelect,
selectedItemIds,
handleSelectChange,
defaultLeafIcon,
...props
},
ref
) => {
return (
<div
ref={ref}
className={cn(
"ml-5 flex items-center py-2 cursor-pointer before:right-1",
treeVariants(),
className,
selectedItemIds.has(item.id) && selectedTreeVariants()
)}
onClick={(e) => {
e.stopPropagation();
if (clickRowToSelect) {
handleSelectChange(item);
}
item.onClick?.();
}}
{...props}
>
<div className="flex items-center min-w-0 flex-shrink-0">
<TreeIcon
item={item}
isSelected={selectedItemIds.has(item.id)}
default={defaultLeafIcon}
onCheck={() => { handleSelectChange(item) }}
/>
<span className="ml-2 text-sm truncate">
{item.name}
</span>
</div>
{item.attributes && (
<span className="mx-auto text-sm text-muted-foreground whitespace-nowrap">
{item.attributes}
</span>
)}
{item.badge && (
<TreeBadge isSelected={selectedItemIds.has(item.id)}>
{item.badge}
</TreeBadge>
)}
</div>
)
}
)
TreeLeaf.displayName = 'TreeLeaf'
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header>
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 w-full items-center py-2 transition-all first:[&[data-state=open]>svg]:rotate-90',
className
)}
{...props}
onClick={(e) => {
e.stopPropagation()
if (props.onClick) {
props.onClick(e)
}
}}
>
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200 text-accent-foreground/50 mr-1" />
{children}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
'overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
className
)}
{...props}
>
<div className="pb-1 pt-0">{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
interface TreeIconProps {
item: TreeDataItem;
isOpen?: boolean;
isSelected?: boolean;
default?: React.ComponentType<{ className?: string }>;
onCheck?: (checked: boolean) => void;
}
const TreeIcon = ({
item,
isOpen,
isSelected,
default: defaultIcon,
onCheck,
}: TreeIconProps) => {
let Icon = defaultIcon;
if (isOpen && item.openIcon) {
Icon = item.openIcon;
} else if (item.icon) {
Icon = item.icon;
}
return (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => onCheck?.(!isSelected)}
onClick={(e) => e.stopPropagation()}
className={cn(
"h-4 w-4 rounded border border-primary dark:border-white shadow transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isSelected
? "bg-black dark:bg-white text-primary-foreground"
: "bg-transparent",
"appearance-none cursor-pointer flex items-center justify-center relative",
"after:content-[''] after:w-1.5 after:h-2",
"after:border-r-2 after:border-b-2 after:rotate-45 after:mt-[-2px]",
isSelected
? "after:block after:border-white dark:after:border-black after:z-10"
: "after:hidden"
)}
/>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
</div>
);
};
interface TreeBadgeProps {
children: React.ReactNode
isSelected: boolean
showOnSelectedOnly?: boolean
}
const TreeBadge = ({
children,
isSelected,
showOnSelectedOnly = false
}: TreeBadgeProps) => {
return (
<div
className={cn(
showOnSelectedOnly
? isSelected
? 'block'
: 'hidden'
: 'block',
'absolute right-3 group-hover:block'
)}
>
{children}
</div>
)
}
export { TreeView, type TreeDataItem }

View File

@@ -99,6 +99,7 @@ export type Account = {
use_proxy?: number;
};
enabled: boolean;
use_dangerous: boolean;
date_since?: {
fixed?: string;
relative?: {
@@ -116,6 +117,7 @@ const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }),
imap: getImapConfigSchema(isEdit, t),
enabled: z.boolean(),
use_dangerous: z.boolean(),
date_since: getDateSelectionSchema(t).optional(),
folder_limit: z
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
@@ -137,7 +139,7 @@ export type Steps = [
const getSteps = (t: (key: string) => string): Steps => [
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email"] },
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap"] },
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous"] },
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
];
@@ -164,6 +166,7 @@ const defaultValues: Account = {
use_proxy: undefined
},
enabled: true,
use_dangerous: false,
date_since: undefined,
folder_limit: undefined,
sync_interval_min: 10,
@@ -189,6 +192,7 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
email: currentRow.email,
imap,
enabled: currentRow.enabled,
use_dangerous: currentRow.use_dangerous,
date_since: currentRow.date_since ?? undefined,
folder_limit: currentRow.folder_limit ?? undefined,
sync_interval_min: currentRow.sync_interval_min ?? 10,
@@ -266,6 +270,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
},
},
enabled: data.enabled,
use_dangerous: data.use_dangerous,
date_since: data.date_since,
folder_limit: data.folder_limit,
sync_interval_min: data.sync_interval_min,

View File

@@ -37,10 +37,10 @@ import { AccountModel } from '../data/schema';
import { useTranslation } from 'react-i18next';
const accountSchema = () =>
const accountSchema = (t: (key: string) => string) =>
z.object({
name: z.string().optional(),
email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }),
email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }),
enabled: z.boolean()
});
@@ -85,7 +85,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
const form = useForm<NoSyncAccount>({
mode: "all",
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
resolver: zodResolver(accountSchema()),
resolver: zodResolver(accountSchema(t)),
});
const queryClient = useQueryClient();
@@ -118,7 +118,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
`${isEdit ? 'Update' : 'Creation'} failed, please try again later`;
(isEdit ? t('accounts.updateFailed') : t('accounts.creationFailed'));
toast({
variant: "destructive",
@@ -134,7 +134,8 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
const commonData = {
email: data.email,
name: data.name,
enabled: data.enabled
enabled: data.enabled,
use_dangerous: false
};
if (isEdit) {
updateMutation.mutate(commonData);

View File

@@ -33,7 +33,7 @@ export function OAuth2Action({ row }: DataTableRowActionsProps) {
const account_type = mailer.account_type;
if (account_type === "NoSync") {
return <span className="text-xs text-muted-foreground">n/a</span>
return <Button variant={"ghost"} className="text-xs text-muted-foreground">n/a</Button>
}
const isOAuth2 = mailer.imap?.auth.auth_type === "OAuth2"

View File

@@ -147,7 +147,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
</span>
) : state.initial_sync_start_time ? (
<span className="flex items-center gap-1 text-blue-600">
{t('runningState.inProgress')}
{t('accounts.runningState.inProgress')}
</span>
) : (
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>

View File

@@ -38,6 +38,7 @@ import { Account } from "./action-dialog";
import { PasswordInput } from "@/components/password-input";
import useProxyList from "@/hooks/use-proxy";
import { useTranslation } from "react-i18next";
import { Checkbox } from "@/components/ui/checkbox";
interface StepProps {
isEdit: boolean;
@@ -110,6 +111,23 @@ export default function Step2({ isEdit }: StepProps) {
</FormItem>
)}
/>
<FormField
control={control}
name="use_dangerous"
render={({ field }) => (
<FormItem className="flex flex-col items-start gap-y-1">
<FormLabel>{t('accounts.useDangerous')}:</FormLabel>
<FormControl>
<Checkbox
className="mt-2"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('accounts.useDangerousDescription')}</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="imap.auth.auth_type"

View File

@@ -93,7 +93,6 @@ export default function Step3() {
</FormItem>
)}
/>
<FormLabel className="flex items-center justify-between">{t('accounts.dateSince')}:</FormLabel>
<RadioGroup
defaultValue={rangeType}

View File

@@ -26,20 +26,119 @@ import {
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, CheckSquare, Square } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AccountModel } from '../data/schema'
import { toast } from '@/hooks/use-toast'
import { list_mailboxes } from '@/api/mailbox/api'
import { buildTree } from '@/lib/build-tree'
import { TreeDataItem, TreeView } from '@/components/tree-view'
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree'
import { Skeleton } from '@/components/ui/skeleton'
import { update_account } from '@/api/account/api'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import axios, { AxiosError } from 'axios'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useTranslation } from 'react-i18next'
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
import { useTheme } from '@/context/theme-context'
import React from 'react'
import Collapse from '@mui/material/Collapse';
import { styled } from '@mui/material/styles';
import { TreeItemCheckbox, TreeItemContent, TreeItemIconContainer, TreeItemLabel, TreeItemRoot } from '@mui/x-tree-view/TreeItem'
import { TreeItemDragAndDropOverlay, TreeItemIcon, TreeItemProvider, TreeViewBaseItem, TreeViewSelectionPropagation, useTreeItem, useTreeItemModel, UseTreeItemParameters } from '@mui/x-tree-view'
import { animated, useSpring } from '@react-spring/web';
import { TransitionProps } from '@mui/material/transitions'
function getParentIds(tree: TreeViewBaseItem[]): string[] {
const result: string[] = [];
function traverse(nodes: TreeViewBaseItem[]) {
for (const node of nodes) {
if (node.children && node.children.length > 0) {
result.push(node.id);
traverse(node.children);
}
}
}
traverse(tree);
return result;
}
interface CustomLabelProps {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
children: React.ReactNode;
icon?: React.ElementType;
expandable?: boolean;
}
function CustomLabel({
expandable,
exists,
attributes,
children,
...other
}: CustomLabelProps) {
return (
<TreeItemLabel
{...other}
sx={{
display: 'flex',
alignItems: 'center',
}}
>
<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>
{exists !== undefined && (
<span
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
>
{exists}
</span>
)}
</TreeItemLabel>
);
}
const CustomCollapse = styled(Collapse)({
padding: 0,
});
const AnimatedCollapse = animated(CustomCollapse);
function TransitionComponent(props: TransitionProps) {
const style = useSpring({
to: {
opacity: props.in ? 1 : 0,
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
},
});
return <AnimatedCollapse style={style} {...props} />;
}
interface CustomTreeItemProps
extends Omit<UseTreeItemParameters, 'rootRef'>,
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
interface Props {
open: boolean
@@ -48,71 +147,151 @@ interface Props {
}
export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
const [selectedFolders, setSelectedFolders] = useState<string[]>(currentRow.sync_folders || []);
const [selectedItems, setSelectedItems] = React.useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [allIds, setAllIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const [itemsWithChildren, setItemsWithChildren] = useState<string[]>([]);
const [mailboxes, setMailboxes] = useState<MailboxData[]>([]);
const [selectionPropagation, setSelectionPropagation] =
React.useState<TreeViewSelectionPropagation>({
parents: false,
descendants: true,
});
const [treeData, setTreeData] = useState<TreeViewBaseItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const queryClient = useQueryClient();
const { t } = useTranslation()
const { data: mailboxes, isLoading } = useQuery({
queryKey: ['account-mailboxes', currentRow.id],
queryFn: () => list_mailboxes(currentRow.id, true),
enabled: open,
});
const { theme } = useTheme()
// Convert mailbox names to IDs for initial selection
const initialSelectedItemIds = useMemo(() => {
if (!mailboxes) return [];
return mailboxes
.filter(mailbox => selectedFolders.includes(mailbox.name))
.map(mailbox => mailbox.id.toString());
}, [mailboxes, selectedFolders]);
useEffect(() => {
if (!open) return;
let cancelled = false;
const fetchMailboxes = async () => {
setIsLoading(true);
try {
const data = await list_mailboxes(currentRow.id, true);
if (!cancelled) {
setMailboxes(data);
const allIds = data.map(mailbox => String(mailbox.id));
setAllIds(allIds);
const treeData = useMemo(() => {
if (!mailboxes) return [];
return buildTree(mailboxes, undefined, true, true);
}, [mailboxes]);
const tree = buildTree(data);
setTreeData(tree);
const itemsWithChildren = getParentIds(tree);
setItemsWithChildren(itemsWithChildren);
setExpandedItems(itemsWithChildren);
const sync_folders = data
.filter(mailbox => currentRow.sync_folders.includes(mailbox.name))
.map(mailbox => mailbox.id.toString());
setSelectedItems(sync_folders);
setError(undefined);
}
} catch (err: any) {
if (axios.isAxiosError(err)) {
const resData = err.response?.data;
if (resData) {
setError(`Error ${resData.code || ''}: ${resData.message || ''}`);
} else {
setError(err.message);
}
}
} finally {
if (!cancelled) setIsLoading(false);
}
};
fetchMailboxes();
return () => {
cancelled = true;
};
}, [currentRow, open]);
const handleSelectItems = useCallback((selectedItems: TreeDataItem[]) => {
const allMailboxes = mailboxes || [];
const selected = selectedItems
.map(item => mailboxes?.find(m => m.id === parseInt(item.id, 10))?.name)
.filter(Boolean) as string[];
const allMailSelected = selected.some(selectedName => {
const mailbox = allMailboxes.find(m => m.name === selectedName);
if (!mailbox) return false;
return mailbox.attributes.some(a => a.attr === 'All');
const handleExpandedItemsChange = (
_event: React.SyntheticEvent | null,
itemIds: string[],
) => {
setExpandedItems(itemIds);
};
const handleExpandClick = () => {
setExpandedItems((oldExpanded) =>
oldExpanded.length === 0 ? itemsWithChildren : [],
);
};
const CustomTreeItem = useMemo(() => {
return React.forwardRef(function CustomTreeItem(
props: CustomTreeItemProps,
ref: React.Ref<HTMLLIElement>,
) {
const { id, itemId, label, disabled, children, ...other } = props;
const {
getContextProviderProps,
getRootProps,
getContentProps,
getIconContainerProps,
getCheckboxProps,
getLabelProps,
getGroupTransitionProps,
getDragAndDropOverlayProps,
status,
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
return (
<TreeItemProvider {...getContextProviderProps()}>
<TreeItemRoot {...getRootProps(other)}>
<TreeItemContent {...getContentProps()}>
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIcon status={status} />
</TreeItemIconContainer>
<TreeItemCheckbox {...getCheckboxProps()} />
<CustomLabel
{...getLabelProps({
exists: item.exists,
attributes: item.attributes,
expandable: status.expandable && status.expanded,
})}
/>
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
</TreeItemContent>
{children && <TransitionComponent {...getGroupTransitionProps()} />}
</TreeItemRoot>
</TreeItemProvider>
);
});
}, [theme]);
if (allMailSelected) {
const handleSelectAll = useCallback(() => {
const selectedSet = new Set(allIds);
const selectedAllMailbox = mailboxes.find((mb) =>
selectedSet.has(String(mb.id)) &&
mb.attributes?.some(a => a.attr === 'All')
);
if (selectedAllMailbox) {
toast({
title: t('accounts.allMailFolderSelected'),
description: t('accounts.allMailFolderSelectedDesc'),
action: <ToastAction altText={t('common.ok')}>{t('common.ok')}</ToastAction>,
});
}
setSelectedFolders(selected);
}, [mailboxes]);
const handleSelectAll = useCallback(() => {
if (!mailboxes) return;
const validFolderNames = mailboxes
.filter(mailbox => {
const isAllMail = mailbox.attributes.some(a => a.attr === 'All');
if (isAllMail) return false;
return true;
})
.map(m => m.name);
setSelectedFolders(validFolderNames);
if (validFolderNames.length < mailboxes.length) {
toast({
description: t('accounts.allMailSkipped'),
});
}
}, [mailboxes]);
setSelectedItems(allIds);
}, [allIds]);
const handleDeselectAll = useCallback(() => {
setSelectedFolders([]);
setSelectedItems([]);
}, []);
@@ -149,8 +328,30 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
console.error(error);
}
const handleSelectedItemsChange = (
_event: React.SyntheticEvent | null,
newSelectedItems: string[],
) => {
const selectedSet = new Set(newSelectedItems);
const selectedAllMailbox = mailboxes.find((mb) =>
selectedSet.has(String(mb.id)) &&
mb.attributes?.some(a => a.attr === 'All')
);
if (selectedAllMailbox) {
toast({
title: t('accounts.allMailFolderSelected'),
description: t('accounts.allMailFolderSelectedDesc'),
action: <ToastAction altText={t('common.ok')}>{t('common.ok')}</ToastAction>,
});
}
setSelectedItems(newSelectedItems);
};
const handleSubmit = async () => {
if (selectedFolders.length === 0) {
if (selectedItems.length === 0) {
toast({
title: t('common.error'),
description: t('accounts.selectAtLeastOneFolder'),
@@ -159,14 +360,23 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
return;
}
setIsSubmitting(true);
const selectedNames: string[] = [];
const idSet = new Set(selectedItems);
for (const mailbox of mailboxes) {
if (idSet.has(String(mailbox.id))) {
selectedNames.push(mailbox.name);
}
}
updateMutation.mutate({
sync_folders: selectedFolders,
sync_folders: selectedNames,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{t('accounts.selectSyncFolders')}</DialogTitle>
<DialogDescription>
@@ -175,13 +385,13 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between pt-2">
<div className="flex gap-2">
<div className="flex flex-col pt-2 gap-2">
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
disabled={isLoading || !mailboxes || mailboxes.length === 0}
disabled={isLoading || !allIds || allIds.length === 0}
className="h-8"
>
<CheckSquare className="w-4 h-4 mr-2" />
@@ -191,23 +401,62 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
variant="outline"
size="sm"
onClick={handleDeselectAll}
disabled={isLoading || selectedFolders.length === 0}
disabled={isLoading || allIds.length === 0}
className="h-8"
>
<Square className="w-4 h-4 mr-2" />
{t('common.deselectAll')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setSelectionPropagation(prev => ({
...prev,
descendants: !prev.descendants,
}))
}
disabled={isLoading}
className="h-8"
>
{selectionPropagation.descendants ? <CheckSquare className="w-4 h-4 mr-2" /> : <Square className="w-4 h-4 mr-2" />}
{t('accounts.folderSync.autoSelectDescendants')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setSelectionPropagation(prev => ({
...prev,
parents: !prev.parents,
}))
}
disabled={isLoading}
className="h-8"
>
{selectionPropagation.parents ? <CheckSquare className="w-4 h-4 mr-2" /> : <Square className="w-4 h-4 mr-2" />}
{t('accounts.folderSync.autoSelectParents')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExpandClick}
disabled={isLoading}>
{expandedItems.length === 0 ? t('accounts.folderSync.expandAll') : t('accounts.folderSync.collapseAll')}
</Button>
</div>
<div className="text-sm text-muted-foreground">
{t('accounts.foldersSelected', { count: selectedFolders.length })}
{t('accounts.foldersSelected', { count: selectedItems.length })}
</div>
</div>
<ScrollArea className="h-[30rem] w-full pr-4 -mr-4 py-1">
<ScrollArea className="h-[35rem] w-full pr-4 -mr-4 py-1">
{isLoading && (
<div className="p-8 space-y-8">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm font-medium">Loading mailbox folders</span>
<span className="text-sm font-medium">{t('accounts.folderSync.loadingMailboxFolders')}</span>
</div>
<div className="space-y-2">
@@ -218,16 +467,23 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
</div>
)}
{!isLoading && (
<TreeView
key={selectedFolders.length > 0 ? `tree-${selectedFolders.length}-${selectedFolders[0]}` : 'tree-empty'}
data={treeData}
multiple
expandAll
clickRowToSelect={false}
initialSelectedItemIds={initialSelectedItemIds}
onSelectItemsChange={handleSelectItems}
<RichTreeView
multiSelect
checkboxSelection
items={treeData}
expandedItems={expandedItems}
onExpandedItemsChange={handleExpandedItemsChange}
selectionPropagation={selectionPropagation}
selectedItems={selectedItems}
onSelectedItemsChange={handleSelectedItemsChange}
slots={{ item: CustomTreeItem }}
/>
)}
{error && (
<div className="mt-auto p-2 text-red-600 text-sm font-medium">
{error}
</div>
)}
</ScrollArea>
</div>
@@ -237,14 +493,14 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || isLoading}
disabled={isSubmitting || isLoading || !!error}
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -60,4 +60,5 @@ export interface AccountModel {
created_at: number;
updated_at: number;
use_proxy?: number
use_dangerous: boolean
}

View File

@@ -10,11 +10,11 @@
//
// 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
// 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/>.
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -22,7 +22,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, BarChart, Bar } from 'recharts';
import { Mail, HardDrive, Database, Users, Inbox } from 'lucide-react';
import { Mail, HardDrive, Database, Users, Inbox, Info } from 'lucide-react';
import { formatBytes, formatNumber } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query';
import { get_dashboard_stats, TimeBucket } from '@/api/system/api';
@@ -94,7 +94,7 @@ export default function MailArchiveDashboard() {
queryFn: get_dashboard_stats,
});
const { t } = useTranslation();
const { t } = useTranslation();
const totalAttachments = (stats?.with_attachment_count ?? 0) + (stats?.without_attachment_count ?? 0);
const attachmentRatio = totalAttachments > 0 ? (stats?.with_attachment_count ?? 0) / totalAttachments : 0;
@@ -123,8 +123,8 @@ export default function MailArchiveDashboard() {
<Skeleton className="h-7 w-28 rounded-full" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
{[...Array(5)].map((_, i) => (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
{[...Array(6)].map((_, i) => (
<MetricCardSkeleton key={i} />
))}
</div>
@@ -156,8 +156,8 @@ export default function MailArchiveDashboard() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('dashboard.mailAccounts')}</CardTitle>
@@ -212,6 +212,29 @@ export default function MailArchiveDashboard() {
<p className="text-xs text-muted-foreground">{t('dashboard.tantivyIndex')}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('dashboard.systemVersion')}</CardTitle>
<Info className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>
{stats!.system_version ? (
<a
href={`https://github.com/rustmailer/bichon/releases/tag/${stats!.system_version}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{stats!.system_version}
</a>
) : 'N/A'}
</div>
<p className="text-xs text-muted-foreground">
{stats!.commit_hash ?? 'N/A'}
</p>
</CardContent>
</Card>
</div>
<Tabs defaultValue="trend" className="space-y-4">
@@ -406,6 +429,11 @@ export default function MailArchiveDashboard() {
</TabsContent>
</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>
</Main>
</>
);

View File

@@ -60,7 +60,6 @@ export function MailList({
}
}
const hasSelected = (mailId: number) => {
return selected.has(mailId);
}
@@ -77,15 +76,14 @@ export function MailList({
});
}
if (isLoading) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 flex-1 max-w-xs" />
<Skeleton className="h-3 w-16 ml-auto" />
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1 max-w-xs" />
<Skeleton className="h-2.5 w-12 ml-auto" />
</div>
))}
</div>
@@ -95,7 +93,7 @@ export function MailList({
return (
<div className="divide-y divide-border">
{items.length > 0 && (
<div className="flex items-center gap-3 px-3 py-2 bg-muted/30">
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
<Checkbox
checked={
selected.size === items.length && items.length > 0
@@ -123,7 +121,7 @@ export function MailList({
<div
key={index}
className={cn(
"group flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors",
"group flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
"hover:bg-accent/50",
isSelected && "bg-accent"
)}
@@ -140,12 +138,11 @@ export function MailList({
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0"
/>
<MailIcon className="h-4 w-4 text-muted-foreground shrink-0" />
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
{/* LEFT AREA: From + Subject + Tags */}
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-1">
<div className="flex items-center gap-1 min-w-0">
<p className="text-sm font-medium truncate">{item.from}</p>
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
{item.subject}
@@ -156,7 +153,7 @@ export function MailList({
</h3>
{/* TAGS BELOW SUBJECT */}
<div className="flex flex-wrap gap-1 mt-0.5">
<div className="flex flex-wrap gap-1 mt-0.25">
{item.tags?.map((tag, i) => (
<Badge
key={i}
@@ -169,10 +166,10 @@ export function MailList({
</div>
{/* RIGHT AREA actions & meta */}
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-2 text-xs text-muted-foreground">
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
{hasAttachments && (
<div className="flex items-center gap-1">
<Paperclip className="h-3.5 w-3.5" />
<Paperclip className="h-3 w-3" />
<span>{item.attachments?.length}</span>
</div>
)}
@@ -190,7 +187,7 @@ export function MailList({
}}
className="p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-all"
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
@@ -200,4 +197,4 @@ export function MailList({
{totalSelected > 0 && <MailBulkActions />}
</div>
)
}
}

View File

@@ -27,11 +27,9 @@ import {
import { Separator } from "@/components/ui/separator"
import { TooltipProvider } from "@/components/ui/tooltip"
import { AccountSwitcher } from "./account-switcher"
import { TreeView } from "@/components/tree-view"
import { ScrollArea } from "@/components/ui/scroll-area"
import { list_mailboxes, MailboxData } from "@/api/mailbox/api"
import { useQuery } from "@tanstack/react-query"
import { buildTree } from "../../../lib/build-tree"
import { Skeleton } from "@/components/ui/skeleton"
import MailboxProvider, { MailboxDialogType } from "../context"
import useDialogState from "@/hooks/use-dialog-state"
@@ -44,6 +42,14 @@ import { EnvelopeDeleteDialog } from "./delete-dialog"
import Logo from '@/assets/logo.svg'
import { EmailEnvelope } from "@/api"
import { EnvelopeListPagination } from "@/components/pagination"
import { RichTreeView, TreeItemCheckbox, TreeItemContent, TreeItemDragAndDropOverlay, TreeItemIcon, TreeItemIconContainer, TreeItemLabel, TreeItemProvider, TreeItemRoot, useTreeItem, useTreeItemModel, UseTreeItemParameters } from "@mui/x-tree-view"
import { buildTree, ExtendedTreeItemProps } from "@/lib/build-tree"
import { useTheme } from "@/context/theme-context"
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"
interface MailProps {
@@ -73,6 +79,83 @@ const useListMessages = ({ accountId, mailboxId, page, page_size }: ListMessages
};
interface CustomLabelProps {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
children: React.ReactNode;
icon?: React.ElementType;
expandable?: boolean;
}
function CustomLabel({
expandable,
exists,
attributes,
children,
...other
}: CustomLabelProps) {
return (
<TreeItemLabel
{...other}
sx={{
display: 'flex',
alignItems: 'center',
}}
>
<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>
{exists !== undefined && (
<span
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
>
{exists}
</span>
)} */}
</TreeItemLabel>
);
}
const CustomCollapse = styled(Collapse)({
padding: 0,
});
const AnimatedCollapse = animated(CustomCollapse);
function TransitionComponent(props: TransitionProps) {
const style = useSpring({
to: {
opacity: props.in ? 1 : 0,
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
},
});
return <AnimatedCollapse style={style} {...props} />;
}
interface CustomTreeItemProps
extends Omit<UseTreeItemParameters, 'rootRef'>,
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
export function Mail({
defaultLayout = [20, 80],
defaultCollapsed = false,
@@ -88,6 +171,7 @@ 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 { theme } = useTheme()
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
queryKey: ['account-mailboxes', `${selectedAccountId}`],
@@ -95,6 +179,9 @@ export function Mail({
enabled: !!selectedAccountId,
})
const tree = buildTree(mailboxes ?? []);
const { data: envelopes, isLoading: isMessagesLoading, isError, error } = useListMessages({
accountId: selectedAccountId,
mailboxId: selectedMailbox?.id,
@@ -126,6 +213,65 @@ export function Mail({
}
}, [isError, error]);
const handleItemSelectionToggle = (
_event: React.SyntheticEvent | null,
itemId: string,
isSelected: boolean,
) => {
if (isSelected) {
setSelectedMailbox(mailboxes?.find(m => String(m.id) === itemId))
setPage(0);
}
};
const CustomTreeItem = React.useMemo(() => {
return React.forwardRef(function CustomTreeItem(
props: CustomTreeItemProps,
ref: React.Ref<HTMLLIElement>,
) {
const { id, itemId, label, disabled, children, ...other } = props;
const {
getContextProviderProps,
getRootProps,
getContentProps,
getIconContainerProps,
getCheckboxProps,
getLabelProps,
getGroupTransitionProps,
getDragAndDropOverlayProps,
status,
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
return (
<TreeItemProvider {...getContextProviderProps()}>
<TreeItemRoot {...getRootProps(other)}>
<TreeItemContent {...getContentProps()}>
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIcon status={status} />
</TreeItemIconContainer>
<TreeItemCheckbox {...getCheckboxProps()} />
<CustomLabel
{...getLabelProps({
exists: item.exists,
attributes: item.attributes,
expandable: status.expandable && status.expanded,
})}
/>
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
</TreeItemContent>
{children && <TransitionComponent {...getGroupTransitionProps()} />}
</TreeItemRoot>
</TreeItemProvider>
);
});
}, [theme]);
return (
<MailboxProvider value={{ open, setOpen, currentMailbox: selectedMailbox, selectedAccountId, setCurrentMailbox: setSelectedMailbox, currentEnvelope: selectedEvelope, setCurrentEnvelope: setSelectedEvelope, deleteIds, setDeleteIds, selected, setSelected }}>
<TooltipProvider delayDuration={0}>
@@ -184,17 +330,23 @@ export function Mail({
))}
</div>
) : (
<TreeView
data={buildTree(mailboxes ?? [])}
clickRowToSelect={true}
onSelectChange={(item) => {
if (item) {
setSelectedMailbox(mailboxes?.find(m => m.id === parseInt(item.id, 10)))
setPage(0);
} else {
setSelectedMailbox(undefined)
}
}}
// <TreeView
// data={buildTree(mailboxes ?? [])}
// clickRowToSelect={true}
// onSelectChange={(item) => {
// if (item) {
// setSelectedMailbox(mailboxes?.find(m => m.id === parseInt(item.id, 10)))
// setPage(0);
// } else {
// setSelectedMailbox(undefined)
// }
// }}
// />
<RichTreeView
//checkboxSelection
items={tree}
onItemSelectionToggle={handleItemSelectionToggle}
slots={{ item: CustomTreeItem }}
/>
)}
</ScrollArea>

View File

@@ -21,7 +21,7 @@ import { cn, formatBytes } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns"
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Checkbox } from "@/components/ui/checkbox" // shadcn Checkbox
import { Checkbox } from "@/components/ui/checkbox"
import { EmailEnvelope } from "@/api"
import { useSearchContext } from "./context"
import { MailBulkActions } from "./bulk-actions"
@@ -62,6 +62,7 @@ export function MailList({
});
}
}
const toggleToDelete = (accountId: number, mailId: number) => {
setToDelete(prev => {
const next = new Map(prev);
@@ -115,11 +116,11 @@ export function MailList({
return (
<div className="divide-y divide-border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-3">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-3 w-20" />
<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>
@@ -129,7 +130,7 @@ export function MailList({
return (
<div className="divide-y divide-border">
{items.length > 0 && (
<div className="flex items-center gap-3 px-3 py-2 bg-muted/30">
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
<Checkbox
checked={
totalSelected === items.length && items.length > 0
@@ -158,7 +159,7 @@ export function MailList({
<div
key={index}
className={cn(
"flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors",
"flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
"hover:bg-accent/50",
isSelectedRow && "bg-accent"
)}
@@ -175,41 +176,35 @@ export function MailList({
className="h-4 w-4 shrink-0"
/>
<MailIcon className="h-4 w-4 text-muted-foreground shrink-0" />
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
{/* LEFT AREA: From + Subject + Tags */}
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0">
{/* from + subject (large screen side by side, small screen subject hidden) */}
<div className="flex items-center gap-2 min-w-0">
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-1 min-w-0">
<p className="text-sm font-medium truncate">{item.from}</p>
{/* subject on large screens */}
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
{item.subject}
</h3>
</div>
{/* subject on small screens */}
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
{item.subject}
</h3>
{/* TAGS (always below on small screen, inline on large screen) */}
<div className="flex flex-wrap gap-1 mt-0.5">
{item.tags?.map((tag, index) => (
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={index}>{tag}</Badge>
<div className="flex flex-wrap gap-1 mt-0.25">
{item.tags?.map((tag, i) => (
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={i}>{tag}</Badge>
))}
</div>
</div>
{/* RIGHT AREA actions & meta */}
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-2 text-xs text-muted-foreground">
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
{hasAttachments && (
<div className="flex items-center gap-1">
<Paperclip className="h-3.5 w-3.5" />
<Paperclip className="h-3 w-3" />
<span>{item.attachments?.length}</span>
</div>
)}
@@ -225,14 +220,14 @@ export function MailList({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 p-0 hover:bg-muted rounded-md"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
@@ -241,7 +236,7 @@ export function MailList({
setOpen("edit-tags");
}}
>
<TagIcon className="ml-2 h-4 w-4" />
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('search.editTag')}
</DropdownMenuItem>
@@ -253,7 +248,7 @@ export function MailList({
handleDelete(item);
}}
>
<Trash2 className="ml-2 h-4 w-4" />
<Trash2 className="ml-2 h-3.5 w-3.5" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -263,9 +258,7 @@ export function MailList({
</div>
)
})}
{totalSelected > 0 && (
<MailBulkActions />
)}
{totalSelected > 0 && <MailBulkActions />}
</div>
)
}
}

View File

@@ -18,100 +18,90 @@
import { MailboxData } from "@/api/mailbox/api";
import { TreeDataItem } from "@/components/tree-view";
import { Badge } from '@/components/ui/badge'
import { FolderClosed, FolderOpen } from "lucide-react";
import React from 'react';
import { TreeViewBaseItem } from '@mui/x-tree-view/models';
type BadgeContentFunction = (item: MailboxData) => React.ReactNode;
export const buildTree = (data: MailboxData[], badgeContent?: BadgeContentFunction, showAttributes?: boolean, showExists?: boolean): TreeDataItem[] => {
const root: TreeDataItem = {
id: 'root',
name: 'Root',
icon: FolderClosed,
openIcon: FolderOpen,
children: [], // Ensure children is initialized as an array
};
export type ExtendedTreeItemProps = {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
id: string;
label: string;
};
const nodeMap: { [key: string]: TreeDataItem } = {};
data.sort((a, b) => a.name.localeCompare(b.name));
data.forEach((item) => {
const { id, name, delimiter, exists, attributes } = item;
const badge = showExists ? (badgeContent ? badgeContent(item) : React.createElement(Badge, {
className: 'text-[12px]',
variant: 'secondary',
}, exists)) : null;
const attributesNode = showAttributes
? attributes.map((item, index) =>
React.createElement(
Badge,
{
key: index,
className: 'text-[12px] mr-1 last:mr-0',
variant: 'secondary',
},
item.attr === 'Extension' ? item.extension || '' : item.attr
)
)
: null;
// const badge = badgeContent || React.createElement(Badge, {
// className: 'text-xs',
// }, exists);
// If there is no delimiter, add the item directly as a child of the root
if (!delimiter) {
root.children!.push({
id: id.toString(),
name,
icon: FolderClosed,
openIcon: FolderOpen,
badge,
attributes: showAttributes ? attributesNode : null,
children: undefined, // Leaf node, so children is undefined
});
return;
export function buildTree(items: MailboxData[]): TreeViewBaseItem<ExtendedTreeItemProps>[] {
const nodeByName = new Map<string, TreeViewBaseItem<ExtendedTreeItemProps>>();
for (const mb of items) {
if (!mb.name) continue;
const delimiter = mb.delimiter ?? '/';
const parts = mb.name.split(delimiter);
let currentFullName = '';
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
currentFullName = currentFullName ? `${currentFullName}${delimiter}${part}` : part;
if (!nodeByName.has(currentFullName)) {
nodeByName.set(currentFullName, {
id: currentFullName,
label: part,
exists: mb.exists,
attributes: mb.attributes,
children: [],
});
}
if (i === parts.length - 1) {
const node = nodeByName.get(currentFullName)!;
node.id = String(mb.id);
}
}
}
const roots: TreeViewBaseItem<ExtendedTreeItemProps>[] = [];
for (const [fullName, node] of nodeByName.entries()) {
const delim = mbDelimiterOrDefault(fullName, items);
const lastDelimIndex = fullName.lastIndexOf(delim);
if (lastDelimIndex === -1) {
roots.push(node);
continue;
}
// Split the name into parts based on the delimiter
const parts = name.split(delimiter); // dir/sub1/sub2
let currentParent = root;
const parentFullName = fullName.substring(0, lastDelimIndex);
const parentNode = nodeByName.get(parentFullName);
// Traverse or create nodes for each part of the path
parts.forEach((part, index) => {
const path = parts.slice(0, index + 1).join(delimiter);
// If the node already exists, set it as the current parent
if (nodeMap[path]) {
currentParent = nodeMap[path];
} else {
// Determine if this is a leaf node (last part of the path)
const isLeaf = index === parts.length - 1;
// Create a new node
const newNode: TreeDataItem = {
id: isLeaf ? id.toString() : path, // Use item.id for leaf nodes, path for non-leaf nodes
name: part,
icon: FolderClosed,
openIcon: FolderOpen,
badge,
attributes: showAttributes ? attributesNode : null,
children: isLeaf ? undefined : [], // Ensure children is initialized as an array for non-leaf nodes
};
// Ensure currentParent.children is initialized as an array
if (!currentParent.children) {
currentParent.children = [];
}
// Add the new node to the current parent's children
currentParent.children.push(newNode);
currentParent = newNode; // Update the current parent to the new node
nodeMap[path] = newNode; // Store the node in the map for quick lookup
if (parentNode) {
parentNode.children = parentNode.children ?? [];
if (!parentNode.children.includes(node)) {
parentNode.children.push(node);
}
});
});
} else {
roots.push(node);
}
}
// Return the children of the root as the final tree structure
return root.children!;
};
const sortNodes = (nodes: TreeViewBaseItem[]) => {
nodes.sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }));
for (const n of nodes) {
if (n.children && n.children.length) sortNodes(n.children);
}
};
const uniqueRoots = Array.from(new Set(roots));
sortNodes(uniqueRoots);
return uniqueRoots;
}
function mbDelimiterOrDefault(fullName: string, items: MailboxData[]): string {
const mb = items.find(it => it.name === fullName || fullName.startsWith(it.name + (it.delimiter ?? '/')));
if (mb?.delimiter) return mb.delimiter;
const withDelim = items.find(it => it.delimiter);
if (withDelim?.delimiter) return withDelim.delimiter;
return '.';
}

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "لا يوجد نشاط حديث",
"noSendersData": "لا توجد بيانات للمرسلين",
"noLargeEmails": "لا توجد رسائل بريد إلكتروني كبيرة",
"noAccountData": "لا توجد بيانات للحساب"
"noAccountData": "لا توجد بيانات للحساب",
"systemVersion": "إصدار النظام"
},
"accounts": {
"title": "حسابات البريد الإلكتروني",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "تحديث حساب البريد الإلكتروني هنا. ",
"addNewEmailAccountHere": "إضافة حساب بريد إلكتروني جديد هنا. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "يستخدم هذا الحساب لأغراض التعريف فقط ولا يتطلب المزامنة مع خادم بريد إلكتروني. إنه يساعد في استيراد بيانات البريد الإلكتروني.",
"determinesWhetherThisAccountIsActiveNoSync": "يحدد ما إذا كان هذا الحساب نشطًا. إذا تم تعطيله، فلن يتمكن الحساب من استيراد البيانات أو إجراء استعلامات.",
"determinesWhetherThisAccountIsActiveNoSync": "يحدد ما إذا كان هذا الحساب نشطًا. إذا تم تعطيله، فلن يكون قادرًا على استيراد البيانات.",
"folderSync": {
"noData": "لا توجد بيانات",
"noFolders": "لا توجد مجلدات للمزامنة",
"batches": "دفعات"
"batches": "دفعات",
"autoSelectDescendants": "تحديد العناصر الفرعية تلقائيًا",
"autoSelectParents": "تحديد العناصر الأصلية تلقائيًا",
"expandAll": "توسيع الكل",
"collapseAll": "طي الكل",
"loadingMailboxFolders": "جارٍ تحميل مجلدات البريد…"
},
"viewDetails": "عرض التفاصيل",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "تفعيل",
"disable": "تعطيل",
"thisWillPreventTheAccountFromBeingUsed": "لن يقوم هذا الحساب بمزامنة البيانات من خادم IMAP بعد الآن، ولكن لا تزال البيانات الموجودة متاحة للعرض.",
"disabled": "معطل"
"disabled": "معطل",
"useDangerous": "الثقة بأي شهادة TLS",
"useDangerousDescription": "فعّل هذا الخيار فقط إذا كنت تتصل بخادم IMAP يستخدم شهادة TLS صادرة عن CA عام أو شهادة موقعة ذاتياً قد لا يتعرف عليها نظامك. هذا الإعداد يتجاوز عملية التحقق الاعتيادية من الشهادة، وقد يعرض الاتصال لهجمات “رجل في الوسط” — فعّله فقط إذا كنت تدرك المخاطر."
},
"mailbox": {
"title": "صندوق البريد",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "Ingen nylig aktivitet",
"noSendersData": "Ingen afsenderdata",
"noLargeEmails": "Ingen store e-mails",
"noAccountData": "Ingen kontodata"
"noAccountData": "Ingen kontodata",
"systemVersion": "Systemversion"
},
"accounts": {
"title": "E-mailkonti",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Opdater e-mailkontoen her. ",
"addNewEmailAccountHere": "Tilføj ny e-mailkonto her. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "Denne konto bruges kun til identifikationsformål og kræver ikke synkronisering med en e-mailserver. Den hjælper med import af e-mail-data.",
"determinesWhetherThisAccountIsActiveNoSync": "Bestemmer, om denne konto er aktiv. Hvis deaktiveret, vil kontoen ikke kunne importere data eller udføre søgninger.",
"determinesWhetherThisAccountIsActiveNoSync": "Bestemmer, om denne konto er aktiv. Hvis den er deaktiveret, kan data ikke importeres.",
"folderSync": {
"noData": "Ingen data",
"noFolders": "Ingen mapper at synkronisere",
"batches": "batcher"
"batches": "batcher",
"autoSelectDescendants": "Vælg efterkommere automatisk",
"autoSelectParents": "Vælg forældre automatisk",
"expandAll": "Udvid alle",
"collapseAll": "Skjul alle",
"loadingMailboxFolders": "Indlæser postkassemapper…"
},
"viewDetails": "vis detaljer",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Aktivér",
"disable": "Deaktivér",
"thisWillPreventTheAccountFromBeingUsed": "Denne konto vil ikke længere synkronisere data fra IMAP-serveren, men eksisterende data vil stadig være tilgængelige.",
"disabled": "Deaktiveret"
"disabled": "Deaktiveret",
"useDangerous": "Stol på ethvert TLScertifikat",
"useDangerousDescription": "Aktivér denne indstilling kun hvis du opretter forbindelse til en IMAPserver, der bruger et offentligt CAcertifikat eller et selvsigneret certifikat, som dit system måske ikke genkender. Denne indstilling omgår standard certificeringsvalidering og kan gøre dig sårbar over for maninthemiddleangreb — aktiver kun hvis du forstår risikoen."
},
"mailbox": {
"title": "Mailboks",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "Keine kürzliche Aktivität",
"noSendersData": "Keine Absenderdaten",
"noLargeEmails": "Keine großen E-Mails",
"noAccountData": "Keine Kontodaten"
"noAccountData": "Keine Kontodaten",
"systemVersion": "Systemversion"
},
"accounts": {
"title": "E-Mail-Konten",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Aktualisieren Sie das E-Mail-Konto hier. ",
"addNewEmailAccountHere": "Fügen Sie hier ein neues E-Mail-Konto hinzu. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "Dieses Konto dient nur zur Identifizierung und erfordert keine Synchronisierung mit dem Mailserver. Es hilft beim Import von E-Mail-Daten.",
"determinesWhetherThisAccountIsActiveNoSync": "Bestimmt, ob dieses Konto aktiv ist. Wenn es deaktiviert ist, kann das Konto keine Daten importieren oder Abfragen ausführen.",
"determinesWhetherThisAccountIsActiveNoSync": "Legt fest, ob dieses Konto aktiv ist. Wenn es deaktiviert ist, können keine Daten importiert werden.",
"folderSync": {
"noData": "Keine Daten",
"noFolders": "Keine Ordner zum Synchronisieren",
"batches": "Batches"
"batches": "Batches",
"autoSelectDescendants": "Unterelemente automatisch auswählen",
"autoSelectParents": "Elternelemente automatisch auswählen",
"expandAll": "Alle erweitern",
"collapseAll": "Alle reduzieren",
"loadingMailboxFolders": "Postfachordner werden geladen…"
},
"viewDetails": "Details anzeigen",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Aktivieren",
"disable": "Deaktivieren",
"thisWillPreventTheAccountFromBeingUsed": "Dieses Konto synchronisiert keine Daten mehr vom IMAP-Server, aber vorhandene Daten bleiben weiterhin zugänglich.",
"disabled": "Deaktiviert"
"disabled": "Deaktiviert",
"useDangerous": "Jedem TLSZertifikat vertrauen",
"useDangerousDescription": "Aktivieren Sie diese Option nur, wenn Sie sich mit einem IMAPServer verbinden, der ein öffentliches CAZertifikat oder ein selbstsigniertes Zertifikat verwendet, das Ihr System möglicherweise nicht erkennt. Diese Einstellung umgeht die StandardZertifikatsprüfung und kann Sie für ManintheMiddleAngriffe anfällig machen aktivieren Sie nur, wenn Sie die Risiken verstehen."
},
"mailbox": {
"title": "Postfach",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "No recent activity",
"noSendersData": "No senders data",
"noLargeEmails": "No large emails",
"noAccountData": "No account data"
"noAccountData": "No account data",
"systemVersion": "System Version"
},
"accounts": {
"title": "Email Accounts",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Update the email account here. ",
"addNewEmailAccountHere": "Add new email account here. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "This account is used for identification purposes only and does not require syncing with an email server. It helps with importing email data.",
"determinesWhetherThisAccountIsActiveNoSync": "Determines whether this account is active. If disabled, the account will not be able to import data or perform queries.",
"determinesWhetherThisAccountIsActiveNoSync": "Determines whether this account is active. If disabled, the account will not be able to import data.",
"folderSync": {
"noData": "No Data",
"noFolders": "No folders to sync",
"batches": "batches"
"batches": "batches",
"autoSelectDescendants": "Auto select descendants",
"autoSelectParents": "Auto select parents",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"loadingMailboxFolders": "Loading mailbox folders…"
},
"viewDetails": "view details",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Enable",
"disable": "Disable",
"thisWillPreventTheAccountFromBeingUsed": "This account will no longer sync data from the IMAP server, but existing data will still be accessible.",
"disabled": "Disabled"
"disabled": "Disabled",
"useDangerous": "Trust Any TLS Certificate",
"useDangerousDescription": "Enable this option only if you are connecting to an IMAP server with a public or self-signed certificate that may not be recognized by your system. Using this setting bypasses standard certificate validation, which can expose you to man-in-the-middle attacks. Only enable if you understand the risks."
},
"mailbox": {
"title": "Mailbox",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "Sin actividad reciente",
"noSendersData": "Sin datos de remitentes",
"noLargeEmails": "Sin correos grandes",
"noAccountData": "Sin datos de cuenta"
"noAccountData": "Sin datos de cuenta",
"systemVersion": "Versión del sistema"
},
"accounts": {
"title": "Cuentas de correo",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Actualiza la cuenta de correo electrónico aquí. ",
"addNewEmailAccountHere": "Añade una nueva cuenta de correo electrónico aquí. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "Esta cuenta se utiliza solo para fines de identificación y no requiere sincronización con el servidor de correo. Ayuda a importar datos de correo electrónico.",
"determinesWhetherThisAccountIsActiveNoSync": "Determina si esta cuenta está activa. Si está deshabilitada, la cuenta no podrá importar datos ni ejecutar consultas.",
"determinesWhetherThisAccountIsActiveNoSync": "Determina si esta cuenta está activa. Si está desactivada, no podrá importar datos.",
"folderSync": {
"noData": "Sin datos",
"noFolders": "No hay carpetas para sincronizar",
"batches": "lotes"
"batches": "lotes",
"autoSelectDescendants": "Seleccionar automáticamente los descendientes",
"autoSelectParents": "Seleccionar automáticamente los padres",
"expandAll": "Expandir todo",
"collapseAll": "Contraer todo",
"loadingMailboxFolders": "Cargando carpetas del buzón…"
},
"viewDetails": "ver detalles",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Activar",
"disable": "Desactivar",
"thisWillPreventTheAccountFromBeingUsed": "Esta cuenta ya no sincronizará datos desde el servidor IMAP, pero los datos existentes seguirán siendo accesibles.",
"disabled": "Deshabilitado"
"disabled": "Deshabilitado",
"useDangerous": "Confiar en cualquier certificado TLS",
"useDangerousDescription": "Activa esta opción solo si te estás conectando a un servidor IMAP que utiliza un certificado público o autofirmado, el cual puede no ser reconocido por tu sistema. Esta opción omite la validación estándar del certificado y puede exponerte a ataques de tipo “maninthemiddle” — actívala solo si entiendes los riesgos."
},
"mailbox": {
"title": "Buzón",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "Ei viimeaikaisia tapahtumia",
"noSendersData": "Ei lähettäjätietoja",
"noLargeEmails": "Ei suuria sähköposteja",
"noAccountData": "Ei tilitietoja"
"noAccountData": "Ei tilitietoja",
"systemVersion": "Järjestelmäversio"
},
"accounts": {
"title": "Sähköpostitilit",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Päivitä sähköpostitili täällä. ",
"addNewEmailAccountHere": "Lisää uusi sähköpostitili täällä. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "Tätä tiliä käytetään vain tunnistamistarkoituksiin, eikä se vaadi synkronointia sähköpostipalvelimen kanssa. Auttaa sähköpostidatan tuomisessa.",
"determinesWhetherThisAccountIsActiveNoSync": "Määrittää, onko tämä tili aktiivinen. Jos poistettu käytöstä, tili ei voi tuoda tietoja tai suorittaa kyselyjä.",
"determinesWhetherThisAccountIsActiveNoSync": "Määrittää, onko tämä tili aktiivinen. Jos se on poistettu käytöstä, tietoja ei voi tuoda.",
"folderSync": {
"noData": "Ei tietoja",
"noFolders": "Ei synkronoitavia kansioita",
"batches": "erät"
"batches": "erät",
"autoSelectDescendants": "Valitse alisolmut automaattisesti",
"autoSelectParents": "Valitse yläsolmut automaattisesti",
"expandAll": "Laajenna kaikki",
"collapseAll": "Kutista kaikki",
"loadingMailboxFolders": "Ladataan postilaatikon kansioita…"
},
"viewDetails": "katso tiedot",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Ota käyttöön",
"disable": "Poista käytöstä",
"thisWillPreventTheAccountFromBeingUsed": "Tili ei enää synkronoi tietoja IMAP-palvelimelta, mutta olemassa olevat tiedot ovat edelleen käytettävissä.",
"disabled": "Poistettu käytöstä"
"disabled": "Poistettu käytöstä",
"useDangerous": "Luota mihin tahansa TLS-sertifikaattiin",
"useDangerousDescription": "Ota tämä vaihtoehto käyttöön vain, jos IMAP-palvelin käyttää julkista CA:ta tai itse allekirjoitettua sertifikaattia, jonka järjestelmä ei tunnista. Tämä ohittaa normaalin sertifikaattitarkistuksen, ja voi altistaa Man-in-the-Middle -hyökkäyksille. Käytä vain, jos ymmärrät riskin."
},
"mailbox": {
"title": "Sähköposti",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "Aucune activité récente",
"noSendersData": "Aucune donnée d'expéditeurs",
"noLargeEmails": "Aucun gros e-mail",
"noAccountData": "Aucune donnée de compte"
"noAccountData": "Aucune donnée de compte",
"systemVersion": "Version du système"
},
"accounts": {
"title": "Comptes de Messagerie",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Mettez à jour le compte e-mail ici. ",
"addNewEmailAccountHere": "Ajoutez un nouveau compte e-mail ici. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "Ce compte est utilisé uniquement à des fins d'identification et ne nécessite pas de synchronisation avec un serveur de messagerie. Il est utile pour l'importation de données d'e-mails.",
"determinesWhetherThisAccountIsActiveNoSync": "Détermine si ce compte est actif. S'il est désactivé, l'importation de données ou l'exécution de requêtes ne sera pas possible pour ce compte.",
"determinesWhetherThisAccountIsActiveNoSync": "Détermine si ce compte est actif. Sil est désactivé, il ne pourra pas importer de données.",
"folderSync": {
"noData": "Aucune Donnée",
"noFolders": "Aucun dossier à synchroniser",
"batches": "lots"
"batches": "lots",
"autoSelectDescendants": "Sélectionner automatiquement les descendants",
"autoSelectParents": "Sélectionner automatiquement les parents",
"expandAll": "Tout développer",
"collapseAll": "Tout réduire",
"loadingMailboxFolders": "Chargement des dossiers de la boîte…"
},
"viewDetails": "voir les détails",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Activer",
"disable": "Désactiver",
"thisWillPreventTheAccountFromBeingUsed": "Ce compte ne synchronisera plus les données depuis le serveur IMAP, mais les données existantes resteront accessibles.",
"disabled": "Désactivé"
"disabled": "Désactivé",
"useDangerous": "Faire confiance à nimporte quel certificat TLS",
"useDangerousDescription": "Activez cette option seulement si vous vous connectez à un serveur IMAP utilisant un certificat public ou autosigné que votre système pourrait ne pas reconnaître. Cette option contourne la vérification standard des certificats, ce qui peut vous exposer à des attaques de type hommedumilieu — nactivez que si vous comprenez les risques."
},
"mailbox": {
"title": "Boîte aux lettres",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "Nessuna attività recente",
"noSendersData": "Nessun dato sui mittenti",
"noLargeEmails": "Nessuna email di grandi dimensioni",
"noAccountData": "Nessun dato sull'account"
"noAccountData": "Nessun dato sull'account",
"systemVersion": "Versione del sistema"
},
"accounts": {
"title": "Account Email",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Aggiorna l'account email qui. ",
"addNewEmailAccountHere": "Aggiungi un nuovo account email qui. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "Questo account viene utilizzato solo a scopo di identificazione e non richiede la sincronizzazione con un server email. Aiuta nell'importazione dei dati email.",
"determinesWhetherThisAccountIsActiveNoSync": "Determina se questo account è attivo. Se disabilitato, l'account non potrà importare dati o eseguire query.",
"determinesWhetherThisAccountIsActiveNoSync": "Determina se questo account è attivo. Se disattivato, non sarà possibile importare dati.",
"folderSync": {
"noData": "Nessun Dato",
"noFolders": "Nessuna cartella da sincronizzare",
"batches": "lotti"
"batches": "lotti",
"autoSelectDescendants": "Seleziona automaticamente i discendenti",
"autoSelectParents": "Seleziona automaticamente i genitori",
"expandAll": "Espandi tutto",
"collapseAll": "Comprimi tutto",
"loadingMailboxFolders": "Caricamento delle cartelle della casella…"
},
"viewDetails": "visualizza dettagli",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Attiva",
"disable": "Disattiva",
"thisWillPreventTheAccountFromBeingUsed": "Questo account non sincronizzerà più i dati dal server IMAP, ma i dati esistenti resteranno accessibili.",
"disabled": "Disabilitato"
"disabled": "Disabilitato",
"useDangerous": "Accetta qualsiasi certificato TLS",
"useDangerousDescription": "Abilita questa opzione solo se ti connetti a un server IMAP che utilizza un certificato pubblico o autofirmato che il tuo sistema potrebbe non riconoscere. Questa impostazione salta la verifica standard del certificato e può esporre la connessione ad attacchi “maninthemiddle” — attivala solo se comprendi i rischi."
},
"mailbox": {
"title": "Posta in arrivo",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "最近のアクティビティなし",
"noSendersData": "送信者データなし",
"noLargeEmails": "サイズの大きいメールなし",
"noAccountData": "アカウントデータなし"
"noAccountData": "アカウントデータなし",
"systemVersion": "システムバージョン"
},
"accounts": {
"title": "メールアカウント",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "こちらでメールアカウントを更新してください。",
"addNewEmailAccountHere": "こちらで新しいメールアカウントを追加してください。",
"thisAccountIsUsedForIdentificationPurposesOnly": "このアカウントは識別目的でのみ使用され、メールサーバーとの同期は必要ありません。メールデータのインポートに役立ちます。",
"determinesWhetherThisAccountIsActiveNoSync": "このアカウントが有効かどうかを定します。無効の場合、データインポートやクエリの実行はできません。",
"determinesWhetherThisAccountIsActiveNoSync": "このアカウントが有効かどうかを定します。無効の場合、データインポートできません。",
"folderSync": {
"noData": "データなし",
"noFolders": "同期するフォルダーがありません",
"batches": "バッチ"
"batches": "バッチ",
"autoSelectDescendants": "子項目を自動選択",
"autoSelectParents": "親項目を自動選択",
"expandAll": "すべて展開",
"collapseAll": "すべて折りたたむ",
"loadingMailboxFolders": "メールボックスフォルダーを読み込み中…"
},
"viewDetails": "詳細を見る",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "有効化",
"disable": "無効化",
"thisWillPreventTheAccountFromBeingUsed": "このアカウントはIMAPサーバーからのデータ同期を行わなくなりますが、既存のデータは引き続き参照可能です。",
"disabled": "無効"
"disabled": "無効",
"useDangerous": "任意の TLS 証明書を信頼する",
"useDangerousDescription": "このオプションを有効にすると、公開 CA または自己署名証明書を使用している IMAP サーバーに対して、システムが証明書を認識していない場合でも接続できます。ただし、標準の証明書検証をバイパスするため、中間者攻撃 (MITM) のリスクがあり — リスクを理解した上でのみ有効にしてください。"
},
"mailbox": {
"title": "メールボックス",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "최근 활동 없음",
"noSendersData": "발신자 데이터 없음",
"noLargeEmails": "크기가 큰 이메일 없음",
"noAccountData": "계정 데이터 없음"
"noAccountData": "계정 데이터 없음",
"systemVersion": "시스템 버전"
},
"accounts": {
"title": "이메일 계정",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "여기에서 이메일 계정을 업데이트하십시오.",
"addNewEmailAccountHere": "여기에서 새 이메일 계정을 추가하십시오.",
"thisAccountIsUsedForIdentificationPurposesOnly": "이 계정은 식별 목적으로만 사용되며 이메일 서버와의 동기화가 필요하지 않습니다. 이메일 데이터 가져오기에 도움이 됩니다.",
"determinesWhetherThisAccountIsActiveNoSync": "이 계정이 활성화되었는지 여부를 결정합니다. 비활성화된 경우 데이터 가져오기 또는 쿼리 실행이 불가능합니다.",
"determinesWhetherThisAccountIsActiveNoSync": "이 계정이 활성화되어 있는지 결정합니다. 비활성화되면 데이터 가져올 수 없습니다.",
"folderSync": {
"noData": "데이터 없음",
"noFolders": "동기화할 폴더가 없습니다",
"batches": "배치"
"batches": "배치",
"autoSelectDescendants": "하위 항목 자동 선택",
"autoSelectParents": "상위 항목 자동 선택",
"expandAll": "모두 펼치기",
"collapseAll": "모두 접기",
"loadingMailboxFolders": "메일 폴더 로딩 중…"
},
"viewDetails": "세부 정보 보기",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "활성화",
"disable": "비활성화",
"thisWillPreventTheAccountFromBeingUsed": "이 계정은 더 이상 IMAP 서버에서 데이터를 동기화하지 않지만, 기존 데이터는 계속 조회할 수 있습니다.",
"disabled": "비활성화"
"disabled": "비활성화",
"useDangerous": "모든 TLS 인증서를 신뢰",
"useDangerousDescription": "공개 CA 인증서 또는 자체 서명 인증서를 사용하는 IMAP 서버에 연결할 때, 시스템에서 인증서를 신뢰하지 않아도 이 옵션을 켜면 무시할 수 있습니다. 하지만 표준 인증서 검증을 무시하기 때문에 중간자 공격에 노출될 수 있습니다 — 위험을 이해한 경우에만 사용하세요"
},
"mailbox": {
"title": "받은 편지함",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "Geen recente activiteit",
"noSendersData": "Geen afzendersgegevens",
"noLargeEmails": "Geen grote e-mails",
"noAccountData": "Geen accountgegevens"
"noAccountData": "Geen accountgegevens",
"systemVersion": "Systeemversie"
},
"accounts": {
"title": "E-mailaccounts",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Werk het e-mailaccount hier bij. ",
"addNewEmailAccountHere": "Voeg hier een nieuw e-mailaccount toe. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "Dit account wordt alleen gebruikt voor identificatiedoeleinden en vereist geen synchronisatie met een e-mailserver. Het helpt bij het importeren van e-mailgegevens.",
"determinesWhetherThisAccountIsActiveNoSync": "Bepaalt of dit account actief is. Indien uitgeschakeld, kan het account geen gegevens importeren of zoekopdrachten uitvoeren.",
"determinesWhetherThisAccountIsActiveNoSync": "Bepaalt of dit account actief is. Als het is uitgeschakeld, kan er geen data worden geïmporteerd.",
"folderSync": {
"noData": "Geen Gegevens",
"noFolders": "Geen mappen om te synchroniseren",
"batches": "batches"
"batches": "batches",
"autoSelectDescendants": "Automatisch onderliggende items selecteren",
"autoSelectParents": "Automatisch bovenliggende items selecteren",
"expandAll": "Alles uitklappen",
"collapseAll": "Alles inklappen",
"loadingMailboxFolders": "Postvakken laden…"
},
"viewDetails": "details bekijken",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Inschakelen",
"disable": "Uitschakelen",
"thisWillPreventTheAccountFromBeingUsed": "Dit account zal geen gegevens meer synchroniseren van de IMAP-server, maar bestaande gegevens blijven toegankelijk.",
"disabled": "Uitgeschakeld"
"disabled": "Uitgeschakeld",
"useDangerous": "Vertrouw elk TLScertificaat",
"useDangerousDescription": "Schakel deze optie alleen in als je verbinding maakt met een IMAPserver die een openbaar CA of een selfsigned certificaat gebruikt dat door je systeem mogelijk niet wordt vertrouwd. Deze instelling omzeilt standaard certificaatverificatie en kan je blootstellen aan maninthemiddleaanvallen — activeer alleen als je de risicos begrijpt."
},
"mailbox": {
"title": "Postvak In",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "Ingen nylig aktivitet",
"noSendersData": "Ingen avsenderdata",
"noLargeEmails": "Ingen store e-poster",
"noAccountData": "Ingen kontodata"
"noAccountData": "Ingen kontodata",
"systemVersion": "Systemversjon"
},
"accounts": {
"title": "E-postkontoer",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Oppdater e-postkontoen her. ",
"addNewEmailAccountHere": "Legg til ny e-postkonto her. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "Denne kontoen brukes kun for identifikasjonsformål og krever ikke synkronisering med en e-postserver. Den hjelper med import av e-postdata.",
"determinesWhetherThisAccountIsActiveNoSync": "Bestemmer om denne kontoen er aktiv. Hvis deaktivert, vil ikke kontoen kunne importere data eller utføre spørringer.",
"determinesWhetherThisAccountIsActiveNoSync": "Bestemmer om denne kontoen er aktiv. Hvis den er deaktivert, kan data ikke importeres.",
"folderSync": {
"noData": "Ingen data",
"noFolders": "Ingen mapper å synkronisere",
"batches": "partier"
"batches": "partier",
"autoSelectDescendants": "Velg etterkommere automatisk",
"autoSelectParents": "Velg foreldre automatisk",
"expandAll": "Utvid alle",
"collapseAll": "Skjul alle",
"loadingMailboxFolders": "Laster inn postkassemapper…"
},
"viewDetails": "se detaljer",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Aktiver",
"disable": "Deaktiver",
"thisWillPreventTheAccountFromBeingUsed": "Denne kontoen vil ikke lenger synkronisere data fra IMAP-serveren, men eksisterende data vil fortsatt være tilgjengelige.",
"disabled": "Deaktivert"
"disabled": "Deaktivert",
"useDangerous": "Stol på hvilken som helst TLSsertifikat",
"useDangerousDescription": "Aktiver dette alternativet kun hvis IMAPserveren bruker et offentlig CAsertifikat eller et selvsignert sertifikat som systemet ditt ikke gjenkjenner. Innstillingen hopper over vanlig sertifikatvalidering, noe som kan utsette deg for maninthemiddleangrep bruk kun hvis du forstår risikoen."
},
"mailbox": {
"title": "Postkasse",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "Nenhuma Atividade Recente",
"noSendersData": "Sem Dados de Remetentes",
"noLargeEmails": "Sem Emails Grandes",
"noAccountData": "Sem Dados de Contas"
"noAccountData": "Sem Dados de Contas",
"systemVersion": "Versão do sistema"
},
"accounts": {
"title": "Contas de Email",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Atualize a conta de email aqui.",
"addNewEmailAccountHere": "Adicione uma nova conta de email aqui.",
"thisAccountIsUsedForIdentificationPurposesOnly": "Esta conta é usada apenas para fins de identificação e não requer sincronização com um servidor de email. Ajuda na importação de dados de email.",
"determinesWhetherThisAccountIsActiveNoSync": "Determina se esta conta está ativa. Se desativada, nenhuma importação de dados ou execução de consulta será possível.",
"determinesWhetherThisAccountIsActiveNoSync": "Determina se esta conta está ativa. Se estiver desativada, não será possível importar dados.",
"folderSync": {
"noData": "Sem Dados",
"noFolders": "Nenhuma pasta para sincronizar",
"batches": "Lotes"
"batches": "Lotes",
"autoSelectDescendants": "Selecionar automaticamente os descendentes",
"autoSelectParents": "Selecionar automaticamente os pais",
"expandAll": "Expandir tudo",
"collapseAll": "Recolher tudo",
"loadingMailboxFolders": "Carregando pastas da caixa de correio…"
},
"viewDetails": "Ver Detalhes",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Ativar",
"disable": "Desativar",
"thisWillPreventTheAccountFromBeingUsed": "Esta conta não sincronizará mais dados do servidor IMAP, mas os dados existentes ainda estarão acessíveis.",
"disabled": "Desativado"
"disabled": "Desativado",
"useDangerous": "Confiar em qualquer certificado TLS",
"useDangerousDescription": "Ative esta opção apenas se estiver a conectar a um servidor IMAP que use um certificado público ou autoassinado que o seu sistema possa não reconhecer. Esta opção ignora a verificação normal de certificados e pode expor a ligação a ataques maninthemiddle — ative apenas se compreender os riscos."
},
"mailbox": {
"title": "Caixa de Entrada",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "Нет недавней активности",
"noSendersData": "Нет данных об отправителях",
"noLargeEmails": "Нет больших писем",
"noAccountData": "Нет данных об аккаунте"
"noAccountData": "Нет данных об аккаунте",
"systemVersion": "Версия системы"
},
"accounts": {
"title": "Почтовые учетные записи",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Обновите почтовый аккаунт здесь. ",
"addNewEmailAccountHere": "Добавьте новый почтовый аккаунт здесь. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "Этот аккаунт используется только для идентификации и не требует синхронизации с почтовым сервером. Он помогает при импорте почтовых данных.",
"determinesWhetherThisAccountIsActiveNoSync": "Определяет, активен ли этот аккаунт. Если отключено, аккаунт не сможет импортировать данные или выполнять запросы.",
"determinesWhetherThisAccountIsActiveNoSync": "Определяет, активна ли учетная запись. Если она отключена, импорт данных будет невозможен.",
"folderSync": {
"noData": "Нет данных",
"noFolders": "Нет папок для синхронизации",
"batches": "пакетов"
"batches": "пакетов",
"autoSelectDescendants": "Автоматически выбирать дочерние элементы",
"autoSelectParents": "Автоматически выбирать родительские элементы",
"expandAll": "Развернуть все",
"collapseAll": "Свернуть все",
"loadingMailboxFolders": "Загрузка папок почтового ящика…"
},
"viewDetails": "смотреть детали",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Включить",
"disable": "Отключить",
"thisWillPreventTheAccountFromBeingUsed": "Этот аккаунт больше не будет синхронизировать данные с IMAP-сервера, но существующие данные останутся доступными.",
"disabled": "Отключено"
"disabled": "Отключено",
"useDangerous": "Доверять любому TLSсертификату",
"useDangerousDescription": "Включите этот параметр только если IMAPсервер использует публичный CAсертификат или самоподписанный сертификат, который система может не распознавать. Это отключает стандартную проверку сертификатов и может подвергнуть соединение атакам «человек‑посередине» — активируйте только если вы понимаете риски."
},
"mailbox": {
"title": "Почтовый ящик",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "Ingen nylig aktivitet",
"noSendersData": "Inga avsändardata",
"noLargeEmails": "Inga stora e-postmeddelanden",
"noAccountData": "Inga kontodata"
"noAccountData": "Inga kontodata",
"systemVersion": "Systemversion"
},
"accounts": {
"title": "E-postkonton",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "Uppdatera e-postkontot här. ",
"addNewEmailAccountHere": "Lägg till nytt e-postkonto här. ",
"thisAccountIsUsedForIdentificationPurposesOnly": "Detta konto används endast för identifieringssyften och kräver inte synkronisering med en e-postserver. Det hjälper till med import av e-postdata.",
"determinesWhetherThisAccountIsActiveNoSync": "Avgör om detta konto är aktivt. Om inaktiverat kommer kontot inte kunna importera data eller utföra sökningar.",
"determinesWhetherThisAccountIsActiveNoSync": "Avgör om kontot är aktivt. Om det är inaktiverat kan ingen data importeras.",
"folderSync": {
"noData": "Inga data",
"noFolders": "Inga mappar att synkronisera",
"batches": "satser"
"batches": "satser",
"autoSelectDescendants": "Välj underordnade automatiskt",
"autoSelectParents": "Välj överordnade automatiskt",
"expandAll": "Expandera alla",
"collapseAll": "Komprimera alla",
"loadingMailboxFolders": "Laddar brevlådemappar…"
},
"viewDetails": "visa detaljer",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "Aktivera",
"disable": "Inaktivera",
"thisWillPreventTheAccountFromBeingUsed": "Det här kontot kommer inte längre att synkronisera data från IMAP-servern, men befintlig data kommer fortfarande att vara åtkomlig.",
"disabled": "Inaktiverad"
"disabled": "Inaktiverad",
"useDangerous": "Lita på vilken TLScertifikat som helst",
"useDangerousDescription": "Aktivera detta alternativ endast om IMAPservern använder ett offentligt eller självsignerat certifikat som inte är betrott av ditt system. Denna inställning kringgår standardverifiering av certifikat, vilket kan utsätta dig för manimittenattacker — slå på endast om du förstår riskerna."
},
"mailbox": {
"title": "Brevlåda",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "無近期活動",
"noSendersData": "無寄件人資料",
"noLargeEmails": "無大容量郵件",
"noAccountData": "無帳號資料"
"noAccountData": "無帳號資料",
"systemVersion": "系統版本"
},
"accounts": {
"title": "郵件帳號",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "在此更新電子郵件帳號。",
"addNewEmailAccountHere": "在此新增電子郵件帳號。",
"thisAccountIsUsedForIdentificationPurposesOnly": "此帳號僅用於識別目的,不需要與郵件伺服器同步。有助於匯入郵件資料。",
"determinesWhetherThisAccountIsActiveNoSync": "決定此帳號是否啟用狀態。如果停用,無法匯入資料或執行查詢。",
"determinesWhetherThisAccountIsActiveNoSync": "用來判斷帳戶是否啟用。若停用,無法匯入資料。",
"folderSync": {
"noData": "無資料",
"noFolders": "沒有可同步的資料夾",
"batches": "批次"
"batches": "批次",
"autoSelectDescendants": "自動選取子項",
"autoSelectParents": "自動選取父項",
"expandAll": "全部展開",
"collapseAll": "全部收合",
"loadingMailboxFolders": "正在載入郵件資料夾…"
},
"viewDetails": "檢視詳細資訊",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "啟用",
"disable": "停用",
"thisWillPreventTheAccountFromBeingUsed": "該帳戶將不會繼續從 IMAP 伺服器同步資料,但已有資料仍然可以查詢。",
"disabled": "已停用"
"disabled": "已停用",
"useDangerous": "信任任意 TLS 憑證",
"useDangerousDescription": "僅當你連線的 IMAP 伺服器使用公開根憑證或自簽憑證,且系統無法驗證該憑證時才啟用此選項。本選項會略過標準憑證驗證流程,可能導致中間人攻擊等安全風險 — 請確認你了解並接受這些風險後再啟用。"
},
"mailbox": {
"title": "信箱",

View File

@@ -126,7 +126,8 @@
"noRecentActivity": "无最近活动",
"noSendersData": "无发件人数据",
"noLargeEmails": "无大邮件",
"noAccountData": "无账户数据"
"noAccountData": "无账户数据",
"systemVersion": "系统版本"
},
"accounts": {
"title": "邮件账户",
@@ -250,11 +251,16 @@
"updateTheEmailAccountHere": "在此更新邮件账户。",
"addNewEmailAccountHere": "在此添加新邮件账户。",
"thisAccountIsUsedForIdentificationPurposesOnly": "此账户仅用于识别目的,不需要与邮件服务器同步。它有助于导入邮件数据。",
"determinesWhetherThisAccountIsActiveNoSync": "确定此账户是否处于活动状态。如果禁用,账户将无法导入数据或执行查询。",
"determinesWhetherThisAccountIsActiveNoSync": "用于判断账户是否处于启用状态。如果禁用,将无法导入数据。",
"folderSync": {
"noData": "无数据",
"noFolders": "没有需要同步的文件夹",
"batches": "批次"
"batches": "批次",
"autoSelectDescendants": "自动选择子项",
"autoSelectParents": "自动选择父项",
"expandAll": "全部展开",
"collapseAll": "全部收起",
"loadingMailboxFolders": "正在加载邮箱文件夹…"
},
"viewDetails": "查看详情",
"runningState": {
@@ -298,7 +304,9 @@
"enable": "启用",
"disable": "禁用",
"thisWillPreventTheAccountFromBeingUsed": "该账户将不会继续从 IMAP 服务器同步数据,但已有数据仍然可以查询。",
"disabled": "已禁用"
"disabled": "已禁用",
"useDangerous": "信任任意 TLS 证书",
"useDangerousDescription": "如果你连接的 IMAP 服务器使用公开 CA 或者自签证书,而系统不认可该证书时,启用此选项可绕过标准证书校验。但请注意,这样可能使你的连接容易受到中间人攻击 —— 仅当你完全理解风险时才启用。"
},
"mailbox": {
"title": "邮箱",