Compare commits

...
Author SHA1 Message Date
rustmailer 409370be4b bump version 2026-07-31 22:50:46 +08:00
rustmailer 657371c669 update 2026-07-31 18:23:42 +08:00
rustmailer 8ab549c27c Merge branch 'main' of https://github.com/rustmailer/bichon 2026-07-31 18:03:07 +08:00
rustmailer 1b7daff10d fix: Proxy field shows error when editing an account #335 2026-07-31 18:03:05 +08:00
rustmailerandGitHub 7c7a490091 Merge pull request #325 from hammaschlach/feature/imap-poll-interval
Allow IMAP sync intervals as low as 1 minute
2026-07-29 17:07:49 +08:00
rustmailer 57a3c3c519 Update README.md 2026-07-29 00:24:09 +08:00
rustmailer a280fb6198 feat(server): auto-build web frontend in build.rs 2026-07-29 00:24:06 +08:00
misolau b4972d81f0 Allow IMAP sync intervals as low as 1 minute 2026-07-15 08:35:22 +00:00
27 changed files with 261 additions and 80 deletions
+56
View File
@@ -0,0 +1,56 @@
# Contributor License Agreement
Thank you for contributing to Bichon ("the Project"), maintained by rustmailer ("the Maintainer").
This Agreement clarifies the intellectual property rights granted by You to the Maintainer when You submit a Contribution. By signing this Agreement, You accept and agree to the following terms.
## 1. Definitions
- **"You"** means the individual or legal entity (corporation, organization, etc.) on whose behalf the Contribution is submitted.
- **"Contribution"** means any original work of authorship, including modifications or additions to existing work, that You intentionally submit to the Project (via Pull Request, patch, email, or any other means).
## 2. Copyright License
You grant the Maintainer a **perpetual, worldwide, non-exclusive, royalty-free, irrevocable** copyright license to:
- Reproduce, modify, adapt, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contribution and any derivative works thereof.
- **Re-license** Your Contribution under different terms, including but not limited to proprietary or commercial licenses, in any medium now known or later developed.
This license survives termination of this Agreement and continues even if You stop contributing.
## 3. Patent License
You grant the Maintainer a **perpetual, worldwide, non-exclusive, royalty-free, irrevocable** patent license to make, have made, use, sell, offer for sale, import, and otherwise transfer Your Contribution, where such license applies only to patent claims licensable by You that are necessarily infringed by Your Contribution alone or in combination with the Project.
## 4. Moral Rights
To the fullest extent permitted by applicable law, You waive, and agree not to assert, any moral rights (including rights of attribution, integrity, or withdrawal) in Your Contribution against the Maintainer or the Maintainer's licensees. If under Your jurisdiction moral rights cannot be waived, You agree not to enforce them against the Maintainer.
## 5. Employer / Entity Disclaimer
If You are employed by or acting on behalf of an entity, You represent that:
- You have received permission from Your employer or entity to submit the Contribution under the terms of this Agreement.
- Your employer or entity has waived any rights to the Contribution.
If Your employer or entity has not provided such permission, You must not submit a Contribution.
## 6. Representations
You represent and warrant that:
- You are legally entitled to make the Contribution and grant the licenses under this Agreement.
- Your Contribution is Your original work, and You have the necessary rights from any third parties whose material is included.
- You are not aware of any claims, pending litigation, or other issues that would affect the rights granted under this Agreement.
## 7. No Obligation
The Maintainer is under no obligation to accept or use Your Contribution. You are under no obligation to make further Contributions.
## 8. Governing Law
This Agreement is governed by the laws of the State of Delaware, United States of America, without regard to its conflict of law principles. Any dispute arising from this Agreement shall be resolved exclusively in the courts of Delaware.
## 9. Signing
You indicate Your acceptance of this Agreement by signing the [cla-assistant](https://github.com/cla-assistant/cla-assistant) prompt when submitting a Pull Request to the Project. Electronic acceptance constitutes a binding signature under applicable law.
Generated
+5 -5
View File
@@ -299,7 +299,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bichon-admin"
version = "2.0.0-alpha.1"
version = "2.0.1-alpha.1"
dependencies = [
"bichon-blob",
"bichon-core",
@@ -346,7 +346,7 @@ dependencies = [
[[package]]
name = "bichon-cli"
version = "2.0.0-alpha.1"
version = "2.0.1-alpha.1"
dependencies = [
"base64 0.22.1",
"bichon-core",
@@ -368,7 +368,7 @@ dependencies = [
[[package]]
name = "bichon-core"
version = "2.0.0-alpha.1"
version = "2.0.1-alpha.1"
dependencies = [
"async-imap",
"base64 0.22.1",
@@ -444,7 +444,7 @@ dependencies = [
[[package]]
name = "bichon-server"
version = "2.0.0-alpha.1"
version = "2.0.1-alpha.1"
dependencies = [
"bichon-core",
"bichon-smtp",
@@ -469,7 +469,7 @@ dependencies = [
[[package]]
name = "bichon-smtp"
version = "2.0.0-alpha.1"
version = "2.0.1-alpha.1"
dependencies = [
"base64 0.22.1",
"bichon-core",
+1 -1
View File
@@ -13,7 +13,7 @@ members = [
resolver = "2"
[workspace.package]
version = "2.0.0-alpha.1"
version = "2.0.1-alpha.1"
edition = "2021"
[workspace.dependencies]
+55 -44
View File
@@ -93,17 +93,17 @@
- **Attachment Search**: Browse and filter attachments by sender, file type, size, and other attachment properties.
- **Faceted Tags**: Add, remove, or overwrite tags on messages and attachments. Filter by tag combinations with real-time count updates.
- **Contacts View**: Extracted and deduplicated sender/recipient address book across all authorized accounts.
- **Three-Layer Storage**: Tantivy for full-text indexing (Zstd compression), Fjall with LZ4 for compressed blob storage, and memdb for relational metadata. All embedded — zero external dependencies.
- **Three-Layer Storage**: Tantivy for full-text indexing (Zstd compression), bichon-blob with Zstd for compressed blob storage, and memdb for relational metadata. All embedded — zero external dependencies.
- **Content Deduplication**: Identical email bodies and attachments stored once via BLAKE3 content hashing. Folder moves update metadata only.
- **Dashboard Analytics**: Email volume trends, top senders, storage usage breakdown, attachment statistics, and per-account activity. Scoped by user permissions.
- **OpenAPI 3.0**: Interactive API documentation at `/api-docs` (Swagger UI, ReDoc, Scalar). All endpoints documented with request/response schemas.
- **Multi-User RBAC**: 5 built-in roles (Admin, Manager, Member, AccountManager, AccountViewer) plus custom roles with 22 granular permissions.
- **Account-Level Isolation**: Grant users access to specific accounts with scoped roles. Permissions enforced at the API layer.
- **CLI Import Tools**: Import from EML directories, MBOX files (including Gmail variants), Thunderbird profiles, and Outlook PST files.
- **CLI & WebUI Import Tools**: Import from EML directories, MBOX files (including Gmail variants), Thunderbird profiles, and Outlook PST files via CLI. Import EML files directly from the WebUI.
- **CLI Export**: Download account data as MBOX via `bichon-cli`.
- **Bulk Restore**: Restore emails in bulk back to their original IMAP accounts.
- **Embedded SMTP Server**: Receive emails directly at the gateway level. STARTTLS or TLS encryption. AUTH PLAIN/LOGIN with API token authentication.
- **Admin Tooling**: Password reset for locked-out admins. Non-destructive v0.3.7 to v1.0 data migration.
- **Admin Tooling**: Password reset for locked-out admins. Non-destructive migration from v0.3.7 and v1.x to v2.x.
- **API Token Management**: Create, list, and revoke long-lived API tokens for programmatic access.
- **SOCKS5 Proxy Management**: Configure and manage proxy profiles for routing IMAP traffic per account.
- **Scheduled Download**: Configure per-account download schedules using cron expressions. Run syncs at specific times or intervals — for example, nightly-only or business-hours-only archiving.
@@ -185,10 +185,7 @@ Download from the [Releases](https://github.com/rustmailer/bichon/releases) page
git clone https://github.com/rustmailer/bichon.git
cd bichon
# Build the WebUI (required before building the server)
cd web && pnpm install && pnpm run build && cd ..
# Build and run
# Build and run — frontend dependencies are installed and built automatically via build.rs
export BICHON_ENCRYPT_PASSWORD=dev-password
cargo run -- --bichon-root-dir /tmp/bichon-data
```
@@ -199,9 +196,6 @@ For frontend development:
cd web && pnpm run dev # Vite dev server with API proxy to Rust backend
```
> [!TIP]
> The WebUI must be built at least once (`pnpm run build`) for the server to serve the frontend. In dev mode (`pnpm run dev`), Vite proxies API calls to the Rust server automatically.
## Configuration Reference
All settings accept both CLI flags (`--bichon-http-port`) and environment variables (`BICHON_HTTP_PORT`). CLI flags take precedence over environment variables.
@@ -270,7 +264,7 @@ All settings accept both CLI flags (`--bichon-http-port`) and environment variab
| Variable | Default | Description |
|----------|---------|-------------|
| `BICHON_INDEX_DIR` | `{root}/bichon-indices` | Tantivy full-text index directory |
| `BICHON_DATA_DIR` | `{root}/bichon-storage` | Fjall blob storage directory |
| `BICHON_DATA_DIR` | `{root}/bichon-storage` | bichon-blob storage directory |
> [!TIP]
> Place `BICHON_INDEX_DIR` on fast SSD storage for responsive search, and `BICHON_DATA_DIR` on high-capacity HDD for cost-effective blob storage.
@@ -378,12 +372,13 @@ All imports are processed server-side — the server handles MIME parsing, index
./bichon-admin
```
Interactive menu with two operations:
Interactive menu with three operations:
| Operation | Description |
|-----------|-------------|
| **Reset Admin Password** | Reset the built-in admin password when locked out |
| **Migrate v0.3.7 → v1.0** | Non-destructive migration from legacy storage layout to v1.0 architecture |
| **Migrate v0.3.7 → v2.x** | Non-destructive migration from legacy Tantivy-based storage to v2.x |
| **Migrate v1.x → v2.x** | Blob-only migration from Fjall to bichon-blob (indexes and metadata untouched) |
## API Reference
@@ -409,10 +404,11 @@ All `/api/v1/*` endpoints require `Authorization: Bearer <token>`.
| **MBOX** | `bichon-cli` | Single-file streaming import; supports Gmail's MBOX variant |
| **Thunderbird** | `bichon-cli` | Reads directly from local Thunderbird profile directory |
| **PST** | `bichon-cli` | Outlook Personal Storage (`.pst`) file parsing |
| **WebUI Import** | WebUI | Upload `.eml` files directly from the browser |
| **API Import** | `POST /api/v1/import` | Base64-encoded EML payloads for programmatic use |
| **MBOX Export** | `bichon-cli` | Download account data as `.mbox` file |
All imports flow through the Bichon REST API. The server parses MIME, extracts metadata, indexes content into Tantivy, deduplicates by BLAKE3 content hash, and stores raw blobs in Fjall.
All imports flow through the Bichon REST API. The server parses MIME, extracts metadata, indexes content into Tantivy, deduplicates by BLAKE3 content hash, and stores raw blobs in bichon-blob.
## Architecture
@@ -438,12 +434,12 @@ Request Layer
Storage Layer │
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ memdb │ │ Tantivy │ │ Fjall
│ memdb │ │ Tantivy │ │ bichon-blob
│ (metadata) │ │ (full-text) │ │ (blobs) │
│ │ │ │ │ │
│ • accounts │ │ • envelope │ │ • raw emails │
│ • users │ │ • attachment │ │ • attachments│
│ • roles │ │ • tags │ │ LZ4 compr.
│ • roles │ │ • tags │ │ Zstd compr.│
│ • config │ │ • contacts │ │ │
│ • proxies │ │ Zstd compr.│ │ BLAKE3 hash │
└──────────────┘ └──────────────┘ └──────────────┘
@@ -451,7 +447,7 @@ Storage Layer │
- **memdb**: Key-value metadata store. Houses accounts, users, roles, OAuth2 configs, proxy settings, and system configuration.
- **Tantivy**: Full-text search indices with Zstd compression support. Two separate indices: envelope (email metadata + body text) and attachment (file metadata + extracted text). Batch-committed every 1,000 documents or 60 seconds.
- **Fjall**: LZ4-compressed LSM tree key-value store. Two keyspaces — `email_keyspace` and `attachments_keyspace`. Content-hash addressed (BLAKE3) with insert-time deduplication. Values larger than 1 KB stored as separate files (KV separation).
- **bichon-blob**: Zstd-compressed log-structured storage engine. Append-only segment files (1&nbsp;GB each) with a redb-backed index for O(1) key lookup. Content-hash addressed (BLAKE3) with insert-time deduplication. Supports global dedup, online GC, and crash-safe recovery.
### IMAP Download Pipeline
@@ -477,7 +473,7 @@ extract_envelope_and_store_it()
┌────┼────┐
▼ ▼ ▼
Tantivy Fjall memdb
Tantivy bichon-blob memdb
```
- Per-account background tasks managed by a global download-task singleton
@@ -513,7 +509,7 @@ Tantivy Fjall memdb
│ attachment │ │ → attachment_content_hash │
│ bytes with │ │ │
│ placeholder: │ │ Store raw undecoded bytes │
│ │ │ in Fjall attachments_ks
│ │ │ in bichon-blob
│ <<BICHON_ │ │ (skip if hash exists) │
│ DETACH_HASH: │ │ │
│ xxx>> │ │ Extract text for indexing │
@@ -523,7 +519,7 @@ Tantivy Fjall memdb
▼ │
┌──────────────────────────────┐ │
│ Stripped EML stored in │ │
Fjall email_keyspace │ │
bichon-blob │ │
│ keyed by email_content_hash │ │
│ (skip if hash exists) │ │
└──────────────┬───────────────┘ │
@@ -538,8 +534,8 @@ Tantivy Fjall memdb
Dedup layers
┌─────────────────────────────────────────────────────────────────┐
Fjall (insert-time)
│ contains_key(hash)? → skip : store with LZ4 compression │
bichon-blob (insert-time) │
│ contains_key(hash)? → skip : store with Zstd compression │
│ │
│ Tantivy (periodic, every 12 h) │
│ Group by (account, mailbox, content_hash) │
@@ -549,14 +545,14 @@ Tantivy Fjall memdb
Reconstruction
┌─────────────────────────────────────────────────────────────────┐
│ Fetch stripped EML by content_hash from Fjall
│ Fetch stripped EML by content_hash from bichon-blob
│ Find <<BICHON_DETACH_HASH:xxx>> placeholders │
│ Replace each with raw attachment blob from Fjall
│ Replace each with raw attachment blob from bichon-blob
│ Result → byte-identical original EML │
└─────────────────────────────────────────────────────────────────┘
```
Every ingested email is hashed with BLAKE3. Attachments are detached from the MIME tree, hashed independently (decoded content), and stored as raw undecoded bytes in Fjall's `attachments_keyspace`. The email body is patched with hash-based placeholders and stored in `email_keyspace`. Both keyspaces check for existing hashes before writing — identical content is never stored twice, regardless of which account or folder it arrives in. A periodic index dedup task (every 12 hours) scans Tantivy for duplicate `(account, mailbox, content_hash)` tuples, keeps the most recently ingested copy, and cascade-deletes orphaned attachment entries so UID-based incremental sync remains accurate. The original EML reconstructs byte-for-byte by swapping placeholders back with their attachment blobs.
Every ingested email is hashed with BLAKE3. Attachments are detached from the MIME tree, hashed independently (decoded content), and stored as raw undecoded bytes in bichon-blob. The email body is patched with hash-based placeholders and stored separately. Both email and attachment blobs are deduplicated by content hash — identical content is never stored twice, regardless of which account or folder it arrives in. A periodic index dedup task (every 12 hours) scans Tantivy for duplicate `(account, mailbox, content_hash)` tuples, keeps the most recently ingested copy, and cascade-deletes orphaned attachment entries so UID-based incremental sync remains accurate. The original EML reconstructs byte-for-byte by swapping placeholders back with their attachment blobs.
## Storage & Backup
@@ -565,7 +561,7 @@ Every ingested email is hashed with BLAKE3. Attachments are detached from the MI
```
{root}/
├── bichon-indices/ Tantivy full-text index (envelope + attachment)
├── bichon-storage/ Fjall LZ4-compressed blob store
├── bichon-storage/ bichon-blob Zstd-compressed blob store
├── memdb/ Metadata database (accounts, users, roles, config)
├── logs/ Server logs (when BICHON_LOG_TO_FILE=true)
```
@@ -605,25 +601,36 @@ The WebUI is available in **18 languages**:
Language preference and UI theme are saved to your user profile and can be changed anytime from the WebUI settings.
## Data Migration (v0.3.7 → v1.x)
## Data Migration
Bichon v1.x introduced a redesigned storage architecture:
Bichon v2.x replaces the Fjall blob engine with bichon-blob. Two migration paths are available:
| Layer | v0.3.7 (Legacy) | v1.x |
| :--- | :--- | :--- |
| **Index** | Tantivy (shared instance, no full attachments) | Tantivy (separate envelope + attachment indices) |
| **Raw data** | Tantivy (inline, stored in another Tantivy instance) | Fjall (LZ4-compressed LSM-tree key-value store) |
| **Metadata** | Native_DB (shared, disk-based DB powered by redb) | memdb (dedicated, in-house in-memory DB) |
| Layer | v0.3.7 (Legacy) | v1.x | v2.x |
| :--- | :--- | :--- | :--- |
| **Index** | Tantivy (inline) | Tantivy (separate envelope + attachment) | Tantivy (unchanged from v1.x) |
| **Blobs** | Tantivy (inline) | Fjall (LZ4-compressed LSM tree) | bichon-blob (Zstd-compressed log-structured) |
| **Metadata** | native_db (redb-backed) | memdb | memdb (unchanged from v1.x) |
If you ran Bichon prior to v1.x, migrate your data:
**v0.3.7 → v2.x** (full migration):
```bash
./bichon-admin
# Select "Migrate Legacy v0.3.7 Storage to v1.x"
# Select "Migrate Legacy v0.3.7 Storage to v2.x"
```
Rebuilds Tantivy indexes, migrates metadata to memdb, and converts blobs to bichon-blob.
**v1.x → v2.x** (blob-only):
```bash
./bichon-admin
# Select "Migrate v1.x Storage to v2.x"
```
Copies blobs from Fjall to bichon-blob. Tantivy indexes and memdb are left untouched.
> [!NOTE]
> The migration is **non-destructive** — original v0.3.7 files remain in place and are not modified. You can safely remove them manually after verifying the migration was successful.
> Both migrations are **non-destructive** — legacy files are never modified. After verifying the migration was successful, see the [Migration Guide](https://github.com/rustmailer/bichon/wiki/Bichon-v2.x-Migration-Guide) for cleanup instructions.
## FAQ
@@ -636,7 +643,7 @@ If you ran Bichon prior to v1.x, migrate your data:
### "Legacy data layout detected" error on startup
Your data was created by Bichon v0.3.7 and must be migrated. Run `./bichon-admin` and select the migration option.
Your data was created by an older version of Bichon and must be migrated. Run `./bichon-admin` and select the appropriate migration option.
### How do I run Bichon behind a reverse proxy?
@@ -684,7 +691,7 @@ No. Bichon is an **archiver**, not an email client. The optional SMTP server **r
- [x] CLI import: EML, MBOX, Thunderbird, PST
- [x] CLI export: MBOX
- [x] Embedded SMTP server
- [x] Data migration tooling (v0.3.7 → v1.0)
- [x] Data migration tooling (v0.3.7 / v1.x → v2.x)
- [x] On-demand manual download controls
- [ ] Post-download server cleanup (free remote mailbox space)
- [ ] Account-to-account email merge / migration
@@ -696,14 +703,14 @@ No. Bichon is an **archiver**, not an email client. The optional SMTP server **r
Contributions of all kinds are welcome — code, bug reports, documentation, or feature suggestions.
> [!IMPORTANT]
> By submitting a Pull Request, you agree to the terms of the [Contributor License Agreement](CLA.md).
```bash
git clone https://github.com/rustmailer/bichon.git
cd bichon
# Build WebUI
cd web && pnpm install && pnpm run build && cd ..
# Build backend
# Build backend — frontend dependencies and build are handled automatically via build.rs
cargo build
# Run tests
@@ -711,7 +718,11 @@ cargo test
```
> [!IMPORTANT]
> Before implementing a new feature or making significant changes, please **open an issue first** to discuss your idea with the maintainer and ensure it aligns with the project's scope.
> **For new features:** Please **open a feature request issue first** before starting implementation. PRs that introduce new functionality without a prior issue may be **rejected** to avoid unnecessary wasted effort.
>
> **For major bug fixes** with wide-ranging impact, please **open an issue and discuss with the maintainer** before acting and submitting. This ensures the fix approach is aligned and avoids duplicate or conflicting work.
>
> **Large, hard-to-review PRs** that touch many modules or contain substantial changes may be **rejected outright**. Break your work into smaller, focused PRs — one logical change per PR.
Feel free to open an [Issue](https://github.com/rustmailer/bichon/issues) or join the [Discord](https://discord.gg/Bq4M2cDmF4) to discuss ideas.
@@ -746,7 +757,7 @@ Rules:
|-------|-----------|
| **Backend** | Rust, Tokio, Poem + Poem OpenAPI |
| **Full-text search** | Tantivy (Zstd compression) |
| **Blob storage** | Fjall (LSM tree, LZ4 compression, KV separation) |
| **Blob storage** | bichon-blob (log-structured, Zstd compression, BLAKE3 dedup) |
| **Metadata DB** | memdb (embedded key-value store with WAL) |
| **IMAP** | async-imap, rustls (ring), SOCKS5 proxy support |
| **SMTP** | Embedded receiver (AUTH PLAIN/LOGIN, STARTTLS/TLS) |
+2 -2
View File
@@ -43,7 +43,7 @@ pub struct AccountCreateRequest {
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub account_type: AccountType,
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "10"))))]
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "1"))))]
pub download_interval_min: Option<i64>,
#[cfg_attr(
feature = "web-api",
@@ -179,7 +179,7 @@ pub struct AccountUpdateRequest {
/// Modified folders will be automatically synced on the next update.
pub sync_folders: Option<Vec<String>>,
/// Incremental download interval (seconds)
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "10"))))]
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "1"))))]
pub download_interval_min: Option<i64>,
#[cfg_attr(
feature = "web-api",
+97 -1
View File
@@ -4,8 +4,9 @@ fn main() -> Result<()> {
if cfg!(target_os = "windows") {
println!("cargo:rustc-link-lib=Rstrtmgr");
}
let output = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"])
.args(["rev-parse", "--short", "HEAD"])
.output()
.expect("Failed to get git commit hash");
let git_hash = String::from_utf8(output.stdout)
@@ -13,5 +14,100 @@ fn main() -> Result<()> {
.trim()
.to_string();
println!("cargo:rustc-env=GIT_HASH={}", git_hash);
// Build frontend assets
build_frontend();
Ok(())
}
fn build_frontend() {
let web_dir = std::path::Path::new("../../web");
let dist_dir = web_dir.join("dist");
// Re-run build.rs when frontend source or dependencies change.
println!("cargo:rerun-if-changed=../../web/package.json");
println!("cargo:rerun-if-changed=../../web/pnpm-lock.yaml");
println!("cargo:rerun-if-changed=../../web/tsconfig.json");
println!("cargo:rerun-if-changed=../../web/vite.config.ts");
println!("cargo:rerun-if-changed=../../web/index.html");
println!("cargo:rerun-if-changed=../../web/src/");
// Find a working pnpm command. On Windows it may only be available
// via cmd.exe (PATH set by the installer, not inherited by bash).
let mut pnpm_cmd = None;
for candidate in &["pnpm", "pnpm.cmd"] {
if command_ok(candidate, &["--version"], web_dir) {
pnpm_cmd = Some(*candidate);
break;
}
}
// Windows fallback: run through cmd.exe which has the full system PATH.
if pnpm_cmd.is_none() && cfg!(target_os = "windows") {
if command_ok("cmd", &["/c", "pnpm.cmd", "--version"], web_dir) {
pnpm_cmd = Some("cmd");
}
}
let Some(cmd) = pnpm_cmd else {
// If dist/ already exists (built manually), allow the build to proceed.
if dist_dir.exists() {
eprintln!(
"WARNING: `pnpm` not found in PATH but `web/dist/` exists.\n\
The embedded frontend may be stale. Install pnpm and run\n\
`cd web && pnpm run build` to update it.\n"
);
return;
}
eprintln!(
"\nERROR: `pnpm` not found. Install Node.js and pnpm, then run:\n\
cd web && pnpm install && pnpm run build\n"
);
std::process::exit(1);
};
if !web_dir.join("node_modules").exists() {
// Fresh clone — install dependencies first.
eprintln!("Installing frontend dependencies (one-time setup)...");
if !run_pnpm(cmd, &["install"], web_dir) {
eprintln!("\nERROR: `pnpm install` failed. See output above.\n");
std::process::exit(1);
}
}
if !run_pnpm(cmd, &["run", "build"], web_dir) {
eprintln!("\nERROR: `pnpm run build` failed. See output above.\n");
std::process::exit(1);
}
}
fn command_ok(cmd: &str, args: &[&str], dir: &std::path::Path) -> bool {
Command::new(cmd)
.args(args)
.current_dir(dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn run_pnpm(cmd: &str, args: &[&str], dir: &std::path::Path) -> bool {
if cmd == "cmd" {
let mut full_args = vec!["/c", "pnpm.cmd"];
full_args.extend_from_slice(args);
Command::new("cmd")
.args(&full_args)
.current_dir(dir)
.status()
.map(|s| s.success())
.unwrap_or(false)
} else {
Command::new(cmd)
.args(args)
.current_dir(dir)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
}
@@ -47,7 +47,7 @@ const emptyImap = {
};
function mapAccountToFormValues(account: AccountModel): AccountFormValues {
const imap = { ...(account.imap ?? emptyImap) };
const imap = { ...(account.imap ?? emptyImap), use_proxy: account.imap?.use_proxy ?? undefined };
imap.auth = { ...imap.auth, password: undefined };
return {
@@ -154,18 +154,18 @@ describe('Account Form Schema', () => {
})
describe('download_interval_min field', () => {
it('rejects value less than 10', () => {
it('rejects value less than 1', () => {
const result = getAccountSchema(false, t).safeParse({
...validAccountData,
download_interval_min: 5,
download_interval_min: 0,
})
expect(result.success).toBe(false)
})
it('accepts value of exactly 10', () => {
it('accepts value of exactly 1', () => {
const result = getAccountSchema(false, t).safeParse({
...validAccountData,
download_interval_min: 10,
download_interval_min: 1,
})
expect(result.success).toBe(true)
})
@@ -97,8 +97,8 @@ export const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
invalid_type_error: t('validation.incrementalSyncMustBeNumber'),
})
.int()
.min(10, {
message: t('validation.incrementalSyncMustBeAtLeast10'),
.min(1, {
message: t('validation.incrementalSyncMustBeAtLeast1'),
}),
download_batch_size: z
.number({
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "بدون وكيل",
"useProxy": "استخدام وكيل",
"useProxyField": "استخدام وكيل",
"useProxyOptional": "استخدام الوكيل (اختياري)",
"value": "القيمة",
"viewDetails": "عرض التفاصيل",
"years": "سنوات"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "مُضيف IMAP مطلوب",
"imapPortMustBeLessThan65536": "يجب أن يكون منفذ IMAP أقل من 65536",
"imapPortMustBePositive": "يجب أن يكون منفذ IMAP عددًا صحيحًا موجبًا",
"incrementalSyncMustBeAtLeast10": "يجب أن يكون فاصل المزامنة التزايدية 10 دقائق على الأقل",
"incrementalSyncMustBeAtLeast1": "يجب أن يكون فاصل المزامنة التزايدية 1 دقيقة على الأقل",
"incrementalSyncMustBeNumber": "يجب أن يكون فاصل المزامنة التزايدية رقمًا",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "عنوان بريد إلكتروني غير صالح",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Ingen proxy",
"useProxy": "Brug Proxy",
"useProxyField": "brug proxy",
"useProxyOptional": "Brug proxy (valgfri)",
"value": "Værdi",
"viewDetails": "vis detaljer",
"years": "År"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "IMAP-vært er påkrævet",
"imapPortMustBeLessThan65536": "IMAP-port skal være mindre end 65536",
"imapPortMustBePositive": "IMAP-port skal være et positivt heltal",
"incrementalSyncMustBeAtLeast10": "Inkrementelt synkroniseringsinterval skal være mindst 10 minutter",
"incrementalSyncMustBeAtLeast1": "Inkrementelt synkroniseringsinterval skal være mindst 1 minut",
"incrementalSyncMustBeNumber": "Inkrementelt synkroniseringsinterval skal være et tal",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Ugyldig e-mailadresse",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Kein Proxy",
"useProxy": "Proxy verwenden",
"useProxyField": "Proxy verwenden",
"useProxyOptional": "Proxy verwenden (optional)",
"value": "Wert",
"viewDetails": "Details anzeigen",
"years": "Jahre"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "IMAP-Host ist erforderlich",
"imapPortMustBeLessThan65536": "Der IMAP-Port muss kleiner als 65536 sein",
"imapPortMustBePositive": "Der IMAP-Port muss eine positive ganze Zahl sein",
"incrementalSyncMustBeAtLeast10": "Das inkrementelle Synchronisierungsintervall muss mindestens 10 Minuten betragen",
"incrementalSyncMustBeAtLeast1": "Das inkrementelle Synchronisierungsintervall muss mindestens 1 Minute betragen",
"incrementalSyncMustBeNumber": "Das inkrementelle Synchronisierungsintervall muss eine Zahl sein",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Ungültige E-Mail-Adresse",
+4 -3
View File
@@ -381,6 +381,7 @@
"useNoProxy": "No Proxy",
"useProxy": "Use Proxy",
"useProxyField": "use proxy",
"useProxyOptional": "Use proxy (optional)",
"value": "Value",
"viewDetails": "view details",
"years": "Years"
@@ -1421,9 +1422,9 @@
},
"proxy": "Proxy",
"proxyTest": "Check Proxy",
"proxyTesting": "Checking...",
"proxyTestFailed": "Proxy check failed",
"proxyTestSuccess": "Proxy works",
"proxyTesting": "Checking...",
"proxyUpdateOrAddFailed": "{{action}} failed, please try again later",
"reset": "Reset",
"resetRootPassword": "Reset Root Password",
@@ -1807,7 +1808,7 @@
"imapHostRequired": "IMAP host is required",
"imapPortMustBeLessThan65536": "IMAP port must be less than 65536",
"imapPortMustBePositive": "IMAP port must be a positive integer",
"incrementalSyncMustBeAtLeast10": "Incremental sync interval must be at least 10 minutes",
"incrementalSyncMustBeAtLeast1": "Incremental sync interval must be at least 1 minute",
"incrementalSyncMustBeNumber": "Incremental sync interval must be a number",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Invalid email address",
@@ -1824,4 +1825,4 @@
"singleRequestBatchSizeTooLarge": "Batch size must be at most 200",
"singleRequestBatchSizeTooSmall": "Batch size must be at least 10"
}
}
}
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Sin proxy",
"useProxy": "Usar Proxy",
"useProxyField": "usar proxy",
"useProxyOptional": "Usar proxy (opcional)",
"value": "Valor",
"viewDetails": "ver detalles",
"years": "Años"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "El host IMAP es obligatorio",
"imapPortMustBeLessThan65536": "El puerto IMAP debe ser menor que 65536",
"imapPortMustBePositive": "El puerto IMAP debe ser un número entero positivo",
"incrementalSyncMustBeAtLeast10": "El intervalo de sincronización incremental debe ser de al menos 10 minutos",
"incrementalSyncMustBeAtLeast1": "El intervalo de sincronización incremental debe ser de al menos 1 minuto",
"incrementalSyncMustBeNumber": "El intervalo de sincronización incremental debe ser un número",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Dirección de correo electrónico inválida",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Ei välityspalvelinta",
"useProxy": "Käytä välityspalvelinta",
"useProxyField": "käytä välityspalvelinta",
"useProxyOptional": "Käytä välityspalvelinta (valinnainen)",
"value": "Arvo",
"viewDetails": "katso tiedot",
"years": "Vuotta"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "IMAP-isäntä on pakollinen",
"imapPortMustBeLessThan65536": "IMAP-portin on oltava pienempi kuin 65536",
"imapPortMustBePositive": "IMAP-portin on oltava positiivinen kokonaisluku",
"incrementalSyncMustBeAtLeast10": "Lisäävän synkronoinnin välin on oltava vähintään 10 minuuttia",
"incrementalSyncMustBeAtLeast1": "Lisäävän synkronoinnin välin on oltava vähintään 1 minuutti",
"incrementalSyncMustBeNumber": "Lisäävän synkronoinnin välin on oltava numero",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Virheellinen sähköpostiosoite",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Sans proxy",
"useProxy": "Utiliser un Proxy",
"useProxyField": "utiliser un proxy",
"useProxyOptional": "Utiliser un proxy (optionnel)",
"value": "Valeur",
"viewDetails": "voir les détails",
"years": "Années"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "L'hôte IMAP est obligatoire",
"imapPortMustBeLessThan65536": "Le port IMAP doit être inférieur à 65536",
"imapPortMustBePositive": "Le port IMAP doit être un entier positif",
"incrementalSyncMustBeAtLeast10": "L'intervalle de synchronisation incrémentielle doit être d'au moins 10 minutes",
"incrementalSyncMustBeAtLeast1": "L'intervalle de synchronisation incrémentielle doit être d'au moins 1 minute",
"incrementalSyncMustBeNumber": "L'intervalle de synchronisation incrémentielle doit être un nombre",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Adresse e-mail non valide",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Nessun proxy",
"useProxy": "Usa Proxy",
"useProxyField": "usa proxy",
"useProxyOptional": "Usa proxy (opzionale)",
"value": "Valore",
"viewDetails": "visualizza dettagli",
"years": "Anni"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "L'host IMAP è obbligatorio",
"imapPortMustBeLessThan65536": "La porta IMAP deve essere inferiore a 65536",
"imapPortMustBePositive": "La porta IMAP deve essere un numero intero positivo",
"incrementalSyncMustBeAtLeast10": "L'intervallo di sincronizzazione incrementale deve essere di almeno 10 minuti",
"incrementalSyncMustBeAtLeast1": "L'intervallo di sincronizzazione incrementale deve essere di almeno 1 minuto",
"incrementalSyncMustBeNumber": "L'intervallo di sincronizzazione incrementale deve essere un numero",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Indirizzo email non valido",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "プロキシなし",
"useProxy": "プロキシを使用",
"useProxyField": "プロキシを使用",
"useProxyOptional": "プロキシを使用(任意)",
"value": "値",
"viewDetails": "詳細を見る",
"years": "年"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "IMAPホストは必須です",
"imapPortMustBeLessThan65536": "IMAPポートは65536未満である必要があります",
"imapPortMustBePositive": "IMAPポートは正の整数である必要があります",
"incrementalSyncMustBeAtLeast10": "増分同期間隔は10分以上である必要があります",
"incrementalSyncMustBeAtLeast1": "増分同期間隔は1分以上である必要があります",
"incrementalSyncMustBeNumber": "増分同期間隔は数値である必要があります",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "無効なメールアドレスです",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "프록시 없음",
"useProxy": "프록시 사용",
"useProxyField": "프록시 사용",
"useProxyOptional": "프록시 사용 (선택 사항)",
"value": "값",
"viewDetails": "세부 정보 보기",
"years": "년"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "IMAP 호스트는 필수입니다",
"imapPortMustBeLessThan65536": "IMAP 포트는 65536보다 작아야 합니다",
"imapPortMustBePositive": "IMAP 포트는 양의 정수여야 합니다",
"incrementalSyncMustBeAtLeast10": "증분 동기화 간격은 최소 10분 이상이어야 합니다",
"incrementalSyncMustBeAtLeast1": "증분 동기화 간격은 최소 1분 이상이어야 합니다",
"incrementalSyncMustBeNumber": "증분 동기화 간격은 숫자여야 합니다",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "유효하지 않은 이메일 주소",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Geen proxy",
"useProxy": "Gebruik Proxy",
"useProxyField": "gebruik proxy",
"useProxyOptional": "Proxy gebruiken (optioneel)",
"value": "Waarde",
"viewDetails": "details bekijken",
"years": "Jaren"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "IMAP host is vereist",
"imapPortMustBeLessThan65536": "IMAP poort moet kleiner zijn dan 65536",
"imapPortMustBePositive": "IMAP poort moet een positief geheel getal zijn",
"incrementalSyncMustBeAtLeast10": "Incrementaal sync interval moet ten minste 10 minuten zijn",
"incrementalSyncMustBeAtLeast1": "Incrementaal sync interval moet ten minste 1 minuut zijn",
"incrementalSyncMustBeNumber": "Incrementaal sync interval moet een nummer zijn",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Ongeldig e-mailadres",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Ingen proxy",
"useProxy": "Bruk Proxy",
"useProxyField": "bruk proxy",
"useProxyOptional": "Bruk proxy (valgfritt)",
"value": "Verdi",
"viewDetails": "se detaljer",
"years": "År"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "IMAP-vert er påkrevd",
"imapPortMustBeLessThan65536": "IMAP-port må være mindre enn 65536",
"imapPortMustBePositive": "IMAP-port må være et positivt heltall",
"incrementalSyncMustBeAtLeast10": "Intervall for inkrementell synkronisering må være minst 10 minutter",
"incrementalSyncMustBeAtLeast1": "Intervall for inkrementell synkronisering må være minst 1 minutt",
"incrementalSyncMustBeNumber": "Intervall for inkrementell synkronisering må være et tall",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Ugyldig e-postadresse",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Brak serwera proxy",
"useProxy": "Użyj Proxy",
"useProxyField": "użyj proxy",
"useProxyOptional": "Użyj serwera proxy (opcjonalnie)",
"value": "Wartość",
"viewDetails": "pokaż szczegóły",
"years": "Lata"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "Host IMAP jest wymagany",
"imapPortMustBeLessThan65536": "Port IMAP musi być liczbą w przedziale 0-65535",
"imapPortMustBePositive": "Port IMAP musi być liczbą całkowitą dodatnią",
"incrementalSyncMustBeAtLeast10": "Przyrostowy interwał synchronizacji musi wynosić co najmniej 10 minut",
"incrementalSyncMustBeAtLeast1": "Przyrostowy interwał synchronizacji musi wynosić co najmniej 1 minutę",
"incrementalSyncMustBeNumber": "Przyrostowy interwał synchronizacji musi być liczbą",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Niewłaściwy adres email",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Nenhum proxy",
"useProxy": "Usar Proxy",
"useProxyField": "Usar Proxy",
"useProxyOptional": "Usar proxy (opcional)",
"value": "Valor",
"viewDetails": "Ver Detalhes",
"years": "Anos"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "O Host IMAP é obrigatório",
"imapPortMustBeLessThan65536": "A Porta IMAP deve ser menor que 65536",
"imapPortMustBePositive": "A Porta IMAP deve ser um número inteiro positivo",
"incrementalSyncMustBeAtLeast10": "O Intervalo de Sincronização Incremental deve ser de pelo menos 10 minutos",
"incrementalSyncMustBeAtLeast1": "O Intervalo de Sincronização Incremental deve ser de pelo menos 1 minuto",
"incrementalSyncMustBeNumber": "O Intervalo de Sincronização Incremental deve ser um número",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Endereço de email inválido",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Без прокси",
"useProxy": "Использовать прокси",
"useProxyField": "использовать прокси",
"useProxyOptional": "Использовать прокси (необязательно)",
"value": "Значение",
"viewDetails": "смотреть детали",
"years": "Годы"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "IMAP хост обязателен",
"imapPortMustBeLessThan65536": "IMAP порт должен быть меньше 65536",
"imapPortMustBePositive": "IMAP порт должен быть положительным целым числом",
"incrementalSyncMustBeAtLeast10": "Интервал инкрементальной синхронизации должен быть не менее 10 минут",
"incrementalSyncMustBeAtLeast1": "Интервал инкрементальной синхронизации должен быть не менее 1 минуты",
"incrementalSyncMustBeNumber": "Интервал инкрементальной синхронизации должен быть числом",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Неверный адрес электронной почты",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "Ingen proxy",
"useProxy": "Använd Proxy",
"useProxyField": "använd proxy",
"useProxyOptional": "Använd proxy (valfritt)",
"value": "Värde",
"viewDetails": "visa detaljer",
"years": "År"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "IMAP-värd krävs",
"imapPortMustBeLessThan65536": "IMAP-port måste vara mindre än 65536",
"imapPortMustBePositive": "IMAP-port måste vara ett positivt heltal",
"incrementalSyncMustBeAtLeast10": "Intervall för inkrementell synkronisering måste vara minst 10 minuter",
"incrementalSyncMustBeAtLeast1": "Intervall för inkrementell synkronisering måste vara minst 1 minut",
"incrementalSyncMustBeNumber": "Intervall för inkrementell synkronisering måste vara ett tal",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "Ogiltig e-postadress",
+2 -1
View File
@@ -371,6 +371,7 @@
"useNoProxy": "不使用代理",
"useProxy": "使用代理",
"useProxyField": "使用代理",
"useProxyOptional": "使用代理伺服器(選填)",
"value": "值",
"viewDetails": "檢視詳細資訊",
"years": "年"
@@ -1791,7 +1792,7 @@
"imapHostRequired": "IMAP 主機為必填項",
"imapPortMustBeLessThan65536": "IMAP 連接埠必須小於 65536",
"imapPortMustBePositive": "IMAP 連接埠必須是正整數",
"incrementalSyncMustBeAtLeast10": "增量同步間隔必須至少為 10 分鐘",
"incrementalSyncMustBeAtLeast1": "增量同步間隔必須至少為 1 分鐘",
"incrementalSyncMustBeNumber": "增量同步間隔必須是數字",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "無效的電子郵件地址",
+2 -1
View File
@@ -381,6 +381,7 @@
"useNoProxy": "不使用代理",
"useProxy": "使用代理",
"useProxyField": "使用代理",
"useProxyOptional": "使用代理(可选)",
"value": "值",
"viewDetails": "查看详情",
"years": "年"
@@ -1801,7 +1802,7 @@
"imapHostRequired": "IMAP 主机为必填项",
"imapPortMustBeLessThan65536": "IMAP 端口必须小于 65536",
"imapPortMustBePositive": "IMAP 端口必须是正整数",
"incrementalSyncMustBeAtLeast10": "增量同步间隔必须至少为 10 分钟",
"incrementalSyncMustBeAtLeast1": "增量同步间隔必须至少为 1 分钟",
"incrementalSyncMustBeNumber": "增量同步间隔必须是数字",
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
"invalidEmail": "无效的电子邮件地址",