mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34ec3a7d5b | ||
|
|
82397ab0cd | ||
|
|
1cfc12324f | ||
|
|
dffdac3eb6 | ||
|
|
7d02e58e4e | ||
|
|
847cc6825a | ||
|
|
6ce1420714 | ||
|
|
9a72ce9154 | ||
|
|
9f713ef044 | ||
|
|
275cde180b | ||
|
|
c82fb4f301 | ||
|
|
736270b08c | ||
|
|
855c9a1ffc | ||
|
|
b845eda58a | ||
|
|
6571966228 | ||
|
|
c727460251 | ||
|
|
7c7e353114 | ||
|
|
78b33f8994 | ||
|
|
324051bdf0 | ||
|
|
33a16c1af9 | ||
|
|
1bfecc2ff1 | ||
|
|
a8282a95f3 | ||
|
|
9fe617aed7 | ||
|
|
22e12b68a8 |
21
.github/ISSUE_TEMPLATE/bug.md.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/bug.md.md
vendored
Normal 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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
18
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
18
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal 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
15
.github/ISSUE_TEMPLATE/other.md
vendored
Normal 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
2316
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@@ -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"
|
||||
|
||||
51
README.md
51
README.md
@@ -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 @@ It’s 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
|
||||
|
||||
[](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 you’d 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 you’d 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 you’d like to support its development, you can consider making a small donation or helping spread the word.
|
||||
Financial support is optional but deeply appreciated — it helps me dedicate more time and resources to building new features and improving the overall experience.
|
||||
|
||||
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!
|
||||
|
||||
[](https://buymeacoffee.com/rustmailer)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
162
src/modules/import/mod.rs
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
49
src/modules/rest/api/import.rs
Normal file
49
src/modules/rest/api/import.rs
Normal 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?))
|
||||
}
|
||||
}
|
||||
@@ -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!(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
581
web/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -93,7 +93,6 @@ export default function Step3() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormLabel className="flex items-center justify-between">{t('accounts.dateSince')}:</FormLabel>
|
||||
<RadioGroup
|
||||
defaultValue={rangeType}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -60,4 +60,5 @@ export interface AccountModel {
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
use_proxy?: number
|
||||
use_dangerous: boolean
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '.';
|
||||
}
|
||||
@@ -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": "صندوق البريد",
|
||||
|
||||
@@ -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 TLS‑certifikat",
|
||||
"useDangerousDescription": "Aktivér denne indstilling kun hvis du opretter forbindelse til en IMAP‑server, der bruger et offentligt CA‑certifikat 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 man‑in‑the‑middle‑angreb — aktiver kun hvis du forstår risikoen."
|
||||
},
|
||||
"mailbox": {
|
||||
"title": "Mailboks",
|
||||
|
||||
@@ -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 TLS‑Zertifikat vertrauen",
|
||||
"useDangerousDescription": "Aktivieren Sie diese Option nur, wenn Sie sich mit einem IMAP‑Server verbinden, der ein öffentliches CA‑Zertifikat oder ein selbstsigniertes Zertifikat verwendet, das Ihr System möglicherweise nicht erkennt. Diese Einstellung umgeht die Standard‑Zertifikatsprüfung und kann Sie für Man‑in‑the‑Middle‑Angriffe anfällig machen – aktivieren Sie nur, wenn Sie die Risiken verstehen."
|
||||
},
|
||||
"mailbox": {
|
||||
"title": "Postfach",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 auto‑firmado, 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 “man‑in‑the‑middle” — actívala solo si entiendes los riesgos."
|
||||
},
|
||||
"mailbox": {
|
||||
"title": "Buzón",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. S’il 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 à n’importe quel certificat TLS",
|
||||
"useDangerousDescription": "Activez cette option seulement si vous vous connectez à un serveur IMAP utilisant un certificat public ou auto‑signé 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 homme‑du‑milieu — n’activez que si vous comprenez les risques."
|
||||
},
|
||||
"mailbox": {
|
||||
"title": "Boîte aux lettres",
|
||||
|
||||
@@ -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 auto‑firmato che il tuo sistema potrebbe non riconoscere. Questa impostazione salta la verifica standard del certificato e può esporre la connessione ad attacchi “man‑in‑the‑middle” — attivala solo se comprendi i rischi."
|
||||
},
|
||||
"mailbox": {
|
||||
"title": "Posta in arrivo",
|
||||
|
||||
@@ -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": "メールボックス",
|
||||
|
||||
@@ -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": "받은 편지함",
|
||||
|
||||
@@ -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 TLS‑certificaat",
|
||||
"useDangerousDescription": "Schakel deze optie alleen in als je verbinding maakt met een IMAP‑server die een openbaar CA‑ of een self‑signed certificaat gebruikt dat door je systeem mogelijk niet wordt vertrouwd. Deze instelling omzeilt standaard certificaatverificatie en kan je blootstellen aan man‑in‑the‑middle‑aanvallen — activeer alleen als je de risico’s begrijpt."
|
||||
},
|
||||
"mailbox": {
|
||||
"title": "Postvak In",
|
||||
|
||||
@@ -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 TLS‑sertifikat",
|
||||
"useDangerousDescription": "Aktiver dette alternativet kun hvis IMAP‑serveren bruker et offentlig CA‑sertifikat eller et selvsignert sertifikat som systemet ditt ikke gjenkjenner. Innstillingen hopper over vanlig sertifikatvalidering, noe som kan utsette deg for man‑in‑the‑middle‑angrep – bruk kun hvis du forstår risikoen."
|
||||
},
|
||||
"mailbox": {
|
||||
"title": "Postkasse",
|
||||
|
||||
@@ -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 man‑in‑the‑middle — ative apenas se compreender os riscos."
|
||||
},
|
||||
"mailbox": {
|
||||
"title": "Caixa de Entrada",
|
||||
|
||||
@@ -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": "Почтовый ящик",
|
||||
|
||||
@@ -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 TLS‑certifikat som helst",
|
||||
"useDangerousDescription": "Aktivera detta alternativ endast om IMAP‑servern 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 man‑i‑mitten‑attacker — slå på endast om du förstår riskerna."
|
||||
},
|
||||
"mailbox": {
|
||||
"title": "Brevlåda",
|
||||
|
||||
@@ -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": "信箱",
|
||||
|
||||
@@ -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": "邮箱",
|
||||
|
||||
Reference in New Issue
Block a user