mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d40ba90b54 | ||
|
|
048d5f361c | ||
|
|
4597df515a | ||
|
|
f4be4a2e8c | ||
|
|
4a3c42c1eb | ||
|
|
8fcb55320f | ||
|
|
86869ac848 | ||
|
|
38453accc6 | ||
|
|
a60b2c7dc4 | ||
|
|
8e46c7a162 | ||
|
|
1a615e1c45 | ||
|
|
83dd9cdd6b | ||
|
|
f30cd66e00 | ||
|
|
4bd714a670 | ||
|
|
c0a63a1e3c | ||
|
|
04a022e850 | ||
|
|
f9c2fc77ff | ||
|
|
ec3e842bbb | ||
|
|
7311529908 | ||
|
|
0792bb546d | ||
|
|
0be2670600 | ||
|
|
575f851cfb | ||
|
|
61430b72b0 | ||
|
|
15c0cfc1d9 | ||
|
|
ea8c493374 | ||
|
|
005b1c2116 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
/target
|
||||
.vscode
|
||||
.idea
|
||||
config.toml
|
||||
config.toml
|
||||
node_modules
|
||||
dedup_report.txt
|
||||
59
Cargo.lock
generated
59
Cargo.lock
generated
@@ -293,7 +293,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bichon-admin"
|
||||
version = "1.2.0"
|
||||
version = "1.4.2"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"console",
|
||||
@@ -301,7 +301,6 @@ dependencies = [
|
||||
"indicatif",
|
||||
"itertools",
|
||||
"memdb",
|
||||
"mimalloc",
|
||||
"native_db",
|
||||
"native_model",
|
||||
"serde",
|
||||
@@ -312,7 +311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-cli"
|
||||
version = "1.2.0"
|
||||
version = "1.4.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
@@ -338,7 +337,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-core"
|
||||
version = "1.2.0"
|
||||
version = "1.4.2"
|
||||
dependencies = [
|
||||
"async-imap",
|
||||
"base64 0.22.1",
|
||||
@@ -346,6 +345,7 @@ dependencies = [
|
||||
"bytes 1.11.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
"cron",
|
||||
"dashmap",
|
||||
"deunicode",
|
||||
"email_address",
|
||||
@@ -396,7 +396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-server"
|
||||
version = "1.2.0"
|
||||
version = "1.4.2"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"bichon-smtp",
|
||||
@@ -404,7 +404,6 @@ dependencies = [
|
||||
"email_address",
|
||||
"governor",
|
||||
"http",
|
||||
"mimalloc",
|
||||
"poem",
|
||||
"poem-derive",
|
||||
"poem-openapi",
|
||||
@@ -421,7 +420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-smtp"
|
||||
version = "1.2.0"
|
||||
version = "1.4.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
@@ -875,6 +874,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "cron"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"once_cell",
|
||||
"winnow 0.6.26",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
@@ -1779,9 +1789,9 @@ checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||
dependencies = [
|
||||
"bytes 1.11.1",
|
||||
"itoa",
|
||||
@@ -2252,15 +2262,6 @@ version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2892ae4ea6fa2cb7acb0e236a6880d39523239cd9089de71d220910ccc806790"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2465,15 +2466,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.51"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebca48a43116bc25f18a61360f1be98412f50cc218f5e52c823086b999a4a21a"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -3931,9 +3923,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -5611,6 +5603,15 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.6.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
|
||||
@@ -6,23 +6,23 @@ members = [
|
||||
"crates/server",
|
||||
"crates/cli",
|
||||
"crates/admin",
|
||||
"crates/smtp",
|
||||
]
|
||||
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.2.0"
|
||||
version = "1.4.2"
|
||||
edition = "2021"
|
||||
|
||||
[workspace.dependencies]
|
||||
chrono = "0.4.44"
|
||||
clap = { version = "4.6.1", features = ["derive", "env"] }
|
||||
mimalloc = "0.1.51"
|
||||
memdb = { path = "crates/memdb" }
|
||||
itertools = "0.14.0"
|
||||
ring = { version = "0.17.14", features = ["std"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
serde_json = "1.0.150"
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-appender = "0.2.3"
|
||||
@@ -38,7 +38,7 @@ reqwest = { version = "0.12.24", default-features = false, features = [
|
||||
"socks",
|
||||
] }
|
||||
tokio-socks = "0.5.2"
|
||||
http = "1.4.0"
|
||||
http = "1.4.1"
|
||||
regex = "1.12.3"
|
||||
email_address = "0.2.9"
|
||||
futures = "0.3.32"
|
||||
|
||||
102
README.md
102
README.md
@@ -106,6 +106,10 @@
|
||||
- **Admin Tooling**: Password reset for locked-out admins. Non-destructive v0.3.7 to v1.0 data migration.
|
||||
- **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.
|
||||
- **Remote Content Blocking**: External images and tracking pixels embedded in emails are blocked by default. Users can selectively allow remote content to load on a per-message basis from the WebUI.
|
||||
- **Async Index Deduplication**: Duplicate detection in the search index is performed asynchronously, reducing write latency during high-throughput ingestion.
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -445,7 +449,7 @@ Storage Layer │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
- **memdb**: Key-value metadata store. Houses accounts, users, roles, OAuth2 configs, proxy settings, and system configuration. All operations wrapped in `tokio::spawn_blocking`.
|
||||
- **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).
|
||||
|
||||
@@ -481,6 +485,79 @@ Tantivy Fjall memdb
|
||||
- Manual sync via `POST /api/v1/accounts/:id/start-download`; cancel with `cancel-download`
|
||||
- Busy-check prevents overlapping manual and automatic syncs on the same account
|
||||
|
||||
### Content Deduplication & Attachment Storage
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Raw EML bytes │
|
||||
└────────────────┬─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ BLAKE3 → email_content_hash │
|
||||
└────────────────┬─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ MIME parse → Message │
|
||||
└───────┬──────────────────┬──────────────┘
|
||||
│ │
|
||||
│ ┌────────────┘
|
||||
│ │ detach attachments
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────────────────┐
|
||||
│ EMAIL BODY │ │ EACH ATTACHMENT │
|
||||
│ │ │ │
|
||||
│ Replace raw │ │ BLAKE3(decoded content) │
|
||||
│ attachment │ │ → attachment_content_hash │
|
||||
│ bytes with │ │ │
|
||||
│ placeholder: │ │ Store raw undecoded bytes │
|
||||
│ │ │ in Fjall attachments_ks │
|
||||
│ <<BICHON_ │ │ (skip if hash exists) │
|
||||
│ DETACH_HASH: │ │ │
|
||||
│ xxx>> │ │ Extract text for indexing │
|
||||
│ │ │ (PDF, DOCX, etc.) │
|
||||
└───────┬─────────┘ └──────────────┬───────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────┐ │
|
||||
│ Stripped EML stored in │ │
|
||||
│ Fjall email_keyspace │ │
|
||||
│ keyed by email_content_hash │ │
|
||||
│ (skip if hash exists) │ │
|
||||
└──────────────┬───────────────┘ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Tantivy full-text index │
|
||||
│ envelope index · attachment index │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
Dedup layers
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Fjall (insert-time) │
|
||||
│ contains_key(hash)? → skip : store with LZ4 compression │
|
||||
│ │
|
||||
│ Tantivy (periodic, every 12 h) │
|
||||
│ Group by (account, mailbox, content_hash) │
|
||||
│ Keep latest ingest_at → soft-delete older copies │
|
||||
│ Cascade-delete orphaned attachment index entries │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Reconstruction
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Fetch stripped EML by content_hash from Fjall │
|
||||
│ Find <<BICHON_DETACH_HASH:xxx>> placeholders │
|
||||
│ Replace each with raw attachment blob from Fjall │
|
||||
│ 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.
|
||||
|
||||
## Storage & Backup
|
||||
|
||||
### Data Directory Layout
|
||||
@@ -528,21 +605,21 @@ 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.0)
|
||||
## Data Migration (v0.3.7 → v1.x)
|
||||
|
||||
Bichon v1.0 introduced a redesigned storage architecture:
|
||||
Bichon v1.x introduced a redesigned storage architecture:
|
||||
|
||||
| Layer | v0.3.7 (Legacy) | v1.0 |
|
||||
|-------|---------------|------|
|
||||
| **Index** | Tantivy (shared) | Tantivy (separate envelope + attachment indices) |
|
||||
| **Raw data** | Tantivy (inline) | Fjall (LZ4-compressed key-value store) |
|
||||
| **Metadata** | Tantivy (shared) | memdb (dedicated embedded DB) |
|
||||
| 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) |
|
||||
|
||||
If you ran Bichon prior to v1.0, migrate your data:
|
||||
If you ran Bichon prior to v1.x, migrate your data:
|
||||
|
||||
```bash
|
||||
./bichon-admin
|
||||
# Select "Migrate Legacy v0.3.7 Storage to v1.0"
|
||||
# Select "Migrate Legacy v0.3.7 Storage to v1.x"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
@@ -580,8 +657,8 @@ No. Bichon is an **archiver**, not an email client. The optional SMTP server **r
|
||||
|
||||
### What hardware does Bichon need?
|
||||
|
||||
- **Minimal:** 1 CPU core, 512 MB RAM
|
||||
- **Recommended (100+ accounts, 200+ GB):** 4+ cores, 2+ GB RAM
|
||||
- **Recommended:** 4+ CPU cores, 2+ GB RAM (sufficient for 10+ accounts and 200+ GB of archived data)
|
||||
- Filesystem: use a mainstream Linux filesystem such as **ext4** or **XFS**; avoid network / virtual filesystems (NFS, VirtIO-FS) for all data directories
|
||||
- Indices benefit from SSD storage; blob storage can use HDD
|
||||
|
||||
### How do I reset the admin password?
|
||||
@@ -652,7 +729,6 @@ Feel free to open an [Issue](https://github.com/rustmailer/bichon/issues) or joi
|
||||
| **Frontend** | React 18, TypeScript, Vite 6, ShadCN UI, TanStack Router/Query/Table |
|
||||
| **Charts** | Recharts |
|
||||
| **i18n** | i18next (18 languages) |
|
||||
| **Allocator** | mimalloc |
|
||||
| **Container** | Ubuntu 24.04, Docker |
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
base_url = "http://localhost:15630"
|
||||
api_token = "WuqNC0g8yNle7CVnxcvjUwjN"
|
||||
api_token = "eErI7WN3PtKeLwWAbIfSXCP6"
|
||||
|
||||
@@ -17,5 +17,4 @@ serde_json.workspace = true
|
||||
|
||||
itertools.workspace = true
|
||||
snafu.workspace = true
|
||||
memdb.workspace = true
|
||||
mimalloc = "0.1.50"
|
||||
memdb.workspace = true
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Select};
|
||||
use mimalloc::MiMalloc;
|
||||
|
||||
use crate::{migrate::handle_migration, reset::handle_reset_password};
|
||||
|
||||
@@ -26,8 +25,6 @@ pub mod meta;
|
||||
pub mod migrate;
|
||||
pub mod reset;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
fn main() {
|
||||
run_interactive();
|
||||
|
||||
@@ -246,7 +246,6 @@ impl From<AccountV3> for AccountModel {
|
||||
capabilities: value.capabilities,
|
||||
date_since: value.date_since,
|
||||
date_before: value.date_before,
|
||||
folder_limit: value.folder_limit,
|
||||
download_folders: value.sync_folders,
|
||||
account_type: value.account_type,
|
||||
download_interval_min: value.sync_interval_min,
|
||||
@@ -261,6 +260,7 @@ impl From<AccountV3> for AccountModel {
|
||||
imap_quota_window: None,
|
||||
imap_quota_bytes: None,
|
||||
auto_download_new_mailboxes: None,
|
||||
download_schedule: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -660,6 +660,7 @@ impl From<MailBox> for bichon_core::cache::imap::mailbox::MailBox {
|
||||
unseen: value.unseen,
|
||||
uid_next: value.uid_next,
|
||||
uid_validity: value.uid_validity,
|
||||
highest_uid: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,17 @@ use crate::BichonCliConfig;
|
||||
pub async fn search_messages(
|
||||
client: &Client,
|
||||
config: &BichonCliConfig,
|
||||
account_ids: Option<std::collections::HashSet<u64>>,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> Option<DataPage<Envelope>> {
|
||||
let url = format!("{}/api/v1/search-messages", config.base_url);
|
||||
|
||||
let payload = EmailSearchRequest {
|
||||
filter: EmailSearchFilter::default(),
|
||||
filter: EmailSearchFilter {
|
||||
account_ids,
|
||||
..Default::default()
|
||||
},
|
||||
page,
|
||||
page_size,
|
||||
sort_by: Some(SortBy::DATE),
|
||||
|
||||
@@ -146,20 +146,23 @@ pub async fn handle_account_export(
|
||||
let mut total_pages;
|
||||
|
||||
loop {
|
||||
if let Some(batch) = search_messages(&client, config, current_page, page_size).await {
|
||||
let account_ids = Some(std::collections::HashSet::from([account.id]));
|
||||
if let Some(batch) = search_messages(&client, config, account_ids, current_page, page_size).await {
|
||||
total_pages = batch.total_pages.unwrap();
|
||||
|
||||
pb.set_message(format!("Page {}/{}", current_page, total_pages));
|
||||
|
||||
for envelope in batch.items {
|
||||
let success =
|
||||
download_and_export_with_json_header(&client, config, envelope, &mut file)
|
||||
download_and_export_with_json_header(&client, config, envelope.clone(), &mut file)
|
||||
.await;
|
||||
|
||||
if !success {
|
||||
pb.finish_with_message("Failed");
|
||||
eprintln!(" ✘ Failed to export an email. Aborting process...");
|
||||
return;
|
||||
eprintln!(
|
||||
" ✘ Failed to export email {}, skipping...",
|
||||
envelope.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
pb.inc(1);
|
||||
}
|
||||
|
||||
@@ -69,5 +69,6 @@ tokio-util.workspace = true
|
||||
whichlang = "0.1.1"
|
||||
deunicode = "1.6.2"
|
||||
scopeguard = "1.2.0"
|
||||
cron = "0.15"
|
||||
quick-xml = { version = "0.40.0", features = ["serialize"] }
|
||||
hickory-resolver = "0.26.0-alpha.1"
|
||||
|
||||
@@ -80,7 +80,6 @@ pub struct Account {
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
pub date_since: Option<DateSince>,
|
||||
pub date_before: Option<RelativeDate>,
|
||||
pub folder_limit: Option<u32>,
|
||||
pub download_folders: Option<Vec<String>>,
|
||||
pub account_type: AccountType,
|
||||
pub download_interval_min: Option<i64>,
|
||||
@@ -95,6 +94,7 @@ pub struct Account {
|
||||
pub imap_quota_bytes: Option<u64>,
|
||||
pub imap_quota_window: Option<QuotaWindow>,
|
||||
pub auto_download_new_mailboxes: Option<bool>,
|
||||
pub download_schedule: Option<String>,
|
||||
}
|
||||
|
||||
impl MemDbModel for Account {
|
||||
@@ -124,7 +124,6 @@ impl Account {
|
||||
created_at: utc_now!(),
|
||||
updated_at: utc_now!(),
|
||||
use_proxy: request.use_proxy,
|
||||
folder_limit: request.folder_limit,
|
||||
use_dangerous: request.use_dangerous,
|
||||
pgp_key: request.pgp_key,
|
||||
created_by: user_id,
|
||||
@@ -133,6 +132,7 @@ impl Account {
|
||||
auto_download_new_mailboxes: request.auto_download_new_mailboxes,
|
||||
imap_quota_bytes: request.imap_quota_bytes,
|
||||
imap_quota_window: request.imap_quota_window,
|
||||
download_schedule: request.download_schedule,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -365,20 +365,10 @@ impl Account {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(folder_limit) = request.folder_limit {
|
||||
new.folder_limit = Some(folder_limit);
|
||||
}
|
||||
|
||||
if let Some(account_name) = request.account_name {
|
||||
new.account_name = Some(account_name);
|
||||
}
|
||||
|
||||
if let Some(clear_folder_limit) = request.clear_folder_limit {
|
||||
if clear_folder_limit {
|
||||
new.folder_limit = None;
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(old.account_type, AccountType::IMAP) {
|
||||
if let Some(imap) = &request.imap {
|
||||
if let Some(current_imap) = &mut new.imap {
|
||||
@@ -439,6 +429,12 @@ impl Account {
|
||||
if let Some(auto_download_new_mailboxes) = request.auto_download_new_mailboxes {
|
||||
new.auto_download_new_mailboxes = Some(auto_download_new_mailboxes);
|
||||
}
|
||||
if let Some(download_schedule) = request.download_schedule {
|
||||
new.download_schedule = Some(download_schedule);
|
||||
}
|
||||
if request.clear_download_schedule == Some(true) {
|
||||
new.download_schedule = None;
|
||||
}
|
||||
new.updated_at = utc_now!();
|
||||
Ok(new)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::account::entity::ImapConfig;
|
||||
use crate::account::migration::{AccountModel, AccountType, QuotaWindow};
|
||||
use crate::account::since::{DateSince, RelativeDate};
|
||||
@@ -39,8 +41,6 @@ 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 = "100"))))]
|
||||
pub folder_limit: Option<u32>,
|
||||
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "10"))))]
|
||||
pub download_interval_min: Option<i64>,
|
||||
#[cfg_attr(
|
||||
@@ -54,6 +54,7 @@ pub struct AccountCreateRequest {
|
||||
pub imap_quota_bytes: Option<u64>,
|
||||
pub imap_quota_window: Option<QuotaWindow>,
|
||||
pub auto_download_new_mailboxes: Option<bool>,
|
||||
pub download_schedule: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountCreateRequest {
|
||||
@@ -92,12 +93,15 @@ impl AccountCreateRequest {
|
||||
))
|
||||
}
|
||||
}
|
||||
if self.download_interval_min.is_none() {
|
||||
if self.download_interval_min.is_none() && self.download_schedule.is_none() {
|
||||
return Err(raise_error!(
|
||||
"`sync_interval_min` is required for IMAP account type".into(),
|
||||
"`sync_interval_min` or `download_schedule` is required for IMAP account type".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
if let Some(ref schedule) = self.download_schedule {
|
||||
validate_cron_expression(schedule)?;
|
||||
}
|
||||
}
|
||||
AccountType::NoSync => {}
|
||||
}
|
||||
@@ -139,12 +143,6 @@ pub struct AccountUpdateRequest {
|
||||
pub date_since: Option<DateSince>,
|
||||
pub date_before: Option<RelativeDate>,
|
||||
pub clear_date_range: Option<bool>,
|
||||
/// Max emails to sync for this folder.
|
||||
/// If not set, sync all emails.
|
||||
/// otherwise sync up to `n` most recent emails (min 10).
|
||||
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "100"))))]
|
||||
pub folder_limit: Option<u32>,
|
||||
pub clear_folder_limit: Option<bool>,
|
||||
/// Configuration for selective folder (mailbox/label) synchronization
|
||||
///
|
||||
/// - For IMAP/SMTP accounts:
|
||||
@@ -178,6 +176,8 @@ pub struct AccountUpdateRequest {
|
||||
pub imap_quota_bytes: Option<u64>,
|
||||
pub imap_quota_window: Option<QuotaWindow>,
|
||||
pub auto_download_new_mailboxes: Option<bool>,
|
||||
pub download_schedule: Option<String>,
|
||||
pub clear_download_schedule: Option<bool>,
|
||||
}
|
||||
|
||||
impl AccountUpdateRequest {
|
||||
@@ -197,13 +197,6 @@ impl AccountUpdateRequest {
|
||||
));
|
||||
}
|
||||
|
||||
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
|
||||
return Err(raise_error!(
|
||||
"clear_folder_limit cannot be combined with folder_limit".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if self.clear_date_range == Some(true)
|
||||
&& (self.date_since.is_some() || self.date_before.is_some())
|
||||
{
|
||||
@@ -230,11 +223,36 @@ impl AccountUpdateRequest {
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.clear_download_schedule == Some(true) && self.download_schedule.is_some() {
|
||||
return Err(raise_error!(
|
||||
"clear_download_schedule cannot be combined with download_schedule".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
if let Some(ref schedule) = self.download_schedule {
|
||||
validate_cron_expression(schedule)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_cron_expression(expr: &str) -> BichonResult<()> {
|
||||
if expr.trim().is_empty() {
|
||||
return Err(raise_error!(
|
||||
"download_schedule must not be empty".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
cron::Schedule::from_str(expr).map_err(|e| {
|
||||
raise_error!(
|
||||
format!("Invalid cron expression '{}': {}", expr, e),
|
||||
ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||
|
||||
@@ -253,3 +271,34 @@ pub fn filter_accessible_accounts<'a>(
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::validate_cron_expression;
|
||||
|
||||
#[test]
|
||||
fn valid_cron_expressions() {
|
||||
assert!(validate_cron_expression("0 0 0 * * *").is_ok()); // daily at midnight
|
||||
assert!(validate_cron_expression("0 */5 * * * *").is_ok()); // every 5 minutes
|
||||
assert!(validate_cron_expression("0 0 12 * * 1-5").is_ok()); // weekdays at noon
|
||||
assert!(validate_cron_expression("0 30 4 1 * *").is_ok()); // 1st of month at 04:30
|
||||
assert!(validate_cron_expression("0 0 * * * *").is_ok()); // every hour
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cron_expression_too_few_fields() {
|
||||
assert!(validate_cron_expression("0 0 * *").is_err());
|
||||
assert!(validate_cron_expression("* * * * *").is_err()); // 5 fields, needs seconds
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cron_expression_empty() {
|
||||
assert!(validate_cron_expression("").is_err());
|
||||
assert!(validate_cron_expression(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cron_expression_garbage() {
|
||||
assert!(validate_cron_expression("not a cron").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,4 +364,43 @@ mod test {
|
||||
};
|
||||
assert!(e.validate().is_err());
|
||||
}
|
||||
|
||||
// ── Sliding window tests ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn relative_date_calculate_returns_valid_format() {
|
||||
let r = RelativeDate {
|
||||
unit: Unit::Years,
|
||||
value: 1,
|
||||
};
|
||||
let date_str = r.calculate_date().unwrap();
|
||||
// Expect format like "26-May-2025"
|
||||
assert!(date_str.len() > 5);
|
||||
assert!(date_str.contains('-'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_date_one_year_ago_is_before_now() {
|
||||
let r = RelativeDate {
|
||||
unit: Unit::Years,
|
||||
value: 1,
|
||||
};
|
||||
let date_str = r.calculate_date().unwrap();
|
||||
let parsed = chrono::NaiveDate::parse_from_str(&date_str, "%d-%b-%Y").unwrap();
|
||||
let today = chrono::Local::now().date_naive();
|
||||
assert!(parsed < today, "1 year ago ({parsed}) should be before today ({today})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_date_one_day_ago_is_yesterday() {
|
||||
let r = RelativeDate {
|
||||
unit: Unit::Days,
|
||||
value: 1,
|
||||
};
|
||||
let date_str = r.calculate_date().unwrap();
|
||||
let parsed = chrono::NaiveDate::parse_from_str(&date_str, "%d-%b-%Y").unwrap();
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let yesterday = today - chrono::Duration::days(1);
|
||||
assert_eq!(parsed, yesterday, "1 day ago should be yesterday");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ pub struct AccountResp {
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
pub date_since: Option<DateSince>,
|
||||
pub date_before: Option<RelativeDate>,
|
||||
pub folder_limit: Option<u32>,
|
||||
pub download_folders: Option<Vec<String>>,
|
||||
pub account_type: AccountType,
|
||||
pub download_interval_min: Option<i64>,
|
||||
@@ -57,6 +56,7 @@ pub struct AccountResp {
|
||||
pub imap_quota_bytes: Option<u64>,
|
||||
pub imap_quota_window: Option<QuotaWindow>,
|
||||
pub auto_download_new_mailboxes: Option<bool>,
|
||||
pub download_schedule: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountResp {
|
||||
@@ -72,7 +72,6 @@ impl AccountResp {
|
||||
capabilities: account.capabilities,
|
||||
date_since: account.date_since,
|
||||
date_before: account.date_before,
|
||||
folder_limit: account.folder_limit,
|
||||
download_folders: account.download_folders,
|
||||
account_type: account.account_type,
|
||||
download_interval_min: account.download_interval_min,
|
||||
@@ -93,6 +92,7 @@ impl AccountResp {
|
||||
imap_quota_bytes: account.imap_quota_bytes,
|
||||
imap_quota_window: account.imap_quota_window,
|
||||
auto_download_new_mailboxes: account.auto_download_new_mailboxes,
|
||||
download_schedule: account.download_schedule,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ pub struct IncomingServer {
|
||||
#[serde(rename = "socketType")]
|
||||
pub socket_type: String,
|
||||
pub username: String,
|
||||
/// Authentication method from the XML, e.g. "OAuth2", "password-cleartext",
|
||||
/// "password-encrypted", "GSSAPI", "NTLM". Absent in DNS SRV fallback.
|
||||
#[serde(default)]
|
||||
pub authentication: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
@@ -137,6 +141,7 @@ async fn lookup_srv(domain: &str) -> Option<MailConfig> {
|
||||
port: imap_port,
|
||||
socket_type: "SSL".to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: String::new(),
|
||||
}],
|
||||
outgoing: vec![OutgoingServer {
|
||||
protocol: "smtp".to_string(),
|
||||
@@ -153,30 +158,37 @@ async fn lookup_srv(domain: &str) -> Option<MailConfig> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Discover mail server configuration for a domain using the Thunderbird
|
||||
/// autoconfig protocol (ISPDB) and DNS SRV fallback.
|
||||
/// autoconfig protocol (ISPDB), DNS SRV, MX fallback, and finally guessing.
|
||||
///
|
||||
/// Probe order:
|
||||
/// 1. `https://autoconfig.{domain}/mail/config-v1.1.xml`
|
||||
/// 2. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
|
||||
/// 3. DNS SRV records (`_imaps._tcp` / `_submission._tcp`)
|
||||
/// 4. Thunderbird central ISPDB (`https://autoconfig.thunderbird.net/v1.1/{domain}`)
|
||||
/// 2. `http://autoconfig.{domain}/mail/config-v1.1.xml`
|
||||
/// 3. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
|
||||
/// 4. `http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
|
||||
/// 5. DNS SRV records (`_imaps._tcp` / `_submission._tcp`)
|
||||
/// 6. Thunderbird central ISPDB (`https://autoconfig.thunderbird.net/v1.1/{domain}`)
|
||||
/// 7. MX lookup → ISPDB for MX domain
|
||||
/// 8. MX lookup → ISP autoconfig for MX domain
|
||||
/// 9. GuessConfig — probe common hostnames + ports
|
||||
pub async fn fetch(domain: &str) -> BichonResult<MailConfig> {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
// 1. Try autoconfig subdomain
|
||||
if let Some(config) = fetch_xml(
|
||||
&client,
|
||||
&format!("https://autoconfig.{domain}/mail/config-v1.1.xml"),
|
||||
)
|
||||
.await
|
||||
// ── ISP autoconfig (HTTPS, then HTTP) ──────────────────────────
|
||||
if let Some(config) =
|
||||
fetch_xml(&client, &format!("https://autoconfig.{domain}/mail/config-v1.1.xml")).await
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
if let Some(config) =
|
||||
fetch_xml(&client, &format!("http://autoconfig.{domain}/mail/config-v1.1.xml")).await
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// 2. Try well-known path
|
||||
// ── Well-known path (HTTPS, then HTTP) ─────────────────────────
|
||||
if let Some(config) = fetch_xml(
|
||||
&client,
|
||||
&format!("https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
|
||||
@@ -185,28 +197,100 @@ pub async fn fetch(domain: &str) -> BichonResult<MailConfig> {
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
if let Some(config) = fetch_xml(
|
||||
&client,
|
||||
&format!("http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// 3. Try DNS SRV records
|
||||
// ── DNS SRV records ────────────────────────────────────────────
|
||||
if let Some(config) = lookup_srv(domain).await {
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// 4. Fall back to Thunderbird central database
|
||||
if let Some(config) = fetch_xml(
|
||||
&client,
|
||||
&format!("https://autoconfig.thunderbird.net/v1.1/{domain}"),
|
||||
)
|
||||
.await
|
||||
// ── Thunderbird central ISPDB ──────────────────────────────────
|
||||
if let Some(config) =
|
||||
fetch_xml(&client, &format!("https://autoconfig.thunderbird.net/v1.1/{domain}")).await
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// ── MX fallback ────────────────────────────────────────────────
|
||||
if let Some(config) = fetch_for_mx(&client, domain).await {
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// ── GuessConfig ────────────────────────────────────────────────
|
||||
if let Some(config) = crate::autoconfig::guess::guess_config(domain).await {
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
Err(raise_error!(
|
||||
format!("No autoconfig found for domain: {domain}"),
|
||||
ErrorCode::InternalError
|
||||
))
|
||||
}
|
||||
|
||||
/// DNS MX lookup → retry ISPDB and ISP autoconfig for the MX domain.
|
||||
///
|
||||
/// Many self-hosted domains have their MX pointed at Google, Microsoft, etc.
|
||||
/// The MX domain's ISPDB entry covers the original domain.
|
||||
async fn fetch_for_mx(client: &Client, domain: &str) -> Option<MailConfig> {
|
||||
let mx_domain = lookup_mx_domain(domain).await?;
|
||||
if mx_domain == domain.to_ascii_lowercase() {
|
||||
return None; // same domain, already tried above
|
||||
}
|
||||
|
||||
// Try ISPDB for the MX domain
|
||||
if let Some(config) =
|
||||
fetch_xml(client, &format!("https://autoconfig.thunderbird.net/v1.1/{mx_domain}")).await
|
||||
{
|
||||
return Some(config);
|
||||
}
|
||||
|
||||
// Try ISP autoconfig for the MX domain (HTTPS then HTTP)
|
||||
if let Some(config) =
|
||||
fetch_xml(client, &format!("https://autoconfig.{mx_domain}/mail/config-v1.1.xml")).await
|
||||
{
|
||||
return Some(config);
|
||||
}
|
||||
if let Some(config) =
|
||||
fetch_xml(client, &format!("http://autoconfig.{mx_domain}/mail/config-v1.1.xml")).await
|
||||
{
|
||||
return Some(config);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// DNS MX lookup → extract the second-level domain of the first MX hostname.
|
||||
async fn lookup_mx_domain(domain: &str) -> Option<String> {
|
||||
let resolver = TokioResolver::builder(TokioConnectionProvider::default())
|
||||
.ok()?
|
||||
.build();
|
||||
let lookup = resolver.mx_lookup(domain).await.ok()?;
|
||||
let record = lookup.iter().next()?;
|
||||
let mx_host = record.to_string().trim_end_matches('.').to_string();
|
||||
|
||||
// Extract a reasonable base domain from the MX hostname.
|
||||
// E.g., "aspmx.l.google.com" → "google.com"
|
||||
// "company.mail.protection.outlook.com" → "outlook.com"
|
||||
extract_base_domain(&mx_host)
|
||||
}
|
||||
|
||||
/// Extract the top two labels from a hostname as a rough base domain.
|
||||
fn extract_base_domain(host: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = host.split('.').collect();
|
||||
if parts.len() >= 2 {
|
||||
Some(parts[parts.len() - 2..].join("."))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
105
crates/core/src/autoconfig/guess.rs
Normal file
105
crates/core/src/autoconfig/guess.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 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::account::entity::Encryption;
|
||||
use crate::autoconfig::client::{IncomingServer, MailConfig};
|
||||
use crate::imap::client::Client;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// A single host:port:encryption combination to probe.
|
||||
struct Guess {
|
||||
hostname: String,
|
||||
port: u16,
|
||||
encryption: Encryption,
|
||||
socket_type: &'static str,
|
||||
}
|
||||
|
||||
/// Generate candidates in the same order Thunderbird uses:
|
||||
/// 1. imap.{domain} — most common
|
||||
/// 2. mail.{domain} — fallback
|
||||
/// 3. {domain} — bare domain (rare)
|
||||
fn make_guesses(domain: &str) -> Vec<Guess> {
|
||||
let hosts = [
|
||||
format!("imap.{domain}"),
|
||||
format!("mail.{domain}"),
|
||||
domain.to_string(),
|
||||
];
|
||||
|
||||
let mut guesses = Vec::with_capacity(hosts.len() * 2);
|
||||
for host in &hosts {
|
||||
guesses.push(Guess {
|
||||
hostname: host.clone(),
|
||||
port: 993,
|
||||
encryption: Encryption::Ssl,
|
||||
socket_type: "SSL",
|
||||
});
|
||||
guesses.push(Guess {
|
||||
hostname: host.clone(),
|
||||
port: 143,
|
||||
encryption: Encryption::StartTls,
|
||||
socket_type: "STARTTLS",
|
||||
});
|
||||
}
|
||||
guesses
|
||||
}
|
||||
|
||||
/// Try to open a connection, read the IMAP banner, and close.
|
||||
/// Returns `true` if the server responds with an IMAP greeting.
|
||||
async fn probe(hostname: &str, port: u16, encryption: &Encryption) -> bool {
|
||||
match Client::connection(hostname, encryption, port, None, true).await {
|
||||
Ok(_) => {
|
||||
debug!("GuessConfig probe succeeded: {hostname}:{port} ({encryption:?})");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("GuessConfig probe failed for {hostname}:{port}: {e:?}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thunderbird-style guessing: try common hostnames and ports, probing
|
||||
/// each with a real TCP connection.
|
||||
///
|
||||
/// Returns the first working `MailConfig`, or `None` if nothing works.
|
||||
pub async fn guess_config(domain: &str) -> Option<MailConfig> {
|
||||
let guesses = make_guesses(domain);
|
||||
info!("GuessConfig: trying {} candidates for {domain}", guesses.len());
|
||||
|
||||
for g in &guesses {
|
||||
if probe(&g.hostname, g.port, &g.encryption).await {
|
||||
info!(
|
||||
"GuessConfig: found working IMAP at {}:{} ({})",
|
||||
g.hostname, g.port, g.socket_type
|
||||
);
|
||||
return Some(MailConfig {
|
||||
incoming: vec![IncomingServer {
|
||||
protocol: "imap".to_string(),
|
||||
hostname: g.hostname.clone(),
|
||||
port: g.port,
|
||||
socket_type: g.socket_type.to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: String::new(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
use crate::account::entity::Encryption;
|
||||
use crate::autoconfig::client::{self, MailConfig};
|
||||
use crate::autoconfig::entity::{MailServerConfig, ServerConfig};
|
||||
use crate::autoconfig::oauth2_providers::lookup_oauth2;
|
||||
use crate::autoconfig::CachedMailSettings;
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::error::BichonResult;
|
||||
@@ -54,9 +55,17 @@ pub(crate) fn mail_config_to_server_config(config: &MailConfig) -> Option<MailSe
|
||||
}
|
||||
};
|
||||
|
||||
// Detect OAuth2 support: the XML <authentication> field and a known
|
||||
// hostname → issuer mapping determine whether the provider supports OAuth2.
|
||||
let oauth2 = if imap.authentication.eq_ignore_ascii_case("OAuth2") {
|
||||
lookup_oauth2(&imap.hostname)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(MailServerConfig {
|
||||
imap: ServerConfig::new(imap.hostname.clone(), port, encryption),
|
||||
oauth2: None,
|
||||
oauth2,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod client;
|
||||
pub mod entity;
|
||||
pub mod guess;
|
||||
pub mod load;
|
||||
mod oauth2_providers;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
||||
151
crates/core/src/autoconfig/oauth2_providers.rs
Normal file
151
crates/core/src/autoconfig/oauth2_providers.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 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::autoconfig::entity::OAuth2Config;
|
||||
|
||||
/// Per-provider OAuth2 metadata, mirroring Thunderbird's `OAuth2Providers.sys.mjs`.
|
||||
///
|
||||
/// Each entry maps one or more IMAP hostname suffixes to a well-known OIDC issuer
|
||||
/// and the IMAP-specific OAuth2 scopes.
|
||||
struct Provider {
|
||||
/// Suffixes matched case-insensitively against the end of the IMAP hostname.
|
||||
host_suffixes: &'static [&'static str],
|
||||
/// The OIDC issuer URL used by the provider.
|
||||
issuer: &'static str,
|
||||
/// OAuth2 scope(s) required for IMAP access.
|
||||
scopes: &'static [&'static str],
|
||||
}
|
||||
|
||||
const PROVIDERS: &[Provider] = &[
|
||||
// Google
|
||||
Provider {
|
||||
host_suffixes: &["imap.gmail.com", ".gmail.com", ".googlemail.com"],
|
||||
issuer: "https://accounts.google.com",
|
||||
scopes: &["https://mail.google.com/"],
|
||||
},
|
||||
// Microsoft (Outlook / Office 365 / Hotmail / Live)
|
||||
Provider {
|
||||
host_suffixes: &[
|
||||
"outlook.office365.com",
|
||||
".outlook.com",
|
||||
".hotmail.com",
|
||||
".live.com",
|
||||
".office365.com",
|
||||
],
|
||||
issuer: "https://login.microsoftonline.com/common/v2.0",
|
||||
scopes: &[
|
||||
"https://outlook.office365.com/IMAP.AccessAsUser.All",
|
||||
"offline_access",
|
||||
],
|
||||
},
|
||||
// Yahoo / AOL / ATT / Verizon
|
||||
Provider {
|
||||
host_suffixes: &[
|
||||
"imap.mail.yahoo.com",
|
||||
".yahoo.com",
|
||||
".yahoodns.net",
|
||||
".aol.com",
|
||||
"imap.aol.com",
|
||||
],
|
||||
issuer: "https://login.yahoo.com",
|
||||
scopes: &["mail-w"],
|
||||
},
|
||||
// Yandex
|
||||
Provider {
|
||||
host_suffixes: &["imap.yandex.ru", "imap.yandex.com", ".yandex.ru"],
|
||||
issuer: "https://oauth.yandex.com",
|
||||
scopes: &["imap:all"],
|
||||
},
|
||||
// Mail.ru
|
||||
Provider {
|
||||
host_suffixes: &["imap.mail.ru", ".mail.ru", ".bk.ru", ".list.ru", ".inbox.ru"],
|
||||
issuer: "https://o2.mail.ru",
|
||||
scopes: &["imap"],
|
||||
},
|
||||
// Fastmail
|
||||
Provider {
|
||||
host_suffixes: &["imap.fastmail.com", ".fastmail.com"],
|
||||
issuer: "https://www.fastmail.com",
|
||||
scopes: &[
|
||||
"https://www.fastmail.com/dev/imap",
|
||||
"offline_access",
|
||||
],
|
||||
},
|
||||
// Comcast
|
||||
Provider {
|
||||
host_suffixes: &["imap.comcast.net", ".comcast.net"],
|
||||
issuer: "https://oauth.xfinity.com",
|
||||
scopes: &["https://email.comcast.net/"],
|
||||
},
|
||||
];
|
||||
|
||||
/// Try to find an OAuth2 provider that matches the given IMAP hostname.
|
||||
///
|
||||
/// Matching is case-insensitive and done by suffix: a hostname "imap.gmail.com"
|
||||
/// matches the suffix ".gmail.com".
|
||||
pub fn lookup_oauth2(hostname: &str) -> Option<OAuth2Config> {
|
||||
let host = hostname.to_ascii_lowercase();
|
||||
for provider in PROVIDERS {
|
||||
if provider
|
||||
.host_suffixes
|
||||
.iter()
|
||||
.any(|suffix| host.ends_with(&suffix.to_ascii_lowercase()))
|
||||
{
|
||||
return Some(OAuth2Config {
|
||||
issuer: provider.issuer.to_string(),
|
||||
scope: provider.scopes.iter().map(|s| s.to_string()).collect(),
|
||||
auth_url: String::new(),
|
||||
token_url: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_known_providers() {
|
||||
let cases = [
|
||||
("imap.gmail.com", Some("https://accounts.google.com")),
|
||||
("imap.gmail.com", Some("https://accounts.google.com")),
|
||||
("outlook.office365.com", Some("https://login.microsoftonline.com/common/v2.0")),
|
||||
("imap.mail.yahoo.com", Some("https://login.yahoo.com")),
|
||||
("imap.aol.com", Some("https://login.yahoo.com")),
|
||||
("imap.yandex.ru", Some("https://oauth.yandex.com")),
|
||||
("imap.mail.ru", Some("https://o2.mail.ru")),
|
||||
("imap.fastmail.com", Some("https://www.fastmail.com")),
|
||||
("imap.comcast.net", Some("https://oauth.xfinity.com")),
|
||||
];
|
||||
for (hostname, expected_issuer) in &cases {
|
||||
let result = lookup_oauth2(hostname);
|
||||
assert_eq!(
|
||||
result.map(|c| c.issuer),
|
||||
expected_issuer.map(|s| s.to_string()),
|
||||
"failed for hostname: {hostname}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_provider() {
|
||||
assert!(lookup_oauth2("mail.my-company.example").is_none());
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,7 @@ fn make_imap_server(host: &str, port: u16, socket_type: &str) -> IncomingServer
|
||||
port,
|
||||
socket_type: socket_type.to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +242,7 @@ fn convert_no_imap_only_pop3() {
|
||||
port: 995,
|
||||
socket_type: "SSL".to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: String::new(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
};
|
||||
@@ -266,6 +268,7 @@ fn convert_picks_imap_over_pop3() {
|
||||
port: 995,
|
||||
socket_type: "SSL".to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: String::new(),
|
||||
},
|
||||
make_imap_server("imap.example.com", 993, "SSL"),
|
||||
],
|
||||
@@ -284,6 +287,7 @@ fn convert_imaps_protocol_variant() {
|
||||
port: 993,
|
||||
socket_type: "SSL".to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: String::new(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
};
|
||||
@@ -300,9 +304,65 @@ fn convert_case_insensitive_protocol() {
|
||||
port: 143,
|
||||
socket_type: "STARTTLS".to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: String::new(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
};
|
||||
let result = mail_config_to_server_config(&config).expect("should recognize 'IMAP'");
|
||||
assert_eq!(result.imap.host, "imap.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_gmail_oauth2() {
|
||||
let config = MailConfig {
|
||||
incoming: vec![IncomingServer {
|
||||
protocol: "imap".to_string(),
|
||||
hostname: "imap.gmail.com".to_string(),
|
||||
port: 993,
|
||||
socket_type: "SSL".to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: "OAuth2".to_string(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
};
|
||||
let result = mail_config_to_server_config(&config).expect("should convert");
|
||||
let oauth2 = result.oauth2.expect("Gmail should have OAuth2");
|
||||
assert_eq!(oauth2.issuer, "https://accounts.google.com");
|
||||
assert!(oauth2.scope.contains(&"https://mail.google.com/".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_outlook_oauth2() {
|
||||
let config = MailConfig {
|
||||
incoming: vec![IncomingServer {
|
||||
protocol: "imap".to_string(),
|
||||
hostname: "outlook.office365.com".to_string(),
|
||||
port: 993,
|
||||
socket_type: "SSL".to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: "OAuth2".to_string(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
};
|
||||
let result = mail_config_to_server_config(&config).expect("should convert");
|
||||
let oauth2 = result.oauth2.expect("Outlook should have OAuth2");
|
||||
assert!(oauth2.issuer.contains("microsoftonline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_unknown_host_no_oauth2() {
|
||||
// OAuth2 auth flag on an unknown hostname → no OAuth2 returned
|
||||
let config = MailConfig {
|
||||
incoming: vec![IncomingServer {
|
||||
protocol: "imap".to_string(),
|
||||
hostname: "mail.random-isp.example".to_string(),
|
||||
port: 993,
|
||||
socket_type: "SSL".to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: "OAuth2".to_string(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
};
|
||||
let result = mail_config_to_server_config(&config).expect("should convert");
|
||||
assert!(result.oauth2.is_none(), "unknown hostname → no OAuth2 mapping");
|
||||
}
|
||||
|
||||
105
crates/core/src/cache/imap/download/download_type.rs
vendored
105
crates/core/src/cache/imap/download/download_type.rs
vendored
@@ -16,6 +16,11 @@
|
||||
// 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::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Local, TimeZone, Utc};
|
||||
use cron::Schedule;
|
||||
|
||||
use crate::{
|
||||
utc_now,
|
||||
{
|
||||
@@ -48,11 +53,20 @@ pub async fn decide_next_download_task(
|
||||
|
||||
let should_start = match trigger_type {
|
||||
TriggerType::Manual => true,
|
||||
TriggerType::Scheduled => should_trigger_next_download(
|
||||
state.last_trigger_at,
|
||||
state.last_finished_at.unwrap_or(0),
|
||||
account.download_interval_min.unwrap_or(60),
|
||||
),
|
||||
TriggerType::Scheduled => {
|
||||
let now = utc_now!();
|
||||
let cooldown_ok = now - state.last_finished_at.unwrap_or(0) > 60 * 1000;
|
||||
if !cooldown_ok {
|
||||
false
|
||||
} else if let Some(ref schedule) = account.download_schedule {
|
||||
should_trigger_scheduled(schedule, state.last_trigger_at)
|
||||
} else {
|
||||
should_trigger_next_download(
|
||||
state.last_trigger_at,
|
||||
account.download_interval_min.unwrap_or(60),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if should_start {
|
||||
@@ -63,11 +77,80 @@ pub async fn decide_next_download_task(
|
||||
}
|
||||
}
|
||||
|
||||
fn should_trigger_next_download(
|
||||
last_trigger_at: i64,
|
||||
last_finished_at: i64,
|
||||
sync_interval_min: i64,
|
||||
) -> bool {
|
||||
fn should_trigger_next_download(last_trigger_at: i64, sync_interval_min: i64) -> bool {
|
||||
let now = utc_now!();
|
||||
now - last_trigger_at > (sync_interval_min * 60 * 1000) && now - last_finished_at > 60 * 1000
|
||||
now - last_trigger_at > (sync_interval_min * 60 * 1000)
|
||||
}
|
||||
|
||||
fn should_trigger_scheduled(schedule_str: &str, last_trigger_at: i64) -> bool {
|
||||
let schedule = match Schedule::from_str(schedule_str) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Invalid cron expression '{}', falling back to no trigger: {}",
|
||||
schedule_str,
|
||||
e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// last_trigger_at is a UTC millis timestamp; convert to server local time
|
||||
let last_utc = match Utc.timestamp_millis_opt(last_trigger_at) {
|
||||
chrono::LocalResult::Single(dt) => dt,
|
||||
_ => {
|
||||
tracing::warn!("Invalid last_trigger_at timestamp: {}", last_trigger_at);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let last_dt: DateTime<Local> = last_utc.with_timezone(&Local);
|
||||
let now = Local::now();
|
||||
schedule
|
||||
.after(&last_dt)
|
||||
.next()
|
||||
.map_or(false, |next| next <= now)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cron_every_minute_triggers_after_60s() {
|
||||
// "0 * * * * *" = every minute at second 0. last_trigger 90s ago → should trigger
|
||||
let now = Local::now();
|
||||
let last_trigger = now.timestamp_millis() - 90_000;
|
||||
assert!(should_trigger_scheduled("0 * * * * *", last_trigger));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_daily_midnight_triggers_when_missed() {
|
||||
// "0 0 0 * * *" = daily at midnight
|
||||
// last_trigger was 25 hours ago → should trigger (we missed midnight)
|
||||
let now = Local::now();
|
||||
let last_trigger = now.timestamp_millis() - 25 * 60 * 60 * 1000;
|
||||
assert!(should_trigger_scheduled("0 0 0 * * *", last_trigger));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_daily_midnight_no_trigger_if_already_fired() {
|
||||
// "0 0 0 * * *" = daily at midnight
|
||||
// last_trigger was 1 minute ago → should NOT trigger
|
||||
let now = Local::now();
|
||||
let last_trigger = now.timestamp_millis() - 60_000;
|
||||
assert!(!should_trigger_scheduled("0 0 0 * * *", last_trigger));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cron_returns_false() {
|
||||
assert!(!should_trigger_scheduled("invalid cron expression", 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_every_hour_triggers() {
|
||||
// "0 0 * * * *" = every hour at minute 0, second 0
|
||||
// last_trigger was 61 minutes ago → should trigger
|
||||
let now = Local::now();
|
||||
let last_trigger = now.timestamp_millis() - 61 * 60 * 1000;
|
||||
assert!(should_trigger_scheduled("0 0 * * * *", last_trigger));
|
||||
}
|
||||
}
|
||||
|
||||
271
crates/core/src/cache/imap/download/flow.rs
vendored
271
crates/core/src/cache/imap/download/flow.rs
vendored
@@ -32,7 +32,9 @@ use crate::{
|
||||
SEMAPHORE,
|
||||
},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
imap::executor::ImapExecutor,
|
||||
imap::executor::{
|
||||
generate_uid_sequence_hashset, ImapExecutor, DEFAULT_BATCH_SIZE,
|
||||
},
|
||||
store::tantivy::envelope::ENVELOPE_MANAGER,
|
||||
},
|
||||
};
|
||||
@@ -40,7 +42,6 @@ use std::time::Instant;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
pub const DEFAULT_BATCH_SIZE: u32 = 30;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum FetchDirection {
|
||||
@@ -54,7 +55,7 @@ pub async fn fetch_and_save_by_date(
|
||||
mailbox: &MailBox,
|
||||
direction: FetchDirection,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<()> {
|
||||
) -> BichonResult<Option<u32>> {
|
||||
let account_id = account.id;
|
||||
let mut session = match ImapExecutor::create_connection(account_id).await {
|
||||
Ok(session) => session,
|
||||
@@ -108,32 +109,18 @@ pub async fn fetch_and_save_by_date(
|
||||
FolderStatus::Success,
|
||||
None,
|
||||
)?;
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let folder_limit = account.folder_limit;
|
||||
// sort small -> bigger
|
||||
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
|
||||
uid_vec.sort();
|
||||
|
||||
if let Some(limit) = folder_limit {
|
||||
let limit = limit.max(100) as usize;
|
||||
if len > limit {
|
||||
uid_vec = match direction {
|
||||
FetchDirection::Since => uid_vec.split_off(len - limit),
|
||||
FetchDirection::Before => {
|
||||
uid_vec.truncate(limit);
|
||||
uid_vec
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let max_uid = uid_vec.last().copied();
|
||||
let planned = uid_vec.len() as u64;
|
||||
let uid_batches = generate_uid_sequence_hashset(
|
||||
uid_vec,
|
||||
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
|
||||
false,
|
||||
);
|
||||
DownloadState::update_folder_progress(
|
||||
account_id,
|
||||
@@ -212,14 +199,16 @@ pub async fn fetch_and_save_by_date(
|
||||
)?;
|
||||
}
|
||||
session.logout().await.ok();
|
||||
Ok(())
|
||||
Ok(max_uid)
|
||||
}
|
||||
|
||||
/// Fetches all messages from a mailbox.
|
||||
/// Returns `Ok(Some(max_uid))` with the highest UID stored, or `Ok(None)` if empty.
|
||||
pub async fn fetch_and_save_full_mailbox(
|
||||
account: &AccountModel,
|
||||
mailbox: &MailBox,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<()> {
|
||||
) -> BichonResult<Option<u32>> {
|
||||
let mailbox_id = mailbox.id;
|
||||
let account_id = account.id;
|
||||
|
||||
@@ -262,33 +251,17 @@ pub async fn fetch_and_save_full_mailbox(
|
||||
}
|
||||
};
|
||||
|
||||
let folder_limit = account.folder_limit;
|
||||
let total_to_fetch = match folder_limit {
|
||||
Some(limit) if (limit as u64) < total => {
|
||||
let limit64 = limit as u64;
|
||||
total.min(limit64.max(100))
|
||||
}
|
||||
_ => total,
|
||||
};
|
||||
|
||||
let page_size = if let Some(limit) = folder_limit {
|
||||
limit
|
||||
.max(100)
|
||||
.min(account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE))
|
||||
} else {
|
||||
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE)
|
||||
};
|
||||
|
||||
let total_batches = total_to_fetch.div_ceil(page_size as u64);
|
||||
let desc = folder_limit.is_some();
|
||||
let page_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
|
||||
let total_batches = total.div_ceil(page_size as u64);
|
||||
|
||||
info!(
|
||||
"Starting full mailbox download for '{}', total={}, limit={:?}, batches={}, desc={}",
|
||||
mailbox.name, total, folder_limit, total_batches, desc
|
||||
"Starting full mailbox download for '{}', total={}, batches={}",
|
||||
mailbox.name, total, total_batches
|
||||
);
|
||||
|
||||
let mut current_processed = 0u64;
|
||||
let mut has_error_or_cancel = false;
|
||||
let mut max_uid: Option<u32> = None;
|
||||
|
||||
for page in 1..=total_batches {
|
||||
if token.is_cancelled() {
|
||||
@@ -300,7 +273,7 @@ pub async fn fetch_and_save_full_mailbox(
|
||||
DownloadState::update_folder_progress(
|
||||
account_id,
|
||||
mailbox.name.clone(),
|
||||
total_to_fetch,
|
||||
total,
|
||||
current_processed,
|
||||
FolderStatus::Cancelled,
|
||||
None,
|
||||
@@ -313,12 +286,12 @@ pub async fn fetch_and_save_full_mailbox(
|
||||
&mut session,
|
||||
account_id,
|
||||
mailbox_id,
|
||||
total_to_fetch,
|
||||
total,
|
||||
page as u64,
|
||||
page_size as u64,
|
||||
&mailbox.encoded_name(),
|
||||
desc,
|
||||
token.clone(),
|
||||
&mut max_uid,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -327,7 +300,7 @@ pub async fn fetch_and_save_full_mailbox(
|
||||
DownloadState::update_folder_progress(
|
||||
account_id,
|
||||
mailbox.name.clone(),
|
||||
total_to_fetch,
|
||||
total,
|
||||
current_processed,
|
||||
FolderStatus::Downloading,
|
||||
None,
|
||||
@@ -339,7 +312,7 @@ pub async fn fetch_and_save_full_mailbox(
|
||||
DownloadState::update_folder_progress(
|
||||
account_id,
|
||||
mailbox.name.clone(),
|
||||
total_to_fetch,
|
||||
total,
|
||||
current_processed,
|
||||
FolderStatus::Failed,
|
||||
Some(err_msg),
|
||||
@@ -354,71 +327,14 @@ pub async fn fetch_and_save_full_mailbox(
|
||||
DownloadState::update_folder_progress(
|
||||
account_id,
|
||||
mailbox.name.clone(),
|
||||
total_to_fetch,
|
||||
total,
|
||||
current_processed,
|
||||
FolderStatus::Success,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
session.logout().await.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate_uid_sequence_hashset(
|
||||
unique_nums: Vec<u32>,
|
||||
chunk_size: usize,
|
||||
desc: bool,
|
||||
) -> Vec<(String, u64)> {
|
||||
assert!(!unique_nums.is_empty());
|
||||
let mut nums = unique_nums;
|
||||
if desc {
|
||||
nums.reverse();
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for chunk in nums.chunks(chunk_size) {
|
||||
let size = chunk.len() as u64;
|
||||
let compressed = compress_uid_list(chunk.to_vec());
|
||||
result.push((compressed, size));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn compress_uid_list(nums: Vec<u32>) -> String {
|
||||
if nums.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut sorted_nums = nums;
|
||||
sorted_nums.sort();
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut current_range_start = sorted_nums[0];
|
||||
let mut current_range_end = sorted_nums[0];
|
||||
|
||||
for &n in sorted_nums.iter().skip(1) {
|
||||
if n == current_range_end + 1 {
|
||||
current_range_end = n;
|
||||
} else {
|
||||
if current_range_start == current_range_end {
|
||||
result.push(current_range_start.to_string());
|
||||
} else {
|
||||
result.push(format!("{}:{}", current_range_start, current_range_end));
|
||||
}
|
||||
current_range_start = n;
|
||||
current_range_end = n;
|
||||
}
|
||||
}
|
||||
|
||||
if current_range_start == current_range_end {
|
||||
result.push(current_range_start.to_string());
|
||||
} else {
|
||||
result.push(format!("{}:{}", current_range_start, current_range_end));
|
||||
}
|
||||
|
||||
result.join(",")
|
||||
Ok(max_uid)
|
||||
}
|
||||
|
||||
pub async fn reconcile_mailboxes(
|
||||
@@ -448,7 +364,7 @@ pub async fn reconcile_mailboxes(
|
||||
break;
|
||||
}
|
||||
|
||||
if local_mailbox.uid_validity != remote_mailbox.uid_validity {
|
||||
let new_highest_uid = if local_mailbox.uid_validity != remote_mailbox.uid_validity {
|
||||
if remote_mailbox.uid_validity.is_none() {
|
||||
let err_msg = format!(
|
||||
"Mailbox '{}' logic error: Server did not provide UIDVALIDITY.",
|
||||
@@ -493,7 +409,7 @@ pub async fn reconcile_mailboxes(
|
||||
FetchDirection::Since,
|
||||
token.clone(),
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
}
|
||||
None => match &account.date_before {
|
||||
Some(r) => {
|
||||
@@ -505,7 +421,7 @@ pub async fn reconcile_mailboxes(
|
||||
FetchDirection::Before,
|
||||
token.clone(),
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
rebuild_mailbox_cache(
|
||||
@@ -520,10 +436,12 @@ pub async fn reconcile_mailboxes(
|
||||
}
|
||||
} else {
|
||||
perform_incremental_sync(account, local_mailbox, remote_mailbox, token.clone())
|
||||
.await?;
|
||||
}
|
||||
.await?
|
||||
};
|
||||
|
||||
mailboxes_to_update.push(remote_mailbox.clone());
|
||||
let mut updated = remote_mailbox.clone();
|
||||
updated.highest_uid = new_highest_uid;
|
||||
mailboxes_to_update.push(updated);
|
||||
}
|
||||
//The metadata of this mailbox must only be updated after a successful synchronization;
|
||||
//otherwise, it may cause synchronization errors and result in missing emails in the local sync results.
|
||||
@@ -598,7 +516,11 @@ pub async fn reconcile_mailboxes(
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Ok(new_highest_uid) => {
|
||||
let mut updated = mailbox.clone();
|
||||
updated.highest_uid = new_highest_uid;
|
||||
MailBox::batch_upsert(&[updated])?;
|
||||
}
|
||||
Err(err) => {
|
||||
has_error = true;
|
||||
tracing::error!("Folder sync task failed: {:#?}", err);
|
||||
@@ -622,57 +544,102 @@ pub async fn reconcile_mailboxes(
|
||||
}
|
||||
|
||||
//only check new emails and sync
|
||||
/// Incrementally syncs a mailbox.
|
||||
/// Returns the new highest UID after sync, or `None` if nothing changed.
|
||||
async fn perform_incremental_sync(
|
||||
account: &AccountModel,
|
||||
local_mailbox: &MailBox,
|
||||
remote_mailbox: &MailBox,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<()> {
|
||||
) -> BichonResult<Option<u32>> {
|
||||
if remote_mailbox.exists > 0 {
|
||||
let local_max_uid = ENVELOPE_MANAGER.get_max_uid(account.id, local_mailbox.id)?;
|
||||
match local_max_uid {
|
||||
Some(max_uid) => {
|
||||
let mut session = ImapExecutor::create_connection(account.id).await?;
|
||||
let before_date = account
|
||||
.date_before
|
||||
.as_ref()
|
||||
.map(|r| r.calculate_date())
|
||||
.transpose()?;
|
||||
|
||||
ImapExecutor::fetch_new_mail(
|
||||
&mut session,
|
||||
account,
|
||||
local_mailbox,
|
||||
max_uid + 1,
|
||||
before_date.as_deref(),
|
||||
token,
|
||||
)
|
||||
.await?;
|
||||
session.logout().await.ok();
|
||||
// Use stored highest_uid if available; otherwise fall back to Tantivy
|
||||
// query once (backward compatibility with pre-existing databases).
|
||||
let start_uid = match local_mailbox.highest_uid {
|
||||
Some(uid) => {
|
||||
tracing::info!(
|
||||
"[account {}][mailbox {}] perform_incremental_sync: stored highest_uid={}, remote.exists={}",
|
||||
account.id,
|
||||
local_mailbox.name,
|
||||
uid,
|
||||
remote_mailbox.exists
|
||||
);
|
||||
uid as u64 + 1
|
||||
}
|
||||
None => {
|
||||
info!(
|
||||
"No maximum UID found in index for mailbox, assuming local cache is missing."
|
||||
let local_max_uid =
|
||||
ENVELOPE_MANAGER.get_max_uid(account.id, local_mailbox.id)?;
|
||||
tracing::info!(
|
||||
"[account {}][mailbox {}] perform_incremental_sync: highest_uid unset, Tantivy max_uid={:?}, remote.exists={}",
|
||||
account.id,
|
||||
local_mailbox.name,
|
||||
local_max_uid,
|
||||
remote_mailbox.exists
|
||||
);
|
||||
|
||||
match &account.date_since {
|
||||
Some(date_since) => {
|
||||
fetch_and_save_by_date(
|
||||
account,
|
||||
date_since.since_date()?.as_str(),
|
||||
remote_mailbox,
|
||||
FetchDirection::Since,
|
||||
token,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
match local_max_uid {
|
||||
Some(uid) => uid + 1,
|
||||
None => {
|
||||
fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
|
||||
info!(
|
||||
"No maximum UID found in index for mailbox, assuming local storage is missing."
|
||||
);
|
||||
|
||||
let result = match &account.date_since {
|
||||
Some(date_since) => {
|
||||
fetch_and_save_by_date(
|
||||
account,
|
||||
date_since.since_date()?.as_str(),
|
||||
remote_mailbox,
|
||||
FetchDirection::Since,
|
||||
token,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => match &account.date_before {
|
||||
Some(r) => {
|
||||
fetch_and_save_by_date(
|
||||
account,
|
||||
&r.calculate_date()?,
|
||||
remote_mailbox,
|
||||
FetchDirection::Before,
|
||||
token,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
fetch_and_save_full_mailbox(
|
||||
account, remote_mailbox, token,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
},
|
||||
};
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
let mut session = ImapExecutor::create_connection(account.id).await?;
|
||||
let before_date = account
|
||||
.date_before
|
||||
.as_ref()
|
||||
.map(|r| r.calculate_date())
|
||||
.transpose()?;
|
||||
|
||||
let new_max_uid = ImapExecutor::fetch_new_mail(
|
||||
&mut session,
|
||||
account,
|
||||
local_mailbox,
|
||||
start_uid,
|
||||
before_date.as_deref(),
|
||||
token,
|
||||
)
|
||||
.await?;
|
||||
session.logout().await.ok();
|
||||
|
||||
// Keep existing highest_uid if no new mail was fetched.
|
||||
Ok(new_max_uid.or(local_mailbox.highest_uid))
|
||||
} else {
|
||||
Ok(local_mailbox.highest_uid)
|
||||
}
|
||||
}
|
||||
|
||||
28
crates/core/src/cache/imap/download/rebuild.rs
vendored
28
crates/core/src/cache/imap/download/rebuild.rs
vendored
@@ -89,7 +89,11 @@ pub async fn rebuild_cache(
|
||||
};
|
||||
|
||||
match fetch_and_save_full_mailbox(&account, &mailbox, token.clone()).await {
|
||||
Ok(_) => {}
|
||||
Ok(new_highest_uid) => {
|
||||
let mut updated = mailbox.clone();
|
||||
updated.highest_uid = new_highest_uid;
|
||||
MailBox::batch_upsert(&[updated])?;
|
||||
}
|
||||
Err(err) => {
|
||||
has_error = true;
|
||||
tracing::error!("Folder sync task failed: {:#?}", err);
|
||||
@@ -169,7 +173,11 @@ pub async fn rebuild_cache_by_date(
|
||||
match fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction, token.clone())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Ok(new_highest_uid) => {
|
||||
let mut updated = mailbox.clone();
|
||||
updated.highest_uid = new_highest_uid;
|
||||
MailBox::batch_upsert(&[updated])?;
|
||||
}
|
||||
Err(err) => {
|
||||
has_error = true;
|
||||
tracing::error!("Folder sync task failed: {:#?}", err);
|
||||
@@ -196,7 +204,7 @@ pub async fn rebuild_mailbox_cache(
|
||||
local_mailbox: &MailBox,
|
||||
remote_mailbox: &MailBox,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<()> {
|
||||
) -> BichonResult<Option<u32>> {
|
||||
ENVELOPE_MANAGER
|
||||
.delete_mailbox_envelopes(account.id, vec![local_mailbox.id])
|
||||
.await?;
|
||||
@@ -217,11 +225,11 @@ pub async fn rebuild_mailbox_cache(
|
||||
FolderStatus::Success,
|
||||
None,
|
||||
)?;
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
|
||||
Ok(())
|
||||
let result = fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn rebuild_mailbox_cache_by_date(
|
||||
@@ -231,7 +239,7 @@ pub async fn rebuild_mailbox_cache_by_date(
|
||||
remote: &MailBox,
|
||||
direction: FetchDirection,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<()> {
|
||||
) -> BichonResult<Option<u32>> {
|
||||
ENVELOPE_MANAGER
|
||||
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
|
||||
.await?;
|
||||
@@ -252,9 +260,9 @@ pub async fn rebuild_mailbox_cache_by_date(
|
||||
FolderStatus::Success,
|
||||
None,
|
||||
)?;
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
fetch_and_save_by_date(account, date, remote, direction, token).await?;
|
||||
Ok(())
|
||||
let result = fetch_and_save_by_date(account, date, remote, direction, token).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
4
crates/core/src/cache/imap/mailbox.rs
vendored
4
crates/core/src/cache/imap/mailbox.rs
vendored
@@ -56,6 +56,10 @@ pub struct MailBox {
|
||||
/// The validity identifier for UIDs in this mailbox, used to ensure UID consistency across sessions.
|
||||
/// If `None`, the IMAP server has not provided this information.
|
||||
pub uid_validity: Option<u32>,
|
||||
/// The highest UID that has been successfully downloaded and stored locally.
|
||||
/// Used for incremental sync: next fetch starts from `highest_uid + 1`.
|
||||
/// If `None`, a fallback query against the Tantivy index will be performed once.
|
||||
pub highest_uid: Option<u32>,
|
||||
}
|
||||
|
||||
impl MemDbModel for MailBox {
|
||||
|
||||
@@ -32,12 +32,16 @@ impl DatabaseManager {
|
||||
let db_path = &DATA_DIR_MANAGER.memdb_dir;
|
||||
std::fs::create_dir_all(db_path).expect("Failed to create memdb data directory");
|
||||
|
||||
let db = MemDb::open_with(db_path, Durability::Full)
|
||||
let db = MemDb::open_with(db_path, Durability::Batch { max_ops: 100 })
|
||||
.expect("Failed to open memdb database");
|
||||
|
||||
// Start periodic snapshot worker (every 5 minutes)
|
||||
db.start_snapshot_worker(Duration::from_secs(300));
|
||||
|
||||
// Start periodic flush worker (every 10 seconds) so buffered writes
|
||||
// are flushed regularly and not only at the batch threshold.
|
||||
db.start_flush_worker(Duration::from_secs(10));
|
||||
|
||||
DatabaseManager { db }
|
||||
}
|
||||
|
||||
@@ -45,4 +49,12 @@ impl DatabaseManager {
|
||||
pub fn db(&self) -> &MemDb {
|
||||
&self.db
|
||||
}
|
||||
|
||||
/// Flush any buffered WAL entries to disk. Must be called before shutdown
|
||||
/// to avoid losing writes that haven't hit the batch threshold yet.
|
||||
pub fn flush(&self) {
|
||||
if let Err(e) = self.db.flush() {
|
||||
eprintln!("[memdb] flush error on shutdown: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,33 +197,37 @@ async fn extract_envelope_core(
|
||||
let attachment_docs: Vec<TantivyDocument> = attachments
|
||||
.iter()
|
||||
.filter(|a| !a.inline || a.content_id.is_none())
|
||||
.map(|a| AttachmentModel {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
envelope_id: envelope_id.clone(),
|
||||
account_id,
|
||||
account_email: None,
|
||||
mailbox_id,
|
||||
mailbox_name: None,
|
||||
subject: subject.clone(),
|
||||
content_hash: a.content_hash.clone(),
|
||||
from: from.clone(),
|
||||
date,
|
||||
ingest_at: now,
|
||||
size: a.size as u64,
|
||||
ext: a.get_extension(),
|
||||
category: a.get_category().to_string(),
|
||||
content_type: a.file_type.clone(),
|
||||
shard_id: 0,
|
||||
text: None,
|
||||
has_text: false,
|
||||
is_ocr: false,
|
||||
page_count: None,
|
||||
is_indexed: false,
|
||||
is_message: a.is_message,
|
||||
name: a.filename.clone(),
|
||||
tags: None,
|
||||
auto_tags: None,
|
||||
}).map(|a|a.into_document())
|
||||
.map(|a| {
|
||||
let has_text = a.extracted_text.is_some();
|
||||
AttachmentModel {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
envelope_id: envelope_id.clone(),
|
||||
account_id,
|
||||
account_email: None,
|
||||
mailbox_id,
|
||||
mailbox_name: None,
|
||||
subject: subject.clone(),
|
||||
content_hash: a.content_hash.clone(),
|
||||
from: from.clone(),
|
||||
date,
|
||||
ingest_at: now,
|
||||
size: a.size as u64,
|
||||
ext: a.get_extension(),
|
||||
category: a.get_category().to_string(),
|
||||
content_type: a.file_type.clone(),
|
||||
shard_id: 0,
|
||||
text: a.extracted_text.clone(),
|
||||
has_text,
|
||||
is_ocr: a.extracted_is_ocr,
|
||||
page_count: a.extracted_page_count.map(|n| n as u64),
|
||||
is_indexed: has_text,
|
||||
is_message: a.is_message,
|
||||
name: a.filename.clone(),
|
||||
tags: None,
|
||||
auto_tags: None,
|
||||
}
|
||||
})
|
||||
.map(|a| a.into_document())
|
||||
.collect();
|
||||
|
||||
let envelope = Envelope {
|
||||
@@ -256,6 +260,14 @@ async fn extract_envelope_core(
|
||||
attachments: Some(attachments),
|
||||
};
|
||||
let doc = ea.to_document(&body_text, 0)?;
|
||||
tracing::debug!(
|
||||
"[account {}][mailbox {}] extract: uid={} msg_id={} content_hash={}",
|
||||
account_id,
|
||||
mailbox_id,
|
||||
uid,
|
||||
&ea.envelope.message_id,
|
||||
&ea.envelope.content_hash,
|
||||
);
|
||||
ENVELOPE_MANAGER.queue(doc).await;
|
||||
for doc in attachment_docs {
|
||||
ATTACHMENT_MANAGER.queue(doc).await;
|
||||
@@ -389,44 +401,125 @@ pub async fn detach_and_store_attachments(
|
||||
|
||||
ranges.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
let mut attachments = Vec::with_capacity(ranges.len());
|
||||
|
||||
// Collect candidates for text extraction (non-inline, known document types).
|
||||
struct TextCandidate {
|
||||
content_hash: String,
|
||||
file_type: String,
|
||||
ext: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
let mut text_candidates: Vec<TextCandidate> = Vec::new();
|
||||
|
||||
for (raw_start, raw_end, att) in ranges {
|
||||
// Step 2: Extract raw bytes and store them as standalone documents
|
||||
let raw_bytes = &original_body[raw_start..raw_end];
|
||||
//This is the content hash of the decoded attachment, not the undecoded one.
|
||||
// mail-parser may report attachment offsets past the body end for
|
||||
// malformed messages; clamp the range to avoid a slice panic.
|
||||
let body_len = original_body.len();
|
||||
let raw_start = raw_start.min(body_len);
|
||||
let raw_end = raw_end.min(body_len);
|
||||
let range_valid = raw_start < raw_end;
|
||||
|
||||
// content hash is computed from the decoded attachment contents,
|
||||
// which is always available regardless of raw offset validity.
|
||||
let content_hash = compute_content_hash(att.contents());
|
||||
|
||||
//"The actual content stored in the blob is the raw undecoded data, to avoid the reconstructed EML differing from the original due to decoding and re-encoding.
|
||||
attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));//
|
||||
if range_valid {
|
||||
let raw_bytes = &original_body[raw_start..raw_end];
|
||||
// The actual content stored in the blob is the raw undecoded data.
|
||||
attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));
|
||||
|
||||
// Step 3: Replace raw attachment content with a hash-based placeholder
|
||||
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
|
||||
let p_bytes = placeholder.as_bytes();
|
||||
stripped_eml.splice(raw_start..raw_end, p_bytes.iter().cloned());
|
||||
// Replace raw attachment content with a hash-based placeholder
|
||||
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
|
||||
stripped_eml.splice(raw_start..raw_end, placeholder.as_bytes().iter().cloned());
|
||||
} else {
|
||||
// Invalid range: store a zero-length blob so the consistency
|
||||
// check passes; reattachment will log a warning for the missing
|
||||
// blob data but won't panic.
|
||||
attachments.push((content_hash.clone(), Bytes::new()));
|
||||
}
|
||||
|
||||
let inline = att
|
||||
.content_disposition()
|
||||
.map(|d| d.is_inline())
|
||||
.unwrap_or_else(|| att.content_id().is_some());
|
||||
let file_type = att
|
||||
.content_type()
|
||||
.map(|ct| {
|
||||
format!(
|
||||
"{}/{}",
|
||||
ct.c_type.as_ref(),
|
||||
ct.c_subtype.as_deref().unwrap_or("")
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
let has_cid = att.content_id().is_some();
|
||||
let ext = att
|
||||
.attachment_name()
|
||||
.and_then(|n| {
|
||||
std::path::Path::new(&n)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if !inline || !has_cid {
|
||||
let decoded_len = att.contents().len();
|
||||
if decoded_len <= crate::ext::text_extractor::MAX_EXTRACT_BYTES
|
||||
&& crate::ext::text_extractor::should_try_extract(&file_type, &ext)
|
||||
{
|
||||
text_candidates.push(TextCandidate {
|
||||
content_hash: content_hash.clone(),
|
||||
file_type: file_type.clone(),
|
||||
ext: ext.clone(),
|
||||
bytes: att.contents().to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let info = AttachmentInfo {
|
||||
filename: att.attachment_name().map(|n| n.to_string()),
|
||||
size: att.contents().len(),
|
||||
inline: att
|
||||
.content_disposition()
|
||||
.map(|d| d.is_inline())
|
||||
.unwrap_or(false),
|
||||
file_type: att
|
||||
.content_type()
|
||||
.map(|ct| {
|
||||
format!(
|
||||
"{}/{}",
|
||||
ct.c_type.as_ref(),
|
||||
ct.c_subtype.as_deref().unwrap_or("")
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||
inline,
|
||||
file_type,
|
||||
content_id: att.content_id().map(|id| id.to_string()),
|
||||
content_hash: content_hash.clone(),
|
||||
is_message: att.is_message(),
|
||||
extracted_text: None,
|
||||
extracted_page_count: None,
|
||||
extracted_is_ocr: false,
|
||||
};
|
||||
|
||||
attachment_infos.push(info);
|
||||
}
|
||||
|
||||
// Run text extraction in a single spawn_blocking batch.
|
||||
if !text_candidates.is_empty() {
|
||||
if let Ok(mut extracted_map) = tokio::task::spawn_blocking(move || {
|
||||
let mut map: std::collections::HashMap<
|
||||
String,
|
||||
(String, Option<u32>, bool),
|
||||
> = std::collections::HashMap::new();
|
||||
for c in text_candidates {
|
||||
if let Some(r) =
|
||||
crate::ext::text_extractor::extract_text(&c.file_type, &c.ext, &c.bytes)
|
||||
{
|
||||
map.insert(c.content_hash, (r.text, r.page_count, r.is_ocr));
|
||||
}
|
||||
}
|
||||
map
|
||||
})
|
||||
.await
|
||||
{
|
||||
for info in &mut attachment_infos {
|
||||
if let Some((text, pages, is_ocr)) = extracted_map.remove(&info.content_hash) {
|
||||
info.extracted_text = Some(text);
|
||||
info.extracted_page_count = pages;
|
||||
info.extracted_is_ocr = is_ocr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Step 4: Store the final stripped EML content
|
||||
BLOB_MANAGER
|
||||
.queue(DetachedEmail {
|
||||
@@ -675,4 +768,56 @@ mod test {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies that [`super::detach_and_store_attachments`] does not panic
|
||||
/// when mail-parser reports attachment offsets past the raw body length.
|
||||
///
|
||||
/// Regression test for: "range end index X out of range for slice of
|
||||
/// length Y" panic caused by a malformed email whose attachment
|
||||
/// `raw_end_offset` exceeded the actual body size.
|
||||
#[tokio::test]
|
||||
async fn detach_attachments_bounds_check() {
|
||||
let raw = concat!(
|
||||
"From: sender@example.com\r\n",
|
||||
"To: recipient@example.com\r\n",
|
||||
"Subject: Test\r\n",
|
||||
"MIME-Version: 1.0\r\n",
|
||||
"Content-Type: multipart/mixed; boundary=\"bnd\"\r\n",
|
||||
"\r\n",
|
||||
"--bnd\r\n",
|
||||
"Content-Type: text/plain\r\n",
|
||||
"\r\n",
|
||||
"Hello\r\n",
|
||||
"--bnd\r\n",
|
||||
"Content-Type: application/octet-stream\r\n",
|
||||
"Content-Disposition: attachment; filename=\"test.bin\"\r\n",
|
||||
"\r\n",
|
||||
"AAAAABBBBBCCCCCDDDDDEEEEEAAAAABBBBBCCCCCDDDDDEEEEE\r\n",
|
||||
"--bnd--\r\n",
|
||||
)
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
|
||||
let message = mail_parser::MessageParser::new()
|
||||
.parse(&raw)
|
||||
.expect("parse valid MIME message");
|
||||
assert_eq!(message.attachment_count(), 1);
|
||||
|
||||
// Truncate the raw body so the attachment's raw_end_offset lies
|
||||
// past the body end — exactly the scenario reported by users.
|
||||
let truncated = &raw[..raw.len() - 20];
|
||||
assert!(truncated.len() < raw.len());
|
||||
|
||||
// Must not panic.
|
||||
let infos = super::detach_and_store_attachments(
|
||||
truncated,
|
||||
&message,
|
||||
"test_content_hash",
|
||||
)
|
||||
.await;
|
||||
|
||||
// The attachment count must still match so the consistency check
|
||||
// in reattach_eml_content doesn't fail later.
|
||||
assert_eq!(infos.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
86
crates/core/src/ext/event_bus.rs
Normal file
86
crates/core/src/ext/event_bus.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 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/>.
|
||||
|
||||
// Event bus extension point.
|
||||
//
|
||||
// Community edition: NoopEventBus — all events are discarded.
|
||||
// Pro edition: AuditEventBus — events are persisted to audit database.
|
||||
// Enterprise edition: adds SIEM webhook to the same trait impl.
|
||||
//
|
||||
// The open-source server emits events at key points (login, view, delete, search).
|
||||
// It never reads from the event bus — events are fire-and-forget.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
EmailViewed {
|
||||
email_id: String,
|
||||
user: String,
|
||||
ip: IpAddr,
|
||||
},
|
||||
EmailDeleted {
|
||||
email_id: String,
|
||||
user: String,
|
||||
},
|
||||
UserLoggedIn {
|
||||
user: String,
|
||||
ip: IpAddr,
|
||||
},
|
||||
UserCreated {
|
||||
created_by: String,
|
||||
new_user: String,
|
||||
},
|
||||
SearchPerformed {
|
||||
query: String,
|
||||
user: String,
|
||||
},
|
||||
SettingsChanged {
|
||||
key: String,
|
||||
user: String,
|
||||
},
|
||||
AttachmentDownloaded {
|
||||
email_id: String,
|
||||
content_hash: String,
|
||||
user: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub trait EventBus: Send + Sync {
|
||||
fn emit(&self, event: Event);
|
||||
}
|
||||
|
||||
/// Default — all events are discarded.
|
||||
struct NoopEventBus;
|
||||
impl EventBus for NoopEventBus {
|
||||
fn emit(&self, _event: Event) {}
|
||||
}
|
||||
|
||||
static EVENT_BUS: LazyLock<RwLock<Box<dyn EventBus>>> =
|
||||
LazyLock::new(|| RwLock::new(Box::new(NoopEventBus)));
|
||||
|
||||
/// Called by Pro/Enterprise at startup to replace the noop default.
|
||||
pub fn set_event_bus(bus: Box<dyn EventBus>) {
|
||||
*EVENT_BUS.write().unwrap() = bus;
|
||||
}
|
||||
|
||||
/// Fire-and-forget. Called by the server at key points.
|
||||
pub fn emit(event: Event) {
|
||||
EVENT_BUS.read().unwrap().emit(event);
|
||||
}
|
||||
29
crates/core/src/ext/mod.rs
Normal file
29
crates/core/src/ext/mod.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 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/>.
|
||||
|
||||
// Event bus extension point.
|
||||
//
|
||||
// Community edition: NoopEventBus — all events are discarded.
|
||||
// Pro edition: AuditEventBus — events are persisted to audit database.
|
||||
// Enterprise edition: adds SIEM webhook to the same trait impl.
|
||||
//
|
||||
// The open-source server emits events at key points (login, view, delete, search).
|
||||
// It never reads from the event bus — events are fire-and-forget.
|
||||
|
||||
pub mod event_bus;
|
||||
pub mod text_extractor;
|
||||
75
crates/core/src/ext/text_extractor.rs
Normal file
75
crates/core/src/ext/text_extractor.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 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/>.
|
||||
|
||||
// Attachment text extraction extension point.
|
||||
//
|
||||
// Community edition: NoopExtractor — no attachments are text-indexed.
|
||||
// Pro edition: PdfExtractor — extracts text from PDF, Word, etc.
|
||||
//
|
||||
// Used in: crates/core/src/envelope/extractor.rs
|
||||
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
|
||||
pub struct ExtractedText {
|
||||
pub text: String,
|
||||
pub page_count: Option<u32>,
|
||||
pub is_ocr: bool,
|
||||
}
|
||||
|
||||
pub trait AttachmentTextExtractor: Send + Sync {
|
||||
/// Returns None if this extractor doesn't handle the file type.
|
||||
/// Returns Some(ExtractedText) if text was successfully extracted.
|
||||
fn extract(&self, content_type: &str, ext: &str, bytes: &[u8]) -> Option<ExtractedText>;
|
||||
}
|
||||
|
||||
/// Default — all attachments are skipped.
|
||||
struct NoopExtractor;
|
||||
impl AttachmentTextExtractor for NoopExtractor {
|
||||
fn extract(&self, _ct: &str, _ext: &str, _bytes: &[u8]) -> Option<ExtractedText> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
static EXTRACTOR: LazyLock<RwLock<Box<dyn AttachmentTextExtractor>>> =
|
||||
LazyLock::new(|| RwLock::new(Box::new(NoopExtractor)));
|
||||
|
||||
/// Called by Pro/Enterprise at startup to replace the noop default.
|
||||
pub fn set_extractor(extractor: Box<dyn AttachmentTextExtractor>) {
|
||||
*EXTRACTOR.write().unwrap() = extractor;
|
||||
}
|
||||
|
||||
/// Attachments larger than this are skipped (10 MiB). Avoids excessive memory
|
||||
/// and CPU cost for huge files whose text is rarely useful for search.
|
||||
pub const MAX_EXTRACT_BYTES: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// Quick pre-filter: returns true for file types where text extraction may
|
||||
/// produce useful results. Avoids cloning attachment bytes for images, videos,
|
||||
/// archives, etc. when no registered extractor would handle them.
|
||||
pub fn should_try_extract(content_type: &str, ext: &str) -> bool {
|
||||
matches!(
|
||||
ext,
|
||||
"pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
|
||||
| "txt" | "rtf" | "odt" | "ods" | "odp"
|
||||
) || content_type.starts_with("text/")
|
||||
}
|
||||
|
||||
/// Called by the attachment pipeline during IMAP sync.
|
||||
/// The caller should wrap this in spawn_blocking for CPU-bound extraction.
|
||||
pub fn extract_text(content_type: &str, ext: &str, bytes: &[u8]) -> Option<ExtractedText> {
|
||||
EXTRACTOR.read().unwrap().extract(content_type, ext, bytes)
|
||||
}
|
||||
@@ -17,8 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::account::migration::AccountModel;
|
||||
use crate::account::state::{DownloadState, FolderStatus};
|
||||
use crate::cache::imap::download::flow::{generate_uid_sequence_hashset, DEFAULT_BATCH_SIZE};
|
||||
use crate::account::state::{DownloadState, DownloadStatus, FolderStatus};
|
||||
use crate::cache::imap::mailbox::MailBox;
|
||||
use crate::envelope::extractor::extract_envelope_and_store_it;
|
||||
use crate::error::code::ErrorCode;
|
||||
@@ -80,6 +79,15 @@ impl ImapExecutor {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
|
||||
}
|
||||
|
||||
/// Fetches new mail for a mailbox.
|
||||
///
|
||||
/// When `before` is `Some(date)`, a two-step approach is used:
|
||||
/// `UID SEARCH` to find matching UIDs (standard IMAP), then batch `UID FETCH`
|
||||
/// for the specific UIDs. When `before` is `None`, a direct ranged
|
||||
/// `UID FETCH {start}:*` is issued and results are streamed.
|
||||
///
|
||||
/// Returns `Ok(Some(max_uid))` with the highest UID fetched, or `Ok(None)`
|
||||
/// if no new mail was found.
|
||||
pub async fn fetch_new_mail(
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
account: &AccountModel,
|
||||
@@ -87,114 +95,197 @@ impl ImapExecutor {
|
||||
start_uid: u64,
|
||||
before: Option<&str>,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<()> {
|
||||
) -> BichonResult<Option<u32>> {
|
||||
assert!(start_uid > 0, "start_uid must be greater than 0");
|
||||
|
||||
let query = match before {
|
||||
Some(date) => format!("UID {start_uid}:* BEFORE {date}"),
|
||||
None => format!("UID {start_uid}:*"),
|
||||
};
|
||||
session
|
||||
.examine(&mailbox.encoded_name())
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
|
||||
let uid_list = match Self::uid_search(session, &mailbox.encoded_name(), &query).await {
|
||||
Ok(uid_list) => uid_list,
|
||||
Err(e) => {
|
||||
let err_msg = format!("UID search failed in [{}]: {:#?}", mailbox.name, e);
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
0,
|
||||
0,
|
||||
FolderStatus::Failed,
|
||||
Some(err_msg.clone()),
|
||||
)?;
|
||||
DownloadState::append_session_error(account.id, err_msg)?;
|
||||
return Err(e);
|
||||
match before {
|
||||
Some(date) => {
|
||||
Self::fetch_new_mail_with_before(session, account, mailbox, start_uid, date, token)
|
||||
.await
|
||||
}
|
||||
};
|
||||
None => Self::fetch_new_mail_range(session, account, mailbox, start_uid, token).await,
|
||||
}
|
||||
}
|
||||
|
||||
let len = uid_list.len();
|
||||
if len == 0 {
|
||||
let msg = match before {
|
||||
Some(date) => format!("No emails found before {}.", date),
|
||||
None => "No new emails found.".into(),
|
||||
};
|
||||
/// Two-step approach for date-filtered incremental fetch: UID SEARCH first,
|
||||
/// then batch UID FETCH for matching UIDs. Uses standard IMAP syntax that
|
||||
/// works across all compliant servers.
|
||||
async fn fetch_new_mail_with_before(
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
account: &AccountModel,
|
||||
mailbox: &MailBox,
|
||||
start_uid: u64,
|
||||
date: &str,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<Option<u32>> {
|
||||
let query = format!("UID {start_uid}:* BEFORE {date}");
|
||||
info!(
|
||||
"[account {}][mailbox {}] fetch_new_mail: UID SEARCH {}",
|
||||
account.id, mailbox.name, query
|
||||
);
|
||||
let results = session.uid_search(&query).await.map_err(|e| {
|
||||
let err_msg = format!("UID SEARCH failed in [{}]: {:#?}", mailbox.name, e);
|
||||
let _ = DownloadState::append_session_error(account.id, err_msg);
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})?;
|
||||
|
||||
if results.is_empty() {
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
0,
|
||||
0,
|
||||
FolderStatus::Success,
|
||||
Some(msg),
|
||||
Some("No new emails found.".into()),
|
||||
)?;
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
info!(
|
||||
"[account {}][mailbox {}] {} envelopes need to be fetched",
|
||||
account.id, mailbox.name, len
|
||||
);
|
||||
|
||||
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
|
||||
let mut uid_vec: Vec<u32> = results.into_iter().collect();
|
||||
uid_vec.sort();
|
||||
let uid_batches = generate_uid_sequence_hashset(
|
||||
uid_vec,
|
||||
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
|
||||
false,
|
||||
);
|
||||
let mut current_processed = 0u64;
|
||||
let mut has_error_or_cancel = false;
|
||||
for (index, batch) in uid_batches.into_iter().enumerate() {
|
||||
let max_uid = uid_vec.last().copied();
|
||||
let planned = uid_vec.len() as u64;
|
||||
let batch_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize;
|
||||
let uid_batches = generate_uid_sequence_hashset(uid_vec, batch_size);
|
||||
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
planned,
|
||||
0,
|
||||
FolderStatus::Pending,
|
||||
None,
|
||||
)?;
|
||||
|
||||
let mut count = 0u64;
|
||||
for batch in uid_batches {
|
||||
if token.is_cancelled() {
|
||||
break;
|
||||
DownloadState::update_session_status(
|
||||
account.id,
|
||||
DownloadStatus::Cancelled,
|
||||
Some("User stopped or system shutdown".to_string()),
|
||||
)?;
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
planned,
|
||||
count,
|
||||
FolderStatus::Cancelled,
|
||||
None,
|
||||
)?;
|
||||
return Err(raise_error!(
|
||||
"Stream cancelled".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
match Self::uid_batch_retrieve_emails(
|
||||
Self::uid_batch_retrieve_emails(
|
||||
session,
|
||||
account.id,
|
||||
mailbox.id,
|
||||
&batch.0,
|
||||
token.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
current_processed += batch.1;
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
len as u64,
|
||||
current_processed,
|
||||
FolderStatus::Downloading,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("Batch {} failed: {:#?}", index, e);
|
||||
DownloadState::append_session_error(account.id, err_msg.clone())?;
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
len as u64,
|
||||
current_processed,
|
||||
FolderStatus::Failed,
|
||||
Some(err_msg),
|
||||
)?;
|
||||
has_error_or_cancel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_error_or_cancel {
|
||||
.await?;
|
||||
count += batch.1;
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
len as u64,
|
||||
current_processed,
|
||||
planned,
|
||||
count,
|
||||
FolderStatus::Downloading,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
count,
|
||||
count,
|
||||
FolderStatus::Success,
|
||||
None,
|
||||
)?;
|
||||
|
||||
Ok(max_uid)
|
||||
}
|
||||
|
||||
/// Direct ranged UID FETCH without date filtering. Streams results from
|
||||
/// the server in a single IMAP round-trip.
|
||||
async fn fetch_new_mail_range(
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
account: &AccountModel,
|
||||
mailbox: &MailBox,
|
||||
start_uid: u64,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<Option<u32>> {
|
||||
let uid_range = format!("{start_uid}:*");
|
||||
info!(
|
||||
"[account {}][mailbox {}] fetch_new_mail: direct UID FETCH {}",
|
||||
account.id, mailbox.name, uid_range
|
||||
);
|
||||
|
||||
let mut stream = session
|
||||
.uid_fetch(&uid_range, BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let err_msg = format!("UID FETCH failed in [{}]: {:#?}", mailbox.name, e);
|
||||
let _ = DownloadState::append_session_error(account.id, err_msg);
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})?;
|
||||
|
||||
let mut count = 0u64;
|
||||
let mut max_uid: Option<u32> = None;
|
||||
while let Some(fetch) = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
tracing::info!("Account {}: fetch_new_mail stream interrupted.", account.id);
|
||||
DownloadState::update_session_status(
|
||||
account.id,
|
||||
DownloadStatus::Cancelled,
|
||||
Some("User stopped or system shutdown".to_string()),
|
||||
)?;
|
||||
return Err(raise_error!(
|
||||
"Stream cancelled".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(uid) = fetch.uid {
|
||||
max_uid = Some(max_uid.unwrap_or(0).max(uid));
|
||||
}
|
||||
extract_envelope_and_store_it(fetch, account.id, mailbox.id).await?;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
0,
|
||||
0,
|
||||
FolderStatus::Success,
|
||||
Some("No new emails found.".into()),
|
||||
)?;
|
||||
} else {
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
count,
|
||||
count,
|
||||
FolderStatus::Success,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(max_uid)
|
||||
}
|
||||
|
||||
pub async fn batch_retrieve_emails(
|
||||
@@ -205,36 +296,23 @@ impl ImapExecutor {
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
encoded_mailbox_name: &str,
|
||||
desc: bool,
|
||||
token: CancellationToken,
|
||||
max_uid: &mut Option<u32>,
|
||||
) -> BichonResult<usize> {
|
||||
assert!(page > 0, "Page number must be greater than 0");
|
||||
assert!(page_size > 0, "Page size must be greater than 0");
|
||||
|
||||
let (start, end) = if desc {
|
||||
// Fetch messages starting from the newest (descending order)
|
||||
let end = total.saturating_sub((page - 1) * page_size);
|
||||
if end == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
// Calculate start as end - page_size + 1 to avoid off-by-one errors
|
||||
let start = end.saturating_sub(page_size - 1).max(1);
|
||||
(start, end)
|
||||
} else {
|
||||
// Fetch messages starting from the oldest (ascending order)
|
||||
let start = (page - 1) * page_size + 1;
|
||||
if start > total {
|
||||
return Ok(0);
|
||||
}
|
||||
// Calculate end, capped by the total number of messages
|
||||
let end = (start + page_size - 1).min(total);
|
||||
(start, end)
|
||||
};
|
||||
// Fetch messages starting from the oldest (ascending order).
|
||||
let start = (page - 1) * page_size + 1;
|
||||
if start > total {
|
||||
return Ok(0);
|
||||
}
|
||||
let end = (start + page_size - 1).min(total);
|
||||
|
||||
let sequence_set = format!("{}:{}", start, end);
|
||||
info!(
|
||||
"Fetching mailbox '{}' messages: sequence {} (page {}, page_size {}, desc={})",
|
||||
encoded_mailbox_name, sequence_set, page, page_size, desc
|
||||
"Fetching mailbox '{}' messages: sequence {} (page {}, page_size {})",
|
||||
encoded_mailbox_name, sequence_set, page, page_size
|
||||
);
|
||||
|
||||
let mut stream = session
|
||||
@@ -255,6 +333,9 @@ impl ImapExecutor {
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
if let Some(uid) = fetch.uid {
|
||||
*max_uid = Some((*max_uid).unwrap_or(0).max(uid));
|
||||
}
|
||||
extract_envelope_and_store_it(fetch, account_id, mailbox_id).await?;
|
||||
count += 1;
|
||||
}
|
||||
@@ -347,3 +428,120 @@ impl ImapExecutor {
|
||||
ImapConnectionManager::build(account_id).await
|
||||
}
|
||||
}
|
||||
|
||||
pub const DEFAULT_BATCH_SIZE: u32 = 30;
|
||||
|
||||
/// Compresses a sorted list of UIDs into an IMAP sequence-set string.
|
||||
/// Consecutive UIDs become ranges (e.g. `1:5`), non-consecutive are
|
||||
/// comma-separated (e.g. `1:5,10,12:15`).
|
||||
pub fn compress_uid_list(nums: Vec<u32>) -> String {
|
||||
if nums.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut sorted_nums = nums;
|
||||
sorted_nums.sort();
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut current_range_start = sorted_nums[0];
|
||||
let mut current_range_end = sorted_nums[0];
|
||||
|
||||
for &n in sorted_nums.iter().skip(1) {
|
||||
if n == current_range_end + 1 {
|
||||
current_range_end = n;
|
||||
} else {
|
||||
if current_range_start == current_range_end {
|
||||
result.push(current_range_start.to_string());
|
||||
} else {
|
||||
result.push(format!("{}:{}", current_range_start, current_range_end));
|
||||
}
|
||||
current_range_start = n;
|
||||
current_range_end = n;
|
||||
}
|
||||
}
|
||||
|
||||
if current_range_start == current_range_end {
|
||||
result.push(current_range_start.to_string());
|
||||
} else {
|
||||
result.push(format!("{}:{}", current_range_start, current_range_end));
|
||||
}
|
||||
|
||||
result.join(",")
|
||||
}
|
||||
|
||||
/// Splits a sorted list of unique UIDs into compressed sequence-set batches.
|
||||
/// Returns `Vec<(sequence_set_string, batch_count)>`.
|
||||
pub fn generate_uid_sequence_hashset(
|
||||
unique_nums: Vec<u32>,
|
||||
chunk_size: usize,
|
||||
) -> Vec<(String, u64)> {
|
||||
assert!(!unique_nums.is_empty());
|
||||
|
||||
let mut result = Vec::new();
|
||||
let nums = unique_nums;
|
||||
|
||||
for chunk in nums.chunks(chunk_size) {
|
||||
let size = chunk.len() as u64;
|
||||
let compressed = compress_uid_list(chunk.to_vec());
|
||||
result.push((compressed, size));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
// ── compress_uid_list ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn compress_empty() {
|
||||
assert_eq!(compress_uid_list(vec![]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_single_uid() {
|
||||
assert_eq!(compress_uid_list(vec![42]), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_consecutive_range() {
|
||||
assert_eq!(compress_uid_list(vec![1, 2, 3, 4, 5]), "1:5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_mixed_ranges() {
|
||||
assert_eq!(
|
||||
compress_uid_list(vec![1, 2, 3, 5, 7, 8, 9, 10]),
|
||||
"1:3,5,7:10"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_gap_at_boundary() {
|
||||
assert_eq!(compress_uid_list(vec![1, 2, 4, 5]), "1:2,4:5");
|
||||
}
|
||||
|
||||
// ── generate_uid_sequence_hashset ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn batch_single_chunk() {
|
||||
let batches = generate_uid_sequence_hashset(vec![1, 2, 3], 10);
|
||||
assert_eq!(batches.len(), 1);
|
||||
assert_eq!(batches[0].0, "1:3");
|
||||
assert_eq!(batches[0].1, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiple_chunks() {
|
||||
let batches = generate_uid_sequence_hashset(vec![1, 2, 3, 4, 5], 2);
|
||||
assert_eq!(batches.len(), 3);
|
||||
assert_eq!(batches[0].0, "1:2");
|
||||
assert_eq!(batches[0].1, 2);
|
||||
assert_eq!(batches[1].0, "3:4");
|
||||
assert_eq!(batches[1].1, 2);
|
||||
assert_eq!(batches[2].0, "5");
|
||||
assert_eq!(batches[2].1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ impl ImportEmls {
|
||||
unseen: None,
|
||||
uid_next: None,
|
||||
uid_validity: None,
|
||||
highest_uid: None,
|
||||
};
|
||||
let mailbox_id = mailbox.id;
|
||||
// Upsert the mailbox, creating it if it doesn't exist
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod account;
|
||||
pub mod ext;
|
||||
pub mod admin;
|
||||
pub mod autoconfig;
|
||||
pub mod cache;
|
||||
|
||||
@@ -157,8 +157,13 @@ async fn fetch_remote_with_progress(account_id: u64) -> BichonResult<Vec<MailBox
|
||||
|
||||
mailbox.account_id = account_id;
|
||||
mailbox.id = create_hash(account_id, &mailbox.name);
|
||||
// Use STATUS instead of EXAMINE: gets MESSAGES/UNSEEN/UIDNEXT/UIDVALIDITY
|
||||
// without selecting the mailbox, avoiding context switches.
|
||||
let mx = session
|
||||
.examine(mailbox_name.as_str())
|
||||
.status(
|
||||
mailbox_name.as_str(),
|
||||
"(MESSAGES UNSEEN UIDNEXT UIDVALIDITY)",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
mailbox.exists = mx.exists;
|
||||
@@ -205,8 +210,13 @@ pub async fn convert_names_to_mailboxes(
|
||||
|
||||
mailbox.account_id = account_id;
|
||||
mailbox.id = create_hash(account_id, &mailbox.name);
|
||||
// Use STATUS instead of EXAMINE: gets MESSAGES/UNSEEN/UIDNEXT/UIDVALIDITY
|
||||
// without selecting the mailbox, avoiding context switches.
|
||||
let mx = session
|
||||
.examine(mailbox_name.as_str())
|
||||
.status(
|
||||
mailbox_name.as_str(),
|
||||
"(MESSAGES UNSEEN UIDNEXT UIDVALIDITY)",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
mailbox.exists = mx.exists;
|
||||
|
||||
@@ -47,6 +47,14 @@ pub struct AttachmentInfo {
|
||||
/// Hash of the content.
|
||||
pub content_hash: String,
|
||||
pub is_message: bool,
|
||||
/// Text extracted from the attachment body (Pro/Enterprise feature).
|
||||
/// Populated during IMAP sync; None for inline attachments and unsupported file types.
|
||||
pub extracted_text: Option<String>,
|
||||
/// Page count reported by the extractor, if any.
|
||||
pub extracted_page_count: Option<u32>,
|
||||
/// Whether the extracted text came from OCR.
|
||||
#[serde(default)]
|
||||
pub extracted_is_ocr: bool,
|
||||
}
|
||||
|
||||
impl AttachmentInfo {
|
||||
@@ -198,7 +206,9 @@ pub fn retrieve_email_content(
|
||||
content_type.c_subtype.as_deref().unwrap_or("")
|
||||
);
|
||||
|
||||
let inline = disposition.map(|d| d.is_inline()).unwrap_or(false);
|
||||
let inline = disposition
|
||||
.map(|d| d.is_inline())
|
||||
.unwrap_or_else(|| attachment.content_id().is_some());
|
||||
|
||||
if inline {
|
||||
if let Some(html1) = html.as_deref() {
|
||||
@@ -222,13 +232,16 @@ pub fn retrieve_email_content(
|
||||
let is_message = attachment.is_message();
|
||||
let content_hash = compute_content_hash(attachment.contents());
|
||||
attachments.push(AttachmentInfo {
|
||||
filename: filename.or(Some(content_hash.clone())), // Fallback to content_hash as the default filename if it is not provided.
|
||||
filename: filename.or(Some(content_hash.clone())),
|
||||
size: attachment.contents().len(),
|
||||
inline,
|
||||
file_type,
|
||||
is_message,
|
||||
content_hash,
|
||||
content_id: attachment.content_id().map(Into::into),
|
||||
extracted_text: None,
|
||||
extracted_page_count: None,
|
||||
extracted_is_ocr: false,
|
||||
});
|
||||
}
|
||||
let mut has_remote_content = false;
|
||||
@@ -291,7 +304,9 @@ pub fn retrieve_nested_eml_content(
|
||||
for attachment in nested_message.attachments() {
|
||||
let cid = attachment.content_id();
|
||||
let disposition = attachment.content_disposition();
|
||||
let is_inline = disposition.map(|d| d.is_inline()).unwrap_or(false);
|
||||
let is_inline = disposition
|
||||
.map(|d| d.is_inline())
|
||||
.unwrap_or_else(|| cid.is_some());
|
||||
|
||||
if has_html && is_inline && cid.is_some() {
|
||||
let content_id = cid.unwrap();
|
||||
@@ -320,13 +335,16 @@ pub fn retrieve_nested_eml_content(
|
||||
filename: attachment
|
||||
.attachment_name()
|
||||
.map(|n| n.to_string())
|
||||
.or(Some(content_hash.clone())), // Fallback to content_hash as the default filename if it is not provided.
|
||||
.or(Some(content_hash.clone())),
|
||||
size: attachment.contents().len(),
|
||||
inline: is_inline,
|
||||
file_type,
|
||||
content_hash,
|
||||
is_message: attachment.is_message(),
|
||||
content_id: cid.map(Into::into),
|
||||
extracted_text: None,
|
||||
extracted_page_count: None,
|
||||
extracted_is_ocr: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -348,3 +366,102 @@ pub fn retrieve_nested_eml_content(
|
||||
has_remote_content,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Simulates JSON written by a version before `extracted_text`, `extracted_page_count`,
|
||||
/// and `extracted_is_ocr` were added to [`AttachmentInfo`]. Deserialization must
|
||||
/// succeed and fill the missing fields with their defaults.
|
||||
#[test]
|
||||
fn attachment_info_backward_compat_no_extracted_fields() {
|
||||
let old_json = r#"[
|
||||
{
|
||||
"file_type": "application/pdf",
|
||||
"inline": false,
|
||||
"filename": "report.pdf",
|
||||
"size": 12345,
|
||||
"content_id": null,
|
||||
"content_hash": "abc123",
|
||||
"is_message": false
|
||||
},
|
||||
{
|
||||
"file_type": "image/png",
|
||||
"inline": true,
|
||||
"filename": "logo.png",
|
||||
"size": 6789,
|
||||
"content_id": "cid:logo@example.com",
|
||||
"content_hash": "def456",
|
||||
"is_message": false
|
||||
}
|
||||
]"#;
|
||||
|
||||
let attachments: Vec<AttachmentInfo> =
|
||||
serde_json::from_str(old_json).expect("should deserialize legacy JSON");
|
||||
|
||||
assert_eq!(attachments.len(), 2);
|
||||
|
||||
// First attachment (regular file)
|
||||
assert_eq!(attachments[0].file_type, "application/pdf");
|
||||
assert!(!attachments[0].inline);
|
||||
assert_eq!(attachments[0].filename.as_deref(), Some("report.pdf"));
|
||||
assert_eq!(attachments[0].size, 12345);
|
||||
assert_eq!(attachments[0].content_id, None);
|
||||
assert_eq!(attachments[0].content_hash, "abc123");
|
||||
assert!(!attachments[0].is_message);
|
||||
// Fields added after the legacy format — must default correctly
|
||||
assert_eq!(attachments[0].extracted_text, None);
|
||||
assert_eq!(attachments[0].extracted_page_count, None);
|
||||
assert!(!attachments[0].extracted_is_ocr);
|
||||
|
||||
// Second attachment (inline image with content-id)
|
||||
assert_eq!(attachments[1].file_type, "image/png");
|
||||
assert!(attachments[1].inline);
|
||||
assert_eq!(attachments[1].filename.as_deref(), Some("logo.png"));
|
||||
assert_eq!(attachments[1].size, 6789);
|
||||
assert_eq!(attachments[1].content_id.as_deref(), Some("cid:logo@example.com"));
|
||||
assert_eq!(attachments[1].content_hash, "def456");
|
||||
assert!(!attachments[1].is_message);
|
||||
assert_eq!(attachments[1].extracted_text, None);
|
||||
assert_eq!(attachments[1].extracted_page_count, None);
|
||||
assert!(!attachments[1].extracted_is_ocr);
|
||||
}
|
||||
|
||||
/// Current struct must round-trip through serde_json without data loss.
|
||||
#[test]
|
||||
fn attachment_info_round_trip() {
|
||||
let attachments = vec![
|
||||
AttachmentInfo {
|
||||
file_type: "text/html".into(),
|
||||
inline: false,
|
||||
filename: Some("page.html".into()),
|
||||
size: 42,
|
||||
content_id: None,
|
||||
content_hash: "hash1".into(),
|
||||
is_message: true,
|
||||
extracted_text: Some("hello world".into()),
|
||||
extracted_page_count: Some(1),
|
||||
extracted_is_ocr: false,
|
||||
},
|
||||
AttachmentInfo {
|
||||
file_type: "application/zip".into(),
|
||||
inline: false,
|
||||
filename: Some("archive.zip".into()),
|
||||
size: 99999,
|
||||
content_id: None,
|
||||
content_hash: "hash2".into(),
|
||||
is_message: false,
|
||||
extracted_text: None,
|
||||
extracted_page_count: None,
|
||||
extracted_is_ocr: true,
|
||||
},
|
||||
];
|
||||
|
||||
let json = serde_json::to_string(&attachments).expect("serialize");
|
||||
let round_tripped: Vec<AttachmentInfo> =
|
||||
serde_json::from_str(&json).expect("deserialize");
|
||||
|
||||
assert_eq!(attachments, round_tripped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,13 +86,22 @@ pub fn detach_attachments_standalone(
|
||||
|
||||
for (raw_start, raw_end, att) in ranges {
|
||||
let content_hash = compute_content_hash(att.contents());
|
||||
blobs.push((
|
||||
content_hash.clone(),
|
||||
Bytes::copy_from_slice(&original_body[raw_start..raw_end]),
|
||||
));
|
||||
let body_len = original_body.len();
|
||||
let raw_start = raw_start.min(body_len);
|
||||
let raw_end = raw_end.min(body_len);
|
||||
let range_valid = raw_start < raw_end;
|
||||
|
||||
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
|
||||
stripped_eml.splice(raw_start..raw_end, placeholder.as_bytes().iter().cloned());
|
||||
if range_valid {
|
||||
blobs.push((
|
||||
content_hash.clone(),
|
||||
Bytes::copy_from_slice(&original_body[raw_start..raw_end]),
|
||||
));
|
||||
}
|
||||
|
||||
if range_valid {
|
||||
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
|
||||
stripped_eml.splice(raw_start..raw_end, placeholder.as_bytes().iter().cloned());
|
||||
}
|
||||
|
||||
infos.push(AttachmentInfo {
|
||||
filename: att.attachment_name().map(|n| n.to_string()),
|
||||
@@ -100,7 +109,7 @@ pub fn detach_attachments_standalone(
|
||||
inline: att
|
||||
.content_disposition()
|
||||
.map(|d| d.is_inline())
|
||||
.unwrap_or(false),
|
||||
.unwrap_or_else(|| att.content_id().is_some()),
|
||||
file_type: att
|
||||
.content_type()
|
||||
.map(|ct| {
|
||||
@@ -114,6 +123,9 @@ pub fn detach_attachments_standalone(
|
||||
content_id: att.content_id().map(|id| id.to_string()),
|
||||
content_hash,
|
||||
is_message: att.is_message(),
|
||||
extracted_text: None,
|
||||
extracted_page_count: None,
|
||||
extracted_is_ocr: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -149,9 +149,18 @@ impl BlobManager {
|
||||
res = receiver.recv() => {
|
||||
match res {
|
||||
Some(eml) => {
|
||||
Self::process_detached_email(eml, &email_ks, &attach_ks);
|
||||
let mut batch = vec![eml];
|
||||
while let Ok(next_eml) = receiver.try_recv() {
|
||||
Self::process_detached_email(next_eml, &email_ks, &attach_ks);
|
||||
batch.push(next_eml);
|
||||
}
|
||||
let email_ks = email_ks.clone();
|
||||
let attach_ks = attach_ks.clone();
|
||||
if let Err(e) = tokio::task::spawn_blocking(move || {
|
||||
for eml in batch {
|
||||
Self::process_detached_email(eml, &email_ks, &attach_ks);
|
||||
}
|
||||
}).await {
|
||||
tracing::error!("BlobManager: spawn_blocking join error: {:#?}", e);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -162,16 +171,25 @@ impl BlobManager {
|
||||
}
|
||||
_ = shutdown.recv() => {
|
||||
receiver.close();
|
||||
let remaining = receiver.len();
|
||||
let mut remaining = Vec::new();
|
||||
while let Some(eml) = receiver.recv().await {
|
||||
remaining.push(eml);
|
||||
}
|
||||
tracing::info!(
|
||||
"BlobManager: Shutdown signal received. Processing {} remaining tasks...",
|
||||
remaining
|
||||
remaining.len()
|
||||
);
|
||||
|
||||
while let Some(eml) = receiver.recv().await {
|
||||
Self::process_detached_email(eml, &email_ks, &attach_ks);
|
||||
if !remaining.is_empty() {
|
||||
let email_ks = email_ks.clone();
|
||||
let attach_ks = attach_ks.clone();
|
||||
if let Err(e) = tokio::task::spawn_blocking(move || {
|
||||
for eml in remaining {
|
||||
Self::process_detached_email(eml, &email_ks, &attach_ks);
|
||||
}
|
||||
}).await {
|
||||
tracing::error!("BlobManager: shutdown spawn_blocking join error: {:#?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("BlobManager: All remaining tasks processed. Closing Fjall.");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -154,7 +154,11 @@ impl IndexManager {
|
||||
"Tantivy: Reached threshold ({} docs), committing...",
|
||||
pending_count
|
||||
);
|
||||
fatal_commit(&mut writer);
|
||||
tokio::task::block_in_place(|| fatal_commit(&mut writer));
|
||||
tracing::debug!(
|
||||
"Tantivy attach: committed {} docs, pending reset to 0",
|
||||
pending_count
|
||||
);
|
||||
pending_count = 0;
|
||||
commit_interval.reset();
|
||||
}
|
||||
@@ -163,7 +167,7 @@ impl IndexManager {
|
||||
tracing::info!("Tantivy: Receiver closed. Finalizing...");
|
||||
if pending_count > 0 {
|
||||
let mut writer = writer.lock().await;
|
||||
fatal_commit(&mut writer);
|
||||
tokio::task::block_in_place(|| fatal_commit(&mut writer));
|
||||
}
|
||||
break;
|
||||
},
|
||||
@@ -172,16 +176,19 @@ impl IndexManager {
|
||||
_ = commit_interval.tick() => {
|
||||
if pending_count > 0 {
|
||||
let mut writer = writer.lock().await;
|
||||
fatal_commit(&mut writer);
|
||||
tracing::debug!(
|
||||
"Tantivy attach: periodic commit ({} docs pending)",
|
||||
pending_count
|
||||
);
|
||||
tokio::task::block_in_place(|| fatal_commit(&mut writer));
|
||||
pending_count = 0;
|
||||
tracing::debug!("Tantivy: Periodic commit finished.");
|
||||
}
|
||||
}
|
||||
_ = shutdown.recv() => {
|
||||
tracing::info!("Tantivy: Shutdown signal received. Performing final commit...");
|
||||
if pending_count > 0 {
|
||||
let mut writer = writer.lock().await;
|
||||
fatal_commit(&mut writer);
|
||||
tokio::task::block_in_place(|| fatal_commit(&mut writer));
|
||||
}
|
||||
tracing::info!("Tantivy: Shutdown cleanup complete.");
|
||||
break;
|
||||
|
||||
@@ -255,11 +255,22 @@ fn dedup_account(
|
||||
// uidvalidity, which is required for correct incremental sync.
|
||||
entries.sort_by_key(|e| std::cmp::Reverse(e.ingest_at));
|
||||
|
||||
eprintln!(
|
||||
"DEBUG Phase2: key={_key:?} kept={} deleting={}",
|
||||
entries[0].email_id,
|
||||
tracing::debug!(
|
||||
"dedup: account={} mailbox={} hash={}: {} copies, keeping eid={} ingest_at={}, deleting {}",
|
||||
account_id,
|
||||
_key.0,
|
||||
&_key.1,
|
||||
entries.len(),
|
||||
&entries[0].email_id,
|
||||
entries[0].ingest_at,
|
||||
entries.len() - 1
|
||||
);
|
||||
|
||||
// eprintln!(
|
||||
// "DEBUG Phase2: key={_key:?} kept={} deleting={}",
|
||||
// entries[0].email_id,
|
||||
// entries.len() - 1
|
||||
// );
|
||||
// Keep entries[0], soft-delete everything else via term query on f_id
|
||||
for entry in &entries[1..] {
|
||||
eprintln!(
|
||||
|
||||
@@ -169,7 +169,11 @@ impl IndexManager {
|
||||
"Tantivy: Reached threshold ({} docs), committing...",
|
||||
pending_count
|
||||
);
|
||||
fatal_commit(&mut writer);
|
||||
tokio::task::block_in_place(|| fatal_commit(&mut writer));
|
||||
tracing::debug!(
|
||||
"Tantivy: committed {} docs, pending reset to 0",
|
||||
pending_count
|
||||
);
|
||||
pending_count = 0;
|
||||
commit_interval.reset();
|
||||
}
|
||||
@@ -178,7 +182,7 @@ impl IndexManager {
|
||||
tracing::info!("Tantivy: Receiver closed. Finalizing...");
|
||||
if pending_count > 0 {
|
||||
let mut writer = writer.lock().await;
|
||||
fatal_commit(&mut writer);
|
||||
tokio::task::block_in_place(|| fatal_commit(&mut writer));
|
||||
}
|
||||
break;
|
||||
},
|
||||
@@ -187,16 +191,19 @@ impl IndexManager {
|
||||
_ = commit_interval.tick() => {
|
||||
if pending_count > 0 {
|
||||
let mut writer = writer.lock().await;
|
||||
fatal_commit(&mut writer);
|
||||
tracing::debug!(
|
||||
"Tantivy: periodic commit ({} docs pending)",
|
||||
pending_count
|
||||
);
|
||||
tokio::task::block_in_place(|| fatal_commit(&mut writer));
|
||||
pending_count = 0;
|
||||
tracing::debug!("Tantivy: Periodic commit finished.");
|
||||
}
|
||||
}
|
||||
_ = shutdown.recv() => {
|
||||
tracing::info!("Tantivy: Shutdown signal received. Performing final commit...");
|
||||
if pending_count > 0 {
|
||||
let mut writer = writer.lock().await;
|
||||
fatal_commit(&mut writer);
|
||||
tokio::task::block_in_place(|| fatal_commit(&mut writer));
|
||||
}
|
||||
tracing::info!("Tantivy: Shutdown cleanup complete.");
|
||||
break;
|
||||
@@ -690,7 +697,15 @@ impl IndexManager {
|
||||
let agg_res = searcher
|
||||
.search(query.as_ref(), &collector)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(Self::extract_max_uid(&agg_res))
|
||||
let result = Self::extract_max_uid(&agg_res);
|
||||
tracing::debug!(
|
||||
"[account {}][mailbox {}] get_max_uid = {:?} (num_docs in searcher = {})",
|
||||
account_id,
|
||||
mailbox_id,
|
||||
result,
|
||||
searcher.num_docs()
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn get_account_stats(&self, account_id: u64) -> BichonResult<AccountStats> {
|
||||
|
||||
@@ -3,6 +3,10 @@ name = "bichon-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["embed-web"]
|
||||
embed-web = ["dep:rust-embed"]
|
||||
|
||||
|
||||
[dependencies]
|
||||
bichon-core = { path = "../core", features = ["web-api"] }
|
||||
@@ -17,7 +21,7 @@ poem-openapi = { version = "5.1.16", features = [
|
||||
"swagger-ui",
|
||||
"email",
|
||||
] }
|
||||
rust-embed = "8.11.0"
|
||||
rust-embed = { version = "8.11.0", optional = true }
|
||||
email_address.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -29,7 +33,6 @@ tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
http.workspace = true
|
||||
urlencoding.workspace = true
|
||||
mimalloc.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
poem = { version = "3.1.12", features = ["test"] }
|
||||
|
||||
135
crates/server/src/lib.rs
Normal file
135
crates/server/src/lib.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 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/>.
|
||||
|
||||
|
||||
pub mod common;
|
||||
pub mod error;
|
||||
pub mod rest;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use bichon_core::{
|
||||
bichon_version,
|
||||
cache::imap::task::SYNC_TASKS,
|
||||
common::{rustls::BichonTls, signal::SignalManager},
|
||||
context::{executors::BichonContext, Initialize},
|
||||
database::manager::DB_MANAGER,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
logger,
|
||||
migrate::check_data_status,
|
||||
raise_error,
|
||||
settings::{cli::SETTINGS, dir::DataDirManager},
|
||||
store::{
|
||||
blob::BLOB_MANAGER,
|
||||
tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
|
||||
},
|
||||
tasks::PeriodicTasks,
|
||||
users::manager::UserManager,
|
||||
};
|
||||
use bichon_smtp::server::{start_smtp_server, SmtpServer};
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run() -> BichonResult<()> {
|
||||
logger::initialize_logging();
|
||||
info!(
|
||||
r#"
|
||||
_ _ _
|
||||
| | (_) | |
|
||||
| |__ _ ___ | |__ ___ _ __
|
||||
| '_ \ | | / __|| '_ \ / _ \ | '_ \
|
||||
| |_) || || (__ | | | || (_) || | | |
|
||||
|_.__/ |_| \___||_| |_| \___/ |_| |_|
|
||||
|
||||
"#
|
||||
);
|
||||
info!("Starting bichon-server");
|
||||
info!("Version: {}", bichon_version!());
|
||||
info!("Git: [{}]", env!("GIT_HASH"));
|
||||
info!("GitHub: https://github.com/rustmailer/bichon");
|
||||
|
||||
match check_data_status() {
|
||||
Ok(false) => {
|
||||
error!("Incompatible data format detected.");
|
||||
error!("Your data was created by an older version of Bichon and must be migrated before use.");
|
||||
error!("Please stop the Bichon v0.3.7 service before migration.");
|
||||
error!("Please run: bichon-admin");
|
||||
error!("Documentation: https://github.com/rustmailer/bichon/wiki/Bichon-Data-Migration:-v0.3.7-%E2%86%92-v1.0");
|
||||
return Err(raise_error!(
|
||||
"Legacy data layout detected".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to check data layout: {:#?}", e);
|
||||
return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError));
|
||||
}
|
||||
Ok(true) => {}
|
||||
}
|
||||
|
||||
if let Err(error) = initialize().await {
|
||||
eprintln!("{:?}", error);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let periodic_tasks = PeriodicTasks::setup();
|
||||
let mut smtp_service: Option<SmtpServer> = None;
|
||||
if SETTINGS.bichon_enable_smtp {
|
||||
info!("SMTP service is enabled, starting...");
|
||||
match start_smtp_server().await {
|
||||
Ok(server) => {
|
||||
info!("SMTP server listening on: {}", server.smtp_addr);
|
||||
smtp_service = Some(server);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to start SMTP server: {}", e);
|
||||
return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("SMTP service is disabled by configuration.");
|
||||
}
|
||||
|
||||
rest::start_http_server().await?;
|
||||
periodic_tasks.shutdown().await;
|
||||
|
||||
if let Some(server) = smtp_service {
|
||||
info!("Shutting down SMTP server...");
|
||||
server.stop().await;
|
||||
info!("SMTP server stopped.");
|
||||
}
|
||||
|
||||
SYNC_TASKS.shutdown().await;
|
||||
ENVELOPE_MANAGER.shutdown().await;
|
||||
ATTACHMENT_MANAGER.shutdown().await;
|
||||
BLOB_MANAGER.shutdown().await;
|
||||
DB_MANAGER.flush();
|
||||
info!("Bichon server stopped.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn initialize() -> BichonResult<()> {
|
||||
SignalManager::initialize().await?;
|
||||
DataDirManager::initialize().await?;
|
||||
UserManager::initialize().await?;
|
||||
BichonTls::initialize().await?;
|
||||
BichonContext::initialize().await?;
|
||||
LazyLock::force(&BLOB_MANAGER);
|
||||
LazyLock::force(&ENVELOPE_MANAGER);
|
||||
LazyLock::force(&ATTACHMENT_MANAGER);
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,205 +16,9 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
use bichon_core::error::BichonResult;
|
||||
|
||||
use bichon_core::{
|
||||
bichon_version,
|
||||
cache::imap::task::SYNC_TASKS,
|
||||
common::rustls::BichonTls,
|
||||
context::{executors::BichonContext, Initialize},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
logger,
|
||||
migrate::check_data_status,
|
||||
raise_error,
|
||||
settings::cli::SETTINGS,
|
||||
store::{
|
||||
blob::BLOB_MANAGER,
|
||||
tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
|
||||
},
|
||||
tasks::PeriodicTasks,
|
||||
};
|
||||
use bichon_smtp::server::{start_smtp_server, SmtpServer};
|
||||
use mimalloc::MiMalloc;
|
||||
use tracing::{error, info};
|
||||
|
||||
use bichon_core::{
|
||||
common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager,
|
||||
};
|
||||
|
||||
use crate::rest::start_http_server;
|
||||
|
||||
pub mod common;
|
||||
pub mod error;
|
||||
pub mod rest;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
static LOGO: &str = r#"
|
||||
_ _ _
|
||||
| | (_) | |
|
||||
| |__ _ ___ | |__ ___ _ __
|
||||
| '_ \ | | / __|| '_ \ / _ \ | '_ \
|
||||
| |_) || || (__ | | | || (_) || | | |
|
||||
|_.__/ |_| \___||_| |_| \___/ |_| |_|
|
||||
|
||||
"#;
|
||||
#[tokio::main]
|
||||
async fn main() -> BichonResult<()> {
|
||||
logger::initialize_logging();
|
||||
info!("{}", LOGO);
|
||||
info!("Starting bichon-server");
|
||||
info!("Version: {}", bichon_version!());
|
||||
info!("Git: [{}]", env!("GIT_HASH"));
|
||||
info!("GitHub: https://github.com/rustmailer/bichon");
|
||||
|
||||
match check_data_status() {
|
||||
Ok(false) => {
|
||||
error!("Incompatible data format detected.");
|
||||
error!("Your data was created by an older version of Bichon and must be migrated before use.");
|
||||
error!("Please stop the Bichon v0.3.7 service before migration.");
|
||||
error!("Please run: bichon-admin");
|
||||
error!("Documentation: https://github.com/rustmailer/bichon/wiki/Bichon-Data-Migration:-v0.3.7-%E2%86%92-v1.0");
|
||||
return Err(raise_error!(
|
||||
"Legacy data layout detected".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to check data layout: {:#?}", e);
|
||||
return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError));
|
||||
}
|
||||
Ok(true) => {}
|
||||
}
|
||||
|
||||
if let Err(error) = initialize().await {
|
||||
eprintln!("{:?}", error);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let periodic_tasks = PeriodicTasks::setup();
|
||||
let mut smtp_service: Option<SmtpServer> = None;
|
||||
if SETTINGS.bichon_enable_smtp {
|
||||
info!("SMTP service is enabled, starting...");
|
||||
match start_smtp_server().await {
|
||||
Ok(server) => {
|
||||
info!("SMTP server listening on: {}", server.smtp_addr);
|
||||
smtp_service = Some(server);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to start SMTP server: {}", e);
|
||||
return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("SMTP service is disabled by configuration.");
|
||||
}
|
||||
|
||||
start_http_server().await?;
|
||||
periodic_tasks.shutdown().await;
|
||||
|
||||
if let Some(server) = smtp_service {
|
||||
info!("Shutting down SMTP server...");
|
||||
server.stop().await;
|
||||
info!("SMTP server stopped.");
|
||||
}
|
||||
|
||||
SYNC_TASKS.shutdown().await;
|
||||
ENVELOPE_MANAGER.shutdown().await;
|
||||
ATTACHMENT_MANAGER.shutdown().await;
|
||||
BLOB_MANAGER.shutdown().await;
|
||||
info!("Bichon server stopped.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize the system by validating settings and starting necessary tasks.
|
||||
async fn initialize() -> BichonResult<()> {
|
||||
SignalManager::initialize().await?;
|
||||
DataDirManager::initialize().await?;
|
||||
UserManager::initialize().await?;
|
||||
BichonTls::initialize().await?;
|
||||
BichonContext::initialize().await?;
|
||||
LazyLock::force(&BLOB_MANAGER);
|
||||
LazyLock::force(&ENVELOPE_MANAGER);
|
||||
LazyLock::force(&ATTACHMENT_MANAGER);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod api_tests {
|
||||
use super::rest::api::create_openapi_service;
|
||||
use poem::test::TestClient;
|
||||
|
||||
#[tokio::test]
|
||||
async fn openapi_spec_json_is_served() {
|
||||
let api_service = create_openapi_service();
|
||||
let spec_endpoint = api_service.spec_endpoint();
|
||||
let cli = TestClient::new(spec_endpoint);
|
||||
|
||||
let resp = cli.get("/").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
|
||||
let body = resp.json().await;
|
||||
let obj = body.value().object();
|
||||
assert!(obj.get_opt("openapi").is_some(), "missing openapi version");
|
||||
assert!(obj.get_opt("info").is_some(), "missing info section");
|
||||
assert!(obj.get_opt("paths").is_some(), "missing paths section");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openapi_spec_yaml_is_served() {
|
||||
let api_service = create_openapi_service();
|
||||
let spec_endpoint = api_service.spec_endpoint_yaml();
|
||||
let cli = TestClient::new(spec_endpoint);
|
||||
|
||||
let resp = cli.get("/").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn swagger_ui_is_served() {
|
||||
let api_service = create_openapi_service();
|
||||
let swagger = api_service.swagger_ui();
|
||||
let cli = TestClient::new(swagger);
|
||||
|
||||
let resp = cli.get("/").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openapi_spec_lists_all_tag_groups() {
|
||||
let api_service = create_openapi_service();
|
||||
let spec_endpoint = api_service.spec_endpoint();
|
||||
let cli = TestClient::new(spec_endpoint);
|
||||
|
||||
let resp = cli.get("/").send().await;
|
||||
let body = resp.json().await;
|
||||
let value = body.value();
|
||||
|
||||
let tag_names: Vec<&str> = value
|
||||
.object()
|
||||
.get("tags")
|
||||
.array()
|
||||
.iter()
|
||||
.map(|v| v.object().get("name").string())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
tag_names.contains(&"AccessToken"),
|
||||
"missing AccessToken tag"
|
||||
);
|
||||
assert!(tag_names.contains(&"Attachment"), "missing Attachment tag");
|
||||
assert!(tag_names.contains(&"AutoConfig"), "missing AutoConfig tag");
|
||||
assert!(tag_names.contains(&"Account"), "missing Account tag");
|
||||
assert!(tag_names.contains(&"System"), "missing System tag");
|
||||
assert!(tag_names.contains(&"Mailbox"), "missing Mailbox tag");
|
||||
assert!(tag_names.contains(&"OAuth2"), "missing OAuth2 tag");
|
||||
assert!(tag_names.contains(&"Message"), "missing Message tag");
|
||||
assert!(tag_names.contains(&"Import"), "missing Import tag");
|
||||
assert!(tag_names.contains(&"Users"), "missing Users tag");
|
||||
}
|
||||
bichon_server::run().await
|
||||
}
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 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/>.
|
||||
#[cfg(feature = "embed-web")]
|
||||
mod inner {
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../web/dist/"]
|
||||
pub struct FrontEndAssets;
|
||||
}
|
||||
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../web/dist/"]
|
||||
pub struct FrontEndAssets;
|
||||
#[cfg(feature = "embed-web")]
|
||||
pub use inner::FrontEndAssets;
|
||||
|
||||
@@ -16,51 +16,49 @@
|
||||
// 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::common::auth::ApiGuard;
|
||||
use crate::common::error::ErrorCapture;
|
||||
use crate::common::log::Tracing;
|
||||
use crate::common::tls::rustls_config;
|
||||
use crate::common::timeout::{Timeout, TIMEOUT_HEADER};
|
||||
use crate::error::handler::error_handler;
|
||||
use crate::rest::public::features::get_features;
|
||||
use crate::rest::public::login::login;
|
||||
use crate::rest::public::status::get_status;
|
||||
use bichon_core::common::signal::SIGNAL_MANAGER;
|
||||
use bichon_core::error::code::ErrorCode;
|
||||
use bichon_core::error::BichonResult;
|
||||
use bichon_core::raise_error;
|
||||
use bichon_core::settings::cli::SETTINGS;
|
||||
|
||||
use super::error::ApiErrorResponse;
|
||||
use crate::common::auth::ApiGuard;
|
||||
use crate::common::timeout::{Timeout, TIMEOUT_HEADER};
|
||||
use api::create_openapi_service;
|
||||
use assets::FrontEndAssets;
|
||||
use bichon_core::raise_error;
|
||||
use http::{HeaderValue, Method};
|
||||
use poem::endpoint::EmbeddedFilesEndpoint;
|
||||
use http::Method;
|
||||
use poem::listener::{Listener, TcpListener};
|
||||
use poem::middleware::{CatchPanic, Compression, SetHeader};
|
||||
use poem::{get, handler, post, IntoResponse};
|
||||
use poem::{middleware::Cors, EndpointExt, Route, Server};
|
||||
use poem::middleware::{CatchPanic, Compression, Cors};
|
||||
use poem::{get, post, Endpoint, EndpointExt, Route, Server};
|
||||
use public::oauth2::oauth2_callback;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(feature = "embed-web")]
|
||||
use {
|
||||
assets::FrontEndAssets,
|
||||
http::HeaderValue,
|
||||
poem::{handler, endpoint::EmbeddedFilesEndpoint, IntoResponse},
|
||||
poem::middleware::SetHeader,
|
||||
};
|
||||
|
||||
pub mod api;
|
||||
pub mod assets;
|
||||
pub mod public;
|
||||
|
||||
pub type ApiResult<T, E = ApiErrorResponse> = std::result::Result<T, E>;
|
||||
|
||||
pub async fn start_http_server() -> BichonResult<()> {
|
||||
let listener = TcpListener::bind((
|
||||
SETTINGS.bichon_bind_ip.clone().unwrap_or("0.0.0.0".into()),
|
||||
SETTINGS.bichon_http_port as u16,
|
||||
));
|
||||
|
||||
let listener = if SETTINGS.bichon_enable_rest_https {
|
||||
listener.rustls(rustls_config()?).boxed()
|
||||
} else {
|
||||
listener.boxed()
|
||||
};
|
||||
use super::error::ApiErrorResponse;
|
||||
|
||||
/// Build the community route tree. Pro/Enterprise servers can call this
|
||||
/// and then add their own routes before passing the tree to the server.
|
||||
pub fn build_routes() -> impl Endpoint {
|
||||
let api_service = create_openapi_service()
|
||||
.summary("A lightweight, high-performance Rust email archiver with WebUI");
|
||||
|
||||
@@ -79,7 +77,6 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
.with(Tracing);
|
||||
|
||||
let cors_origins: Option<HashSet<String>> = SETTINGS.bichon_cors_origins.clone();
|
||||
|
||||
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
|
||||
|
||||
let cors = Cors::new()
|
||||
@@ -92,7 +89,6 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
}
|
||||
cors_origins.iter().any(|o| o == origin)
|
||||
})
|
||||
//.allow_origins(cors_origins)
|
||||
.allow_credentials(true)
|
||||
.allow_methods(&[
|
||||
Method::GET,
|
||||
@@ -107,13 +103,6 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
.expose_headers(vec!["Accept"])
|
||||
.max_age(SETTINGS.bichon_cors_max_age);
|
||||
|
||||
let cache_static = || {
|
||||
SetHeader::new().overriding(
|
||||
http::header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("max-age=86400"),
|
||||
)
|
||||
};
|
||||
|
||||
let app_logic = Route::new()
|
||||
.nest("/api-docs/swagger", swagger)
|
||||
.nest("/api-docs/redoc", redoc)
|
||||
@@ -122,42 +111,43 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
.nest("/api-docs/spec.json", spec_json)
|
||||
.nest("/api-docs/spec.yaml", spec_yaml)
|
||||
.nest("/oauth2/callback", get(oauth2_callback))
|
||||
.nest("/api/v1/features", get(get_features))
|
||||
.nest("/api/status", get(get_status))
|
||||
.nest("/api/login", post(login))
|
||||
.nest_no_strip("/api/v1", open_api_route)
|
||||
.nest_no_strip("/api/v1", open_api_route);
|
||||
|
||||
let app_logic = add_web_assets(app_logic);
|
||||
|
||||
Route::new()
|
||||
.nest(&SETTINGS.bichon_base_url, app_logic)
|
||||
.with(cors)
|
||||
.with_if(SETTINGS.bichon_http_compression_enabled, Compression::new())
|
||||
.with(CatchPanic::new())
|
||||
}
|
||||
|
||||
#[cfg(feature = "embed-web")]
|
||||
fn add_web_assets(route: Route) -> impl Endpoint {
|
||||
let cache_static = || {
|
||||
SetHeader::new().overriding(
|
||||
http::header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("max-age=86400"),
|
||||
)
|
||||
};
|
||||
|
||||
route
|
||||
.nest_no_strip(
|
||||
"/assets",
|
||||
EmbeddedFilesEndpoint::<FrontEndAssets>::new().with(cache_static()),
|
||||
)
|
||||
.at("/*", serve_index_with_base);
|
||||
|
||||
let route = Route::new()
|
||||
.nest(&SETTINGS.bichon_base_url, app_logic)
|
||||
.with(cors)
|
||||
.with_if(SETTINGS.bichon_http_compression_enabled, Compression::new())
|
||||
.with(CatchPanic::new());
|
||||
|
||||
let mut rx = SIGNAL_MANAGER.subscribe();
|
||||
let shutdown_fut = async move {
|
||||
let _ = rx.recv().await;
|
||||
};
|
||||
let server = Server::new(listener)
|
||||
.name("Bichon Service")
|
||||
.idle_timeout(Duration::from_secs(60))
|
||||
.run_with_graceful_shutdown(
|
||||
route.catch_all_error(error_handler),
|
||||
shutdown_fut,
|
||||
Some(Duration::from_secs(5)),
|
||||
);
|
||||
println!(
|
||||
"Bichon Service is now running on port {}.",
|
||||
SETTINGS.bichon_http_port
|
||||
);
|
||||
server
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||
.at("/*", serve_index_with_base)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "embed-web"))]
|
||||
fn add_web_assets(route: Route) -> Route {
|
||||
route
|
||||
}
|
||||
|
||||
#[cfg(feature = "embed-web")]
|
||||
#[handler]
|
||||
async fn serve_index_with_base() -> impl IntoResponse {
|
||||
let mut html =
|
||||
@@ -180,3 +170,38 @@ async fn serve_index_with_base() -> impl IntoResponse {
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(html)
|
||||
}
|
||||
|
||||
pub async fn start_http_server() -> BichonResult<()> {
|
||||
let listener = TcpListener::bind((
|
||||
SETTINGS.bichon_bind_ip.clone().unwrap_or("0.0.0.0".into()),
|
||||
SETTINGS.bichon_http_port as u16,
|
||||
));
|
||||
|
||||
let listener = if SETTINGS.bichon_enable_rest_https {
|
||||
listener.rustls(rustls_config()?).boxed()
|
||||
} else {
|
||||
listener.boxed()
|
||||
};
|
||||
|
||||
let route = build_routes();
|
||||
|
||||
let mut rx = SIGNAL_MANAGER.subscribe();
|
||||
let shutdown_fut = async move {
|
||||
let _ = rx.recv().await;
|
||||
};
|
||||
let server = Server::new(listener)
|
||||
.name("Bichon Service")
|
||||
.idle_timeout(Duration::from_secs(60))
|
||||
.run_with_graceful_shutdown(
|
||||
route.catch_all_error(error_handler),
|
||||
shutdown_fut,
|
||||
Some(Duration::from_secs(5)),
|
||||
);
|
||||
println!(
|
||||
"Bichon Service is now running on port {}.",
|
||||
SETTINGS.bichon_http_port
|
||||
);
|
||||
server
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||
}
|
||||
|
||||
36
crates/server/src/rest/public/features.rs
Normal file
36
crates/server/src/rest/public/features.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 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 poem::{handler, web::Json, IntoResponse};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FeaturesResponse {
|
||||
features: Vec<String>,
|
||||
edition: &'static str,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[handler]
|
||||
pub async fn get_features() -> impl IntoResponse {
|
||||
Json(FeaturesResponse {
|
||||
features: vec![],
|
||||
edition: "community",
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
})
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
pub mod features;
|
||||
pub mod login;
|
||||
pub mod oauth2;
|
||||
pub mod status;
|
||||
|
||||
@@ -628,6 +628,7 @@ async fn parse_email(data: &[u8], session: &Session) -> BichonResult<()> {
|
||||
unseen: None,
|
||||
uid_next: None,
|
||||
uid_validity: None,
|
||||
highest_uid: None,
|
||||
};
|
||||
let mailbox_id = mailbox.id;
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ export interface AccountModel {
|
||||
capabilities?: string[];
|
||||
date_since?: DateSelection;
|
||||
date_before?: RelativeDate;
|
||||
folder_limit?: number,
|
||||
download_folders: string[];
|
||||
download_interval_min?: number;
|
||||
download_batch_size?: number;
|
||||
@@ -143,6 +142,7 @@ export interface AccountModel {
|
||||
imap_quota_window?: QuotaWindow;
|
||||
imap_quota_bytes?: number;
|
||||
auto_download_new_mailboxes?: boolean;
|
||||
download_schedule?: string;
|
||||
}
|
||||
|
||||
export const download_state = async (account_id: number) => {
|
||||
|
||||
@@ -213,37 +213,6 @@ describe('Account Form Schema', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder_limit field', () => {
|
||||
it('accepts undefined folder_limit', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts null folder_limit', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: null,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects folder_limit less than 100', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: 50,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts folder_limit of exactly 100', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: 100,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('account_name and login_name fields', () => {
|
||||
it('accepts undefined account_name and login_name', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
@@ -266,6 +235,45 @@ describe('Account Form Schema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('download_schedule field', () => {
|
||||
it('accepts undefined download_schedule', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid 6-field cron expression', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_schedule: '0 0 0 * * *',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts cron with */step syntax', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_schedule: '0 */30 8-17 * * 1-5',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects cron with too few fields', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_schedule: '0 0 *',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts empty string cron (treated as not set)', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_schedule: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auth Config Schema (password validation)', () => {
|
||||
|
||||
@@ -133,8 +133,8 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.folderLimit')}:</span>
|
||||
<span>{currentRow.folder_limit ? currentRow.folder_limit : t('accounts.notAvailable')}</span>
|
||||
<span className="text-muted-foreground">{t('accounts.downloadSchedule')}:</span>
|
||||
<span>{currentRow.download_schedule || t('accounts.notAvailable')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -49,7 +49,7 @@ export type Steps = [...Step[]];
|
||||
const getSteps = (t: (key: string) => string): Steps => [
|
||||
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email", "account_name"] },
|
||||
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous", "login_name"] },
|
||||
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "date_before", "folder_limit", "download_interval_min", "download_batch_size", "auto_download_new_mailboxes"] },
|
||||
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "date_before", "download_interval_min", "download_batch_size", "auto_download_new_mailboxes", "download_schedule"] },
|
||||
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
|
||||
];
|
||||
|
||||
@@ -79,10 +79,10 @@ const defaultValues: Account = {
|
||||
use_dangerous: false,
|
||||
date_since: undefined,
|
||||
date_before: undefined,
|
||||
folder_limit: undefined,
|
||||
download_interval_min: 60,
|
||||
download_batch_size: 30,
|
||||
auto_download_new_mailboxes: true,
|
||||
download_schedule: undefined,
|
||||
};
|
||||
|
||||
const emptyImap: ImapConfig = {
|
||||
@@ -109,10 +109,10 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
|
||||
use_dangerous: currentRow.use_dangerous,
|
||||
date_since: currentRow.date_since ?? undefined,
|
||||
date_before: currentRow.date_before ?? undefined,
|
||||
folder_limit: currentRow.folder_limit ?? undefined,
|
||||
download_interval_min: currentRow.download_interval_min ?? 60,
|
||||
download_batch_size: currentRow.download_batch_size ?? 30,
|
||||
auto_download_new_mailboxes: currentRow.auto_download_new_mailboxes ?? true,
|
||||
download_schedule: currentRow.download_schedule ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -191,18 +191,18 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
use_dangerous: data.use_dangerous,
|
||||
date_since: data.date_since,
|
||||
date_before: data.date_before,
|
||||
folder_limit: data.folder_limit,
|
||||
download_interval_min: data.download_interval_min,
|
||||
download_batch_size: data.download_batch_size,
|
||||
auto_download_new_mailboxes: data.auto_download_new_mailboxes,
|
||||
download_schedule: data.download_schedule || null,
|
||||
};
|
||||
if (isEdit) {
|
||||
const isAllMode = !data.date_since && !data.date_before;
|
||||
const clear_folder_limit = !data.folder_limit;
|
||||
const clear_download_schedule = !data.download_schedule && currentRow?.download_schedule;
|
||||
updateMutation.mutate({
|
||||
...commonData,
|
||||
...(isAllMode ? { clear_date_range: true } : {}),
|
||||
...(clear_folder_limit ? { clear_folder_limit: true } : {})
|
||||
...(clear_download_schedule ? { clear_download_schedule: true } : {})
|
||||
});
|
||||
} else {
|
||||
createMutation.mutate({ ...commonData, account_type: "IMAP" });
|
||||
|
||||
@@ -106,9 +106,12 @@ export function useColumns(): ColumnDef<AccountModel>[] {
|
||||
if (account_type === "NoSync") {
|
||||
return <LongText className="text-center">n/a</LongText>
|
||||
}
|
||||
if (row.original.download_schedule) {
|
||||
return <LongText className="text-center">{row.original.download_schedule}</LongText>
|
||||
}
|
||||
return <LongText className="text-center">{row.original.download_interval_min} min</LongText>
|
||||
},
|
||||
meta: { className: 'text-center max-w-[120px]' },
|
||||
meta: { className: 'text-center max-w-[160px]' },
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -98,26 +98,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[220px]'>
|
||||
|
||||
{showDownload && (
|
||||
<DropdownMenuItem onClick={handleStartDownload}>
|
||||
{t('accounts.startDownload')}
|
||||
<DropdownMenuShortcut>
|
||||
<IconPlayerPlay size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
|
||||
{showDownload && (
|
||||
<DropdownMenuItem onClick={handleCancelDownload}>
|
||||
{t('accounts.cancelDownload')}
|
||||
<DropdownMenuShortcut>
|
||||
<IconPlayerStop size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showDownload && <DropdownMenuSeparator />}
|
||||
{hasPermission && <DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
@@ -169,6 +149,27 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>}
|
||||
{hasPermission && <DropdownMenuSeparator />}
|
||||
|
||||
{showDownload && (
|
||||
<DropdownMenuItem onClick={handleStartDownload}>
|
||||
{t('accounts.startDownload')}
|
||||
<DropdownMenuShortcut>
|
||||
<IconPlayerPlay size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
|
||||
{showDownload && (
|
||||
<DropdownMenuItem onClick={handleCancelDownload}>
|
||||
{t('accounts.cancelDownload')}
|
||||
<DropdownMenuShortcut>
|
||||
<IconPlayerStop size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showDownload && <DropdownMenuSeparator />}
|
||||
|
||||
{hasPermission && <DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
|
||||
@@ -79,12 +79,6 @@ export const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
use_dangerous: z.boolean(),
|
||||
date_since: dateSelectionSchema(t).optional(),
|
||||
date_before: relativeDateSchema(t).optional(),
|
||||
folder_limit: z
|
||||
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
|
||||
.int()
|
||||
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
|
||||
.nullable()
|
||||
.optional(),
|
||||
download_interval_min: z
|
||||
.number({
|
||||
invalid_type_error: t('validation.incrementalSyncMustBeNumber'),
|
||||
@@ -107,6 +101,18 @@ export const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
message: t('validation.singleRequestBatchSizeTooLarge'),
|
||||
}),
|
||||
auto_download_new_mailboxes: z.boolean(),
|
||||
download_schedule: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (!val || val.trim() === '') return true;
|
||||
const fields = val.trim().split(/\s+/);
|
||||
if (fields.length < 6) return false;
|
||||
return true;
|
||||
},
|
||||
{ message: t('validation.invalidCronExpression') }
|
||||
),
|
||||
})
|
||||
|
||||
export type AccountFormValues = z.infer<
|
||||
|
||||
@@ -48,6 +48,72 @@ import i18n from "@/i18n";
|
||||
|
||||
|
||||
type SyncMode = 'all' | 'since_fixed' | 'since_relative' | 'before_relative';
|
||||
type ScheduleMode = 'interval' | 'cron';
|
||||
type CronMode = 'simple' | 'advanced';
|
||||
type CronFrequency = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
interface CronSimpleState {
|
||||
frequency: CronFrequency;
|
||||
hour: number;
|
||||
minute: number;
|
||||
dayOfWeek: number;
|
||||
dayOfMonth: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CRON_SIMPLE: CronSimpleState = {
|
||||
frequency: 'daily',
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
dayOfWeek: 1,
|
||||
dayOfMonth: 1,
|
||||
};
|
||||
|
||||
function buildCronFromSimple(s: CronSimpleState): string {
|
||||
switch (s.frequency) {
|
||||
case 'daily':
|
||||
return `0 ${s.minute} ${s.hour} * * *`;
|
||||
case 'weekly':
|
||||
return `0 ${s.minute} ${s.hour} * * ${s.dayOfWeek}`;
|
||||
case 'monthly':
|
||||
return `0 ${s.minute} ${s.hour} ${s.dayOfMonth} * *`;
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseCronToSimple(cron: string): CronSimpleState | null {
|
||||
const fields = cron.trim().split(/\s+/);
|
||||
if (fields.length < 6) return null;
|
||||
|
||||
const sec = fields[0];
|
||||
const min = fields[1];
|
||||
const hour = fields[2];
|
||||
const dom = fields[3];
|
||||
const month = fields[4];
|
||||
const dow = fields[5];
|
||||
|
||||
if (sec !== '0') return null;
|
||||
if (month !== '*') return null;
|
||||
|
||||
const minuteVal = parseInt(min, 10);
|
||||
const hourVal = parseInt(hour, 10);
|
||||
if (isNaN(minuteVal) || isNaN(hourVal)) return null;
|
||||
|
||||
if (dom === '*' && dow === '*') {
|
||||
return { frequency: 'daily', hour: hourVal, minute: minuteVal, dayOfWeek: 1, dayOfMonth: 1 };
|
||||
}
|
||||
if (dom === '*') {
|
||||
const dowVal = parseInt(dow, 10);
|
||||
if (!isNaN(dowVal)) {
|
||||
return { frequency: 'weekly', hour: hourVal, minute: minuteVal, dayOfWeek: dowVal, dayOfMonth: 1 };
|
||||
}
|
||||
}
|
||||
if (dow === '*') {
|
||||
const domVal = parseInt(dom, 10);
|
||||
if (!isNaN(domVal)) {
|
||||
return { frequency: 'monthly', hour: hourVal, minute: minuteVal, dayOfWeek: 1, dayOfMonth: domVal };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Step3() {
|
||||
const { t } = useTranslation();
|
||||
@@ -61,6 +127,31 @@ export default function Step3() {
|
||||
return 'all';
|
||||
});
|
||||
|
||||
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>(() => {
|
||||
if (current.download_schedule) return 'cron';
|
||||
return 'interval';
|
||||
});
|
||||
|
||||
const [cronMode, setCronMode] = useState<CronMode>(() => {
|
||||
if (current.download_schedule && tryParseCronToSimple(current.download_schedule)) {
|
||||
return 'simple';
|
||||
}
|
||||
if (current.download_schedule) return 'advanced';
|
||||
return 'simple';
|
||||
});
|
||||
|
||||
const [cronSimple, setCronSimple] = useState<CronSimpleState>(() => {
|
||||
if (current.download_schedule) {
|
||||
return tryParseCronToSimple(current.download_schedule) ?? DEFAULT_CRON_SIMPLE;
|
||||
}
|
||||
return DEFAULT_CRON_SIMPLE;
|
||||
});
|
||||
|
||||
const updateCronFromSimple = (partial: Partial<CronSimpleState>) => {
|
||||
const next = { ...cronSimple, ...partial };
|
||||
setCronSimple(next);
|
||||
setValue('download_schedule', buildCronFromSimple(next));
|
||||
};
|
||||
|
||||
const handleModeChange = (mode: SyncMode) => {
|
||||
setSyncMode(mode);
|
||||
@@ -77,41 +168,231 @@ export default function Step3() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleModeChange = (mode: ScheduleMode) => {
|
||||
setScheduleMode(mode);
|
||||
if (mode === 'interval') {
|
||||
setValue("download_schedule", undefined);
|
||||
} else {
|
||||
setValue("download_interval_min", 60);
|
||||
if (cronMode === 'simple') {
|
||||
updateCronFromSimple(cronSimple);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={control}
|
||||
name="download_interval_min"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('accounts.downloadInterval')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t('accounts.downloadIntervalPlaceholder')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
<div className="space-y-4">
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">{t('accounts.scheduleMode')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('accounts.scheduleModeDescription')}
|
||||
</FormDescription>
|
||||
<Select value={scheduleMode} onValueChange={(v) => handleScheduleModeChange(v as ScheduleMode)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="interval">{t('accounts.scheduleModeInterval')}</SelectItem>
|
||||
<SelectItem value="cron">{t('accounts.scheduleModeCron')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{scheduleMode === 'interval' ? (
|
||||
<FormField
|
||||
control={control}
|
||||
name="download_interval_min"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('accounts.downloadInterval')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t('accounts.downloadIntervalPlaceholder')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* <FormLabel className="text-sm font-medium">{t('accounts.downloadSchedule')}</FormLabel> */}
|
||||
<div className="flex items-center rounded-md border text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-1 rounded-l-md ${cronMode === 'simple' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => setCronMode('simple')}
|
||||
>
|
||||
{t('accounts.cronSimple')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-1 rounded-r-md ${cronMode === 'advanced' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => setCronMode('advanced')}
|
||||
>
|
||||
{t('accounts.cronAdvanced')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cronMode === 'simple' ? (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<FormItem className="w-[140px]">
|
||||
<FormLabel className="text-xs">{t('accounts.cronFrequency')}</FormLabel>
|
||||
<Select
|
||||
value={cronSimple.frequency}
|
||||
onValueChange={(v) => updateCronFromSimple({ frequency: v as CronFrequency })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">{t('accounts.cronDaily')}</SelectItem>
|
||||
<SelectItem value="weekly">{t('accounts.cronWeekly')}</SelectItem>
|
||||
<SelectItem value="monthly">{t('accounts.cronMonthly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
|
||||
<FormItem className="w-[80px]">
|
||||
<FormLabel className="text-xs">{t('accounts.cronHour')}</FormLabel>
|
||||
<Select
|
||||
value={String(cronSimple.hour)}
|
||||
onValueChange={(v) => updateCronFromSimple({ hour: parseInt(v, 10) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<SelectItem key={i} value={String(i)}>
|
||||
{String(i).padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
|
||||
<span className="text-muted-foreground pb-2">:</span>
|
||||
|
||||
<FormItem className="w-[80px]">
|
||||
<FormLabel className="text-xs">{t('accounts.cronMinute')}</FormLabel>
|
||||
<Select
|
||||
value={String(cronSimple.minute)}
|
||||
onValueChange={(v) => updateCronFromSimple({ minute: parseInt(v, 10) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55].map((m) => (
|
||||
<SelectItem key={m} value={String(m)}>
|
||||
{String(m).padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
|
||||
{cronSimple.frequency === 'weekly' && (
|
||||
<FormItem className="w-[140px]">
|
||||
<FormLabel className="text-xs">{t('accounts.cronDayOfWeek')}</FormLabel>
|
||||
<Select
|
||||
value={String(cronSimple.dayOfWeek)}
|
||||
onValueChange={(v) => updateCronFromSimple({ dayOfWeek: parseInt(v, 10) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">{t('accounts.cronMonday')}</SelectItem>
|
||||
<SelectItem value="2">{t('accounts.cronTuesday')}</SelectItem>
|
||||
<SelectItem value="3">{t('accounts.cronWednesday')}</SelectItem>
|
||||
<SelectItem value="4">{t('accounts.cronThursday')}</SelectItem>
|
||||
<SelectItem value="5">{t('accounts.cronFriday')}</SelectItem>
|
||||
<SelectItem value="6">{t('accounts.cronSaturday')}</SelectItem>
|
||||
<SelectItem value="0">{t('accounts.cronSunday')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
{cronSimple.frequency === 'monthly' && (
|
||||
<FormItem className="w-[90px]">
|
||||
<FormLabel className="text-xs">{t('accounts.cronDayOfMonth')}</FormLabel>
|
||||
<Select
|
||||
value={String(cronSimple.dayOfMonth)}
|
||||
onValueChange={(v) => updateCronFromSimple({ dayOfMonth: parseInt(v, 10) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[200px]">
|
||||
{Array.from({ length: 28 }, (_, i) => i + 1).map((d) => (
|
||||
<SelectItem key={d} value={String(d)}>
|
||||
{d}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground pb-2 font-mono">
|
||||
= {buildCronFromSimple(cronSimple)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FormField
|
||||
control={control}
|
||||
name="download_schedule"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
placeholder={t('accounts.downloadSchedulePlaceholder')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('accounts.downloadScheduleDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cronMode === 'simple' && (
|
||||
<FormDescription>{t('accounts.downloadScheduleDescription')}</FormDescription>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
||||
<span>{t('accounts.cronTimezoneNote')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="download_batch_size"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('accounts.downloadBatchSize')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t('accounts.downloadBatchSizeDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="download_batch_size"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('accounts.downloadBatchSize')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t('accounts.downloadBatchSizeDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
@@ -249,29 +530,6 @@ export default function Step3() {
|
||||
/>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="folder_limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('accounts.folderLimit')}</FormLabel>
|
||||
<FormDescription>{t('accounts.folderLimitDescription')}</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('accounts.folderLimitPlaceholder')}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.onChange(value === '' ? null : Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export default function Step4() {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl">
|
||||
<Accordion type="multiple" defaultValue={['email', 'account_name', 'login_name', 'imap', 'date_since', 'folder_limit', 'sync_interval', 'sync_scope', 'sync_batch_size']}>
|
||||
<Accordion type="multiple" defaultValue={['email', 'account_name', 'login_name', 'imap', 'date_since', 'sync_interval', 'sync_scope', 'sync_batch_size', 'download_schedule']}>
|
||||
<AccordionItem key="email" value="email">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.email')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.email}</AccordionContent>
|
||||
@@ -154,11 +154,6 @@ export default function Step4() {
|
||||
</AccordionItem>
|
||||
|
||||
|
||||
<AccordionItem key="folder_limit" value="folder_limit">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.folderLimit')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.folder_limit ?? t('accounts.notAvailable')}</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem key="sync_interval" value="sync_interval">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.downloadInterval')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.download_interval_min} {t('accounts.minutes')}</AccordionContent>
|
||||
@@ -169,6 +164,11 @@ export default function Step4() {
|
||||
<AccordionContent>{summaryData.download_batch_size}</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem key="download_schedule" value="download_schedule">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.downloadSchedule')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.download_schedule || t('accounts.notAvailable')}</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem key="auto_download_new_mailboxes" value="auto_download_new_mailboxes">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.autoDownloadNewMailboxes')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.auto_download_new_mailboxes ? t('common.yes') : t('common.no')}</AccordionContent>
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "متابعة",
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"creationFailed": "فشل الإنشاء، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"cronAdvanced": "تعبير متقدم",
|
||||
"cronDaily": "يومياً",
|
||||
"cronDayOfMonth": "اليوم من الأسبوع",
|
||||
"cronDayOfWeek": "اليوم من الأسبوع",
|
||||
"cronFrequency": "التكرار",
|
||||
"cronFriday": "الجمعة",
|
||||
"cronHour": "الساعة",
|
||||
"cronMinute": "الدقيقة",
|
||||
"cronMonday": "الإثنين",
|
||||
"cronMonthly": "شهرياً",
|
||||
"cronSaturday": "السبت",
|
||||
"cronSimple": "تعبير بسيط",
|
||||
"cronSunday": "الأحد",
|
||||
"cronThursday": "الخميس",
|
||||
"cronTimezoneNote": "جميع الأوقات بالتوقيت المحلي للخادم.",
|
||||
"cronTuesday": "الثلاثاء",
|
||||
"cronWednesday": "الأربعاء",
|
||||
"cronWeekly": "أسبوعياً",
|
||||
"dateSelection": "تحديد التاريخ",
|
||||
"dateSince": "التاريخ منذ",
|
||||
"days": "أيام",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "فشل بدء مهمة التنزيل",
|
||||
"downloadInterval": "دورة التنزيل (بالدقائق)",
|
||||
"downloadIntervalPlaceholder": "أدخل الدقائق",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "كرون من 6 حقول (ثانية-دقائق-ساعات-يوم-شهر-أسبوع) بتوقيت الخادم. يتجاوز الفاصل الزمني.",
|
||||
"downloadSchedulePlaceholder": "مثال: 0 0 * * *",
|
||||
"downloadScope": "استراتيجية التنزيل",
|
||||
"downloadScopeDescription": "اختر رسائل البريد الإلكتروني التي يجب فهرستها وتنزيلها.",
|
||||
"downloadStarted": "بدأت مهمة التنزيل",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "كل {{minutes}} دقيقة",
|
||||
"field": "الحقل",
|
||||
"fixed": "ثابت",
|
||||
"folderLimit": "حد المجلد",
|
||||
"folderLimitDescription": "حدد عدد رسائل البريد الإلكتروني للمزامنة لكل مجلد (الحد الأدنى 100). اتركه فارغًا بلا حدود.",
|
||||
"folderLimitPlaceholder": "مثال: 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "تحديد العناصر الفرعية تلقائيًا",
|
||||
"autoSelectParents": "تحديد العناصر الأصلية تلقائيًا",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "حفظ التغييرات",
|
||||
"scheduleMode": "جدولة التنزيل",
|
||||
"scheduleModeCron": "تعبير Cron",
|
||||
"scheduleModeDescription": "تعيين التنزيل بفواصل زمنية ثابتة أو عبر Cron.",
|
||||
"scheduleModeInterval": "فواصل زمنية ثابتة",
|
||||
"selectAccountType": "اختر نوع الحساب",
|
||||
"selectAtLeastOneFolder": "يرجى تحديد مجلد واحد على الأقل",
|
||||
"selectAuthMethod": "اختر طريقة مصادقة",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "البريد الإلكتروني مطلوب",
|
||||
"folderLimitMustBeAtLeast100": "يجب أن يكون حد المجلد 100 على الأقل",
|
||||
"folderLimitMustBeNumber": "يجب أن يكون حد المجلد رقمًا",
|
||||
"imapHostCannotBeEmpty": "لا يمكن أن يكون مُضيف IMAP فارغًا",
|
||||
"imapHostRequired": "مُضيف IMAP مطلوب",
|
||||
"imapPortMustBeLessThan65536": "يجب أن يكون منفذ IMAP أقل من 65536",
|
||||
"imapPortMustBePositive": "يجب أن يكون منفذ IMAP عددًا صحيحًا موجبًا",
|
||||
"incrementalSyncMustBeAtLeast10": "يجب أن يكون فاصل المزامنة التزايدية 10 دقائق على الأقل",
|
||||
"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": "عنوان بريد إلكتروني غير صالح",
|
||||
"invalidUrl": "عنوان URL غير صالح",
|
||||
"passwordMinLength": "يجب أن تتكون كلمة المرور من {{min}} أحرف على الأقل",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "يجب أن يكون حجم الدُفعة على الأكثر 200",
|
||||
"singleRequestBatchSizeTooSmall": "يجب أن يكون حجم الدُفعة على الأقل 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Fortsæt",
|
||||
"createdAt": "Oprettet",
|
||||
"creationFailed": "Oprettelse mislykkedes, prøv venligst igen senere",
|
||||
"cronAdvanced": "Avanceret udtryk",
|
||||
"cronDaily": "Dagligt",
|
||||
"cronDayOfMonth": "Ugedag",
|
||||
"cronDayOfWeek": "Ugedag",
|
||||
"cronFrequency": "Frekvens",
|
||||
"cronFriday": "Fredag",
|
||||
"cronHour": "Time",
|
||||
"cronMinute": "Minut",
|
||||
"cronMonday": "Mandag",
|
||||
"cronMonthly": "Månedligt",
|
||||
"cronSaturday": "Lørdag",
|
||||
"cronSimple": "Simpelt udtryk",
|
||||
"cronSunday": "Søndag",
|
||||
"cronThursday": "Torsdag",
|
||||
"cronTimezoneNote": "Alle tider er serverens lokale tid.",
|
||||
"cronTuesday": "Tirsdag",
|
||||
"cronWednesday": "Onsdag",
|
||||
"cronWeekly": "Ugentligt",
|
||||
"dateSelection": "Datovalg",
|
||||
"dateSince": "Dato fra",
|
||||
"days": "Dage",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Kunne ikke starte download-opgave",
|
||||
"downloadInterval": "Downloadinterval (minutter)",
|
||||
"downloadIntervalPlaceholder": "Indtast minutter",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "6-felters Cron (sek min time dag mdr ugedag) i servertid. Tilsidesætter interval.",
|
||||
"downloadSchedulePlaceholder": "F.eks.: 0 0 * * *",
|
||||
"downloadScope": "Downloadstrategi",
|
||||
"downloadScopeDescription": "Vælg hvilke e-mails der skal indekseres og downloades.",
|
||||
"downloadStarted": "Download-opgave startet",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "hvert {{minutes}} minut",
|
||||
"field": "Felt",
|
||||
"fixed": "Fast",
|
||||
"folderLimit": "Mappegrænse",
|
||||
"folderLimitDescription": "Begræns antallet af e-mails, der skal synkroniseres pr. mappe (minimum 100). Lad stå tomt for ingen grænse.",
|
||||
"folderLimitPlaceholder": "f.eks. 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Vælg efterkommere automatisk",
|
||||
"autoSelectParents": "Vælg forældre automatisk",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Gem ændringer",
|
||||
"scheduleMode": "Planlægning af download",
|
||||
"scheduleModeCron": "Cron-udtryk",
|
||||
"scheduleModeDescription": "Download via faste intervaller eller Cron.",
|
||||
"scheduleModeInterval": "Fast interval",
|
||||
"selectAccountType": "Vælg kontotype",
|
||||
"selectAtLeastOneFolder": "Vælg venligst mindst én mappe",
|
||||
"selectAuthMethod": "Vælg en godkendelsesmetode",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-mail er påkrævet",
|
||||
"folderLimitMustBeAtLeast100": "Mappegrænse skal være mindst 100",
|
||||
"folderLimitMustBeNumber": "Mappegrænse skal være et tal",
|
||||
"imapHostCannotBeEmpty": "IMAP-vært kan ikke være tom",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "Ugyldig URL",
|
||||
"passwordMinLength": "Adgangskoden skal være mindst {{min}} tegn lang",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batch‑størrelse skal være højst 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batch‑størrelse skal være mindst 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Weiter",
|
||||
"createdAt": "Erstellt am",
|
||||
"creationFailed": "Erstellung fehlgeschlagen, bitte versuchen Sie es später erneut",
|
||||
"cronAdvanced": "Erweiterter Ausdruck",
|
||||
"cronDaily": "Täglich",
|
||||
"cronDayOfMonth": "Wochentag",
|
||||
"cronDayOfWeek": "Wochentag",
|
||||
"cronFrequency": "Häufigkeit",
|
||||
"cronFriday": "Freitag",
|
||||
"cronHour": "Stunde",
|
||||
"cronMinute": "Minute",
|
||||
"cronMonday": "Montag",
|
||||
"cronMonthly": "Monatlich",
|
||||
"cronSaturday": "Samstag",
|
||||
"cronSimple": "Einfacher Ausdruck",
|
||||
"cronSunday": "Sonntag",
|
||||
"cronThursday": "Donnerstag",
|
||||
"cronTimezoneNote": "Alle Zeiten nutzen Server-Ortszeit.",
|
||||
"cronTuesday": "Dienstag",
|
||||
"cronWednesday": "Mittwoch",
|
||||
"cronWeekly": "Wöchentlich",
|
||||
"dateSelection": "Datumsauswahl",
|
||||
"dateSince": "Datum seit",
|
||||
"days": "Tage",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Download-Aufgabe konnte nicht gestartet werden",
|
||||
"downloadInterval": "Download-Intervall (Minuten)",
|
||||
"downloadIntervalPlaceholder": "Minuten eingeben",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "6-Felder-Cron (Sek Min Std Tag Mon Wochentag) in Serverzeit. Ersetzt Intervall.",
|
||||
"downloadSchedulePlaceholder": "Z. B.: 0 0 * * *",
|
||||
"downloadScope": "Download-Strategie",
|
||||
"downloadScopeDescription": "Wählen Sie aus, welche E-Mails indiziert und heruntergeladen werden sollen.",
|
||||
"downloadStarted": "Download-Aufgabe gestartet",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "alle {{minutes}} Minuten",
|
||||
"field": "Feld",
|
||||
"fixed": "Fest",
|
||||
"folderLimit": "Ordnerlimit",
|
||||
"folderLimitDescription": "Begrenzen Sie die Anzahl der pro Ordner zu synchronisierenden E-Mails (mindestens 100). Lassen Sie das Feld leer für keine Begrenzung.",
|
||||
"folderLimitPlaceholder": "z.B. 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Unterelemente automatisch auswählen",
|
||||
"autoSelectParents": "Elternelemente automatisch auswählen",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"scheduleMode": "Download-Zeitplan",
|
||||
"scheduleModeCron": "Cron-Ausdruck",
|
||||
"scheduleModeDescription": "Download nach festem Intervall oder Cron-Ausdruck.",
|
||||
"scheduleModeInterval": "Festes Intervall",
|
||||
"selectAccountType": "Kontotyp auswählen",
|
||||
"selectAtLeastOneFolder": "Wählen Sie mindestens einen Ordner aus",
|
||||
"selectAuthMethod": "Authentifizierungsmethode auswählen",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-Mail ist erforderlich",
|
||||
"folderLimitMustBeAtLeast100": "Das Ordnerlimit muss mindestens 100 betragen",
|
||||
"folderLimitMustBeNumber": "Das Ordnerlimit muss eine Zahl sein",
|
||||
"imapHostCannotBeEmpty": "IMAP-Host darf nicht leer sein",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "Ungültige URL",
|
||||
"passwordMinLength": "Das Passwort muss mindestens {{min}} Zeichen lang sein",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Die Stapelgröße darf höchstens 200 sein",
|
||||
"singleRequestBatchSizeTooSmall": "Die Stapelgröße muss mindestens 10 sein"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Continue",
|
||||
"createdAt": "Created At",
|
||||
"creationFailed": "Creation failed, please try again later",
|
||||
"cronAdvanced": "Advanced Expression",
|
||||
"cronDaily": "Daily",
|
||||
"cronDayOfMonth": "Day of Week",
|
||||
"cronDayOfWeek": "Day of Week",
|
||||
"cronFrequency": "Frequency",
|
||||
"cronFriday": "Friday",
|
||||
"cronHour": "Hour",
|
||||
"cronMinute": "Minute",
|
||||
"cronMonday": "Monday",
|
||||
"cronMonthly": "Monthly",
|
||||
"cronSaturday": "Saturday",
|
||||
"cronSimple": "Simple Expression",
|
||||
"cronSunday": "Sunday",
|
||||
"cronThursday": "Thursday",
|
||||
"cronTimezoneNote": "All times use server local timezone.",
|
||||
"cronTuesday": "Tuesday",
|
||||
"cronWednesday": "Wednesday",
|
||||
"cronWeekly": "Weekly",
|
||||
"dateSelection": "Date Selection",
|
||||
"dateSince": "Date Since",
|
||||
"days": "Days",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Failed to start download task",
|
||||
"downloadInterval": "Download Interval (minutes)",
|
||||
"downloadIntervalPlaceholder": "Enter minutes",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "6-field Cron (sec min hour day month dow) in server time. Overrides interval.",
|
||||
"downloadSchedulePlaceholder": "e.g., 0 0 * * *",
|
||||
"downloadScope": "Download Strategy",
|
||||
"downloadScopeDescription": "Choose which emails should be indexed and downloaded.",
|
||||
"downloadStarted": "Download task started",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "every {{minutes}} minutes",
|
||||
"field": "Field",
|
||||
"fixed": "Fixed",
|
||||
"folderLimit": "Folder Limit",
|
||||
"folderLimitDescription": "Limit the number of emails to sync per folder (minimum 100). Leave empty for no limit.",
|
||||
"folderLimitPlaceholder": "e.g. 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Auto select descendants",
|
||||
"autoSelectParents": "Auto select parents",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Save changes",
|
||||
"scheduleMode": "Download Schedule",
|
||||
"scheduleModeCron": "Cron Expression",
|
||||
"scheduleModeDescription": "Download via fixed intervals or Cron expressions.",
|
||||
"scheduleModeInterval": "Fixed Interval",
|
||||
"selectAccountType": "Select account type",
|
||||
"selectAtLeastOneFolder": "Please select at least one folder",
|
||||
"selectAuthMethod": "Select an authentication method",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "Email is required",
|
||||
"folderLimitMustBeAtLeast100": "Folder limit must be at least 100",
|
||||
"folderLimitMustBeNumber": "Folder limit must be a number",
|
||||
"imapHostCannotBeEmpty": "IMAP host cannot be empty",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "Invalid URL",
|
||||
"passwordMinLength": "Password must be at least {{min}} characters long",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batch size must be at most 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batch size must be at least 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Continuar",
|
||||
"createdAt": "Creado el",
|
||||
"creationFailed": "Error al crear, por favor, inténtalo de nuevo más tarde",
|
||||
"cronAdvanced": "Expresión avanzada",
|
||||
"cronDaily": "Diario",
|
||||
"cronDayOfMonth": "Día de la semana",
|
||||
"cronDayOfWeek": "Día de la semana",
|
||||
"cronFrequency": "Frecuencia",
|
||||
"cronFriday": "Viernes",
|
||||
"cronHour": "Hora",
|
||||
"cronMinute": "Minuto",
|
||||
"cronMonday": "Lunes",
|
||||
"cronMonthly": "Mensual",
|
||||
"cronSaturday": "Sábado",
|
||||
"cronSimple": "Expresión simple",
|
||||
"cronSunday": "Domingo",
|
||||
"cronThursday": "Jueves",
|
||||
"cronTimezoneNote": "Horas en zona horaria del servidor.",
|
||||
"cronTuesday": "Martes",
|
||||
"cronWednesday": "Miércoles",
|
||||
"cronWeekly": "Semanal",
|
||||
"dateSelection": "Selección de fecha",
|
||||
"dateSince": "Fecha desde",
|
||||
"days": "Días",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Error al iniciar la tarea de descarga",
|
||||
"downloadInterval": "Intervalo de descarga (minutos)",
|
||||
"downloadIntervalPlaceholder": "Ingresa los minutos",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "Cron de 6 campos (seg min hora día mes día-sem) en hora del servidor. Anula intervalo.",
|
||||
"downloadSchedulePlaceholder": "ej., 0 0 * * *",
|
||||
"downloadScope": "Estrategia de descarga",
|
||||
"downloadScopeDescription": "Elija qué correos electrónicos deben indexarse y descargarse.",
|
||||
"downloadStarted": "Tarea de descarga iniciada",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "cada {{minutes}} minutos",
|
||||
"field": "Campo",
|
||||
"fixed": "Fija",
|
||||
"folderLimit": "Límite de carpetas",
|
||||
"folderLimitDescription": "Limita el número de correos a sincronizar por carpeta (mínimo 100). Deja vacío para no tener límite.",
|
||||
"folderLimitPlaceholder": "ej. 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Seleccionar automáticamente los descendientes",
|
||||
"autoSelectParents": "Seleccionar automáticamente los padres",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Guardar cambios",
|
||||
"scheduleMode": "Programación de descarga",
|
||||
"scheduleModeCron": "Expresión Cron",
|
||||
"scheduleModeDescription": "Descarga por intervalos fijos o expresiones Cron.",
|
||||
"scheduleModeInterval": "Intervalo fijo",
|
||||
"selectAccountType": "Seleccionar tipo de cuenta",
|
||||
"selectAtLeastOneFolder": "Selecciona al menos una carpeta",
|
||||
"selectAuthMethod": "Selecciona el método de autenticación",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "El correo electrónico es obligatorio",
|
||||
"folderLimitMustBeAtLeast100": "El límite de carpetas debe ser de al menos 100",
|
||||
"folderLimitMustBeNumber": "El límite de carpetas debe ser un número",
|
||||
"imapHostCannotBeEmpty": "El host IMAP no puede estar vacío",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "URL inválida",
|
||||
"passwordMinLength": "La contraseña debe tener al menos {{min}} caracteres",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "El tamaño del lote debe ser como máximo 200",
|
||||
"singleRequestBatchSizeTooSmall": "El tamaño del lote debe ser al menos 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Jatka",
|
||||
"createdAt": "Luotu",
|
||||
"creationFailed": "Luominen epäonnistui, yritä myöhemmin uudelleen",
|
||||
"cronAdvanced": "Edistynyt lauseke",
|
||||
"cronDaily": "Päivittäin",
|
||||
"cronDayOfMonth": "Viikonpäivä",
|
||||
"cronDayOfWeek": "Viikonpäivä",
|
||||
"cronFrequency": "Tiheys",
|
||||
"cronFriday": "Perjantai",
|
||||
"cronHour": "Tunti",
|
||||
"cronMinute": "Minuutti",
|
||||
"cronMonday": "Maanantai",
|
||||
"cronMonthly": "Kuukausittain",
|
||||
"cronSaturday": "Lauantai",
|
||||
"cronSimple": "Yksinkertainen lauseke",
|
||||
"cronSunday": "Sunnuntai",
|
||||
"cronThursday": "Torstai",
|
||||
"cronTimezoneNote": "Kaikki ajat palvelimen paikallista aikaa.",
|
||||
"cronTuesday": "Tiistai",
|
||||
"cronWednesday": "Keskiviikko",
|
||||
"cronWeekly": "Viikoittain",
|
||||
"dateSelection": "Päivämäärän valinta",
|
||||
"dateSince": "Päivämäärä alkaen",
|
||||
"days": "Päivää",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Lataustehtävän aloittaminen epäonnistui",
|
||||
"downloadInterval": "Latausväli (minuuttia)",
|
||||
"downloadIntervalPlaceholder": "Syötä minuutit",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "6-kenttäinen Cron (sek min tun pv kk vkpv) palvelimen ajassa. Korvaa aikavälin.",
|
||||
"downloadSchedulePlaceholder": "esim. 0 0 * * *",
|
||||
"downloadScope": "Latausstrategia",
|
||||
"downloadScopeDescription": "Valitse, mitkä sähköpostit indeksoidaan ja ladataan.",
|
||||
"downloadStarted": "Lataustehtävä aloitettu",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "joka {{minutes}} minuutti",
|
||||
"field": "Kenttä",
|
||||
"fixed": "Kiinteä",
|
||||
"folderLimit": "Kansioraja",
|
||||
"folderLimitDescription": "Rajoittaa synkronoitavien sähköpostien määrän kansiota kohden (vähintään 100). Jätä tyhjäksi ilman rajoitusta.",
|
||||
"folderLimitPlaceholder": "esim. 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Valitse alisolmut automaattisesti",
|
||||
"autoSelectParents": "Valitse yläsolmut automaattisesti",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Tallenna muutokset",
|
||||
"scheduleMode": "Latauksen ajoitus",
|
||||
"scheduleModeCron": "Cron-lauseke",
|
||||
"scheduleModeDescription": "Lataa säännöllisin väliajoin tai Cron-lausekkeella.",
|
||||
"scheduleModeInterval": "Säännöllinen väliaika",
|
||||
"selectAccountType": "Valitse tilityyppi",
|
||||
"selectAtLeastOneFolder": "Valitse vähintään yksi kansio",
|
||||
"selectAuthMethod": "Valitse todennusmenetelmä",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "Sähköposti on pakollinen",
|
||||
"folderLimitMustBeAtLeast100": "Kansiorajan on oltava vähintään 100",
|
||||
"folderLimitMustBeNumber": "Kansiorajan on oltava numero",
|
||||
"imapHostCannotBeEmpty": "IMAP-isäntä ei voi olla tyhjä",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "Virheellinen URL-osoite",
|
||||
"passwordMinLength": "Salasanan on oltava vähintään {{min}} merkkiä pitkä",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Eräkoko tulee olla enintään 200",
|
||||
"singleRequestBatchSizeTooSmall": "Eräkoko tulee olla vähintään 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Continuer",
|
||||
"createdAt": "Créé le",
|
||||
"creationFailed": "La création a échoué, veuillez réessayer plus tard",
|
||||
"cronAdvanced": "Expression avancée",
|
||||
"cronDaily": "Chaque jour",
|
||||
"cronDayOfMonth": "Jour de la semaine",
|
||||
"cronDayOfWeek": "Jour de la semaine",
|
||||
"cronFrequency": "Fréquence",
|
||||
"cronFriday": "Vendredi",
|
||||
"cronHour": "Heure",
|
||||
"cronMinute": "Minute",
|
||||
"cronMonday": "Lundi",
|
||||
"cronMonthly": "Chaque mois",
|
||||
"cronSaturday": "Samedi",
|
||||
"cronSimple": "Expression simple",
|
||||
"cronSunday": "Dimanche",
|
||||
"cronThursday": "Jeudi",
|
||||
"cronTimezoneNote": "Heures au fuseau horaire du serveur.",
|
||||
"cronTuesday": "Mardi",
|
||||
"cronWednesday": "Mercredi",
|
||||
"cronWeekly": "Chaque semaine",
|
||||
"dateSelection": "Sélection de Date",
|
||||
"dateSince": "Date Depuis",
|
||||
"days": "Jours",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Échec du lancement de la tâche de téléchargement",
|
||||
"downloadInterval": "Intervalle de téléchargement (minutes)",
|
||||
"downloadIntervalPlaceholder": "Entrer les minutes",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "Cron à 6 champs (sec min heure jour mois jour-sem) à l'heure du serveur. Remplace l'intervalle.",
|
||||
"downloadSchedulePlaceholder": "ex. 0 0 * * *",
|
||||
"downloadScope": "Stratégie de téléchargement",
|
||||
"downloadScopeDescription": "Choisissez les e-mails à indexer et à télécharger.",
|
||||
"downloadStarted": "Tâche de téléchargement lancée",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "toutes les {{minutes}} minutes",
|
||||
"field": "Champ",
|
||||
"fixed": "Fixe",
|
||||
"folderLimit": "Limite de Dossiers",
|
||||
"folderLimitDescription": "Limite le nombre d'e-mails à synchroniser par dossier (minimum 100). Laissez vide pour aucune limite.",
|
||||
"folderLimitPlaceholder": "ex. 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Sélectionner automatiquement les descendants",
|
||||
"autoSelectParents": "Sélectionner automatiquement les parents",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Enregistrer les Modifications",
|
||||
"scheduleMode": "Planification de téléchargement",
|
||||
"scheduleModeCron": "Expression Cron",
|
||||
"scheduleModeDescription": "Téléchargement par intervalle fixe ou expression Cron.",
|
||||
"scheduleModeInterval": "Intervalle fixe",
|
||||
"selectAccountType": "Sélectionner le type de compte",
|
||||
"selectAtLeastOneFolder": "Veuillez sélectionner au moins un dossier",
|
||||
"selectAuthMethod": "Sélectionner une méthode d'authentification",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "L'adresse e-mail est obligatoire",
|
||||
"folderLimitMustBeAtLeast100": "La limite de dossiers doit être d'au moins 100",
|
||||
"folderLimitMustBeNumber": "La limite de dossiers doit être un nombre",
|
||||
"imapHostCannotBeEmpty": "L'hôte IMAP ne peut pas être vide",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "URL non valide",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins {{min}} caractères",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "La taille du lot doit être au plus 200",
|
||||
"singleRequestBatchSizeTooSmall": "La taille du lot doit être au moins 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Continua",
|
||||
"createdAt": "Creato Il",
|
||||
"creationFailed": "Creazione fallita, riprova più tardi",
|
||||
"cronAdvanced": "Espressione avanzata",
|
||||
"cronDaily": "Ogni giorno",
|
||||
"cronDayOfMonth": "Giorno della settimana",
|
||||
"cronDayOfWeek": "Giorno della settimana",
|
||||
"cronFrequency": "Frequenza",
|
||||
"cronFriday": "Venerdì",
|
||||
"cronHour": "Ora",
|
||||
"cronMinute": "Minuto",
|
||||
"cronMonday": "Lunedì",
|
||||
"cronMonthly": "Ogni mese",
|
||||
"cronSaturday": "Sabato",
|
||||
"cronSimple": "Espressione semplice",
|
||||
"cronSunday": "Domenica",
|
||||
"cronThursday": "Giovedì",
|
||||
"cronTimezoneNote": "Orari nel fuso orario del server.",
|
||||
"cronTuesday": "Martedì",
|
||||
"cronWednesday": "Mercoledì",
|
||||
"cronWeekly": "Ogni settimana",
|
||||
"dateSelection": "Selezione Data",
|
||||
"dateSince": "Data Da",
|
||||
"days": "Giorni",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Avvio attività di download non riuscito",
|
||||
"downloadInterval": "Intervallo di download (minuti)",
|
||||
"downloadIntervalPlaceholder": "Inserisci i minuti",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "Cron a 6 campi (sec min ora giorno mese giorno-sett) in ora del server. Sostituisce l'intervallo.",
|
||||
"downloadSchedulePlaceholder": "es. 0 0 * * *",
|
||||
"downloadScope": "Strategia di download",
|
||||
"downloadScopeDescription": "Scegli quali email indicizzare e scaricare.",
|
||||
"downloadStarted": "Attività di download avviata",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "ogni {{minutes}} minuti",
|
||||
"field": "Campo",
|
||||
"fixed": "Fissa",
|
||||
"folderLimit": "Limite Cartelle",
|
||||
"folderLimitDescription": "Limita il numero di email da sincronizzare per cartella (minimo 100). Lascia vuoto per nessun limite.",
|
||||
"folderLimitPlaceholder": "es. 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Seleziona automaticamente i discendenti",
|
||||
"autoSelectParents": "Seleziona automaticamente i genitori",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Salva Modifiche",
|
||||
"scheduleMode": "Pianificazione del download",
|
||||
"scheduleModeCron": "Espressione Cron",
|
||||
"scheduleModeDescription": "Download a intervalli fissi o tramite espressioni Cron.",
|
||||
"scheduleModeInterval": "Intervallo fisso",
|
||||
"selectAccountType": "Seleziona tipo di account",
|
||||
"selectAtLeastOneFolder": "Seleziona almeno una cartella",
|
||||
"selectAuthMethod": "Seleziona un metodo di autenticazione",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "L'email è obbligatoria",
|
||||
"folderLimitMustBeAtLeast100": "Il limite di cartelle deve essere almeno 100",
|
||||
"folderLimitMustBeNumber": "Il limite di cartelle deve essere un numero",
|
||||
"imapHostCannotBeEmpty": "L'host IMAP non può essere vuoto",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "URL non valido",
|
||||
"passwordMinLength": "La password deve contenere almeno {{min}} caratteri",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "La dimensione del batch deve essere al massimo 200",
|
||||
"singleRequestBatchSizeTooSmall": "La dimensione del batch deve essere almeno 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "続行",
|
||||
"createdAt": "作成日時",
|
||||
"creationFailed": "作成に失敗しました。しばらくしてからもう一度お試しください。",
|
||||
"cronAdvanced": "高度な式",
|
||||
"cronDaily": "毎日",
|
||||
"cronDayOfMonth": "曜日",
|
||||
"cronDayOfWeek": "曜日",
|
||||
"cronFrequency": "頻度",
|
||||
"cronFriday": "金曜日",
|
||||
"cronHour": "时",
|
||||
"cronMinute": "分",
|
||||
"cronMonday": "月曜日",
|
||||
"cronMonthly": "毎月",
|
||||
"cronSaturday": "土曜日",
|
||||
"cronSimple": "簡易式",
|
||||
"cronSunday": "日曜日",
|
||||
"cronThursday": "木曜日",
|
||||
"cronTimezoneNote": "時間はサーバーの現地時間です。",
|
||||
"cronTuesday": "火曜日",
|
||||
"cronWednesday": "水曜日",
|
||||
"cronWeekly": "毎週",
|
||||
"dateSelection": "日付選択",
|
||||
"dateSince": "同期開始日",
|
||||
"days": "日",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "ダウンロードタスクの起動に失敗しました",
|
||||
"downloadInterval": "ダウンロード間隔 (分)",
|
||||
"downloadIntervalPlaceholder": "分を入力してください",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "サーバー時間の6フィールドCron式(秒分時日月曜日)。設定値より優先。",
|
||||
"downloadSchedulePlaceholder": "例:0 0 * * *",
|
||||
"downloadScope": "ダウンロード戦略",
|
||||
"downloadScopeDescription": "インデックスとダウンロードの対象となるメールを選択してください。",
|
||||
"downloadStarted": "ダウンロードタスクを開始しました",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "{{minutes}}分ごと",
|
||||
"field": "フィールド",
|
||||
"fixed": "固定",
|
||||
"folderLimit": "フォルダーの制限",
|
||||
"folderLimitDescription": "フォルダーごとに同期するメールの数を制限します(最小100)。制限なしにする場合は空欄にしてください。",
|
||||
"folderLimitPlaceholder": "例: 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "子項目を自動選択",
|
||||
"autoSelectParents": "親項目を自動選択",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "変更を保存",
|
||||
"scheduleMode": "ダウンロードスケジュール",
|
||||
"scheduleModeCron": "Cron式",
|
||||
"scheduleModeDescription": "固定間隔またはCron式でダウンロード。",
|
||||
"scheduleModeInterval": "固定間隔",
|
||||
"selectAccountType": "アカウントの種類を選択",
|
||||
"selectAtLeastOneFolder": "少なくとも1つのフォルダーを選択してください",
|
||||
"selectAuthMethod": "認証方式を選択",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "メールアドレスは必須です",
|
||||
"folderLimitMustBeAtLeast100": "フォルダーの制限は100以上である必要があります",
|
||||
"folderLimitMustBeNumber": "フォルダーの制限は数値である必要があります",
|
||||
"imapHostCannotBeEmpty": "IMAPホストは空にできません",
|
||||
"imapHostRequired": "IMAPホストは必須です",
|
||||
"imapPortMustBeLessThan65536": "IMAPポートは65536未満である必要があります",
|
||||
"imapPortMustBePositive": "IMAPポートは正の整数である必要があります",
|
||||
"incrementalSyncMustBeAtLeast10": "増分同期間隔は10分以上である必要があります",
|
||||
"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": "無効なメールアドレスです",
|
||||
"invalidUrl": "無効なURLです",
|
||||
"passwordMinLength": "パスワードは{{min}}文字以上である必要があります",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "バッチサイズは最大でも200でなければなりません",
|
||||
"singleRequestBatchSizeTooSmall": "バッチサイズは最低でも10でなければなりません"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "계속",
|
||||
"createdAt": "생성일",
|
||||
"creationFailed": "생성에 실패했습니다. 나중에 다시 시도하십시오.",
|
||||
"cronAdvanced": "고급 표현식",
|
||||
"cronDaily": "매일",
|
||||
"cronDayOfMonth": "요일",
|
||||
"cronDayOfWeek": "요일",
|
||||
"cronFrequency": "주기",
|
||||
"cronFriday": "금요일",
|
||||
"cronHour": "시",
|
||||
"cronMinute": "분",
|
||||
"cronMonday": "월요일",
|
||||
"cronMonthly": "매월",
|
||||
"cronSaturday": "토요일",
|
||||
"cronSimple": "간단한 표현식",
|
||||
"cronSunday": "일요일",
|
||||
"cronThursday": "목요일",
|
||||
"cronTimezoneNote": "모든 시간은 서버 현지 시간 기준입니다.",
|
||||
"cronTuesday": "화요일",
|
||||
"cronWednesday": "수요일",
|
||||
"cronWeekly": "매주",
|
||||
"dateSelection": "날짜 선택",
|
||||
"dateSince": "동기화 시작일",
|
||||
"days": "일",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "다운로드 작업 시작 실패",
|
||||
"downloadInterval": "다운로드 주기 (분)",
|
||||
"downloadIntervalPlaceholder": "분 단위 입력",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "서버 시간 기준 6필드 Cron(초 분 시 일 월 요일). 간격 설정보다 우선됨.",
|
||||
"downloadSchedulePlaceholder": "예: 0 0 * * *",
|
||||
"downloadScope": "다운로드 전략",
|
||||
"downloadScopeDescription": "색인화 및 다운로드할 이메일을 선택하십시오.",
|
||||
"downloadStarted": "다운로드 작업 시작됨",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "매 {{minutes}}분",
|
||||
"field": "필드",
|
||||
"fixed": "고정",
|
||||
"folderLimit": "폴더 제한",
|
||||
"folderLimitDescription": "폴더당 동기화할 이메일 수를 제한합니다(최소 100). 제한이 없으면 비워 두십시오.",
|
||||
"folderLimitPlaceholder": "예: 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "하위 항목 자동 선택",
|
||||
"autoSelectParents": "상위 항목 자동 선택",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "변경 사항 저장",
|
||||
"scheduleMode": "다운로드 일정",
|
||||
"scheduleModeCron": "Cron 표현식",
|
||||
"scheduleModeDescription": "고정 간격 또는 Cron 표현식으로 다운로드.",
|
||||
"scheduleModeInterval": "고정 간격",
|
||||
"selectAccountType": "계정 유형 선택",
|
||||
"selectAtLeastOneFolder": "하나 이상의 폴더를 선택하십시오",
|
||||
"selectAuthMethod": "인증 방법 선택",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "이메일 주소는 필수입니다",
|
||||
"folderLimitMustBeAtLeast100": "폴더 제한은 최소 100 이상이어야 합니다",
|
||||
"folderLimitMustBeNumber": "폴더 제한은 숫자여야 합니다",
|
||||
"imapHostCannotBeEmpty": "IMAP 호스트는 비워 둘 수 없습니다",
|
||||
"imapHostRequired": "IMAP 호스트는 필수입니다",
|
||||
"imapPortMustBeLessThan65536": "IMAP 포트는 65536보다 작아야 합니다",
|
||||
"imapPortMustBePositive": "IMAP 포트는 양의 정수여야 합니다",
|
||||
"incrementalSyncMustBeAtLeast10": "증분 동기화 간격은 최소 10분 이상이어야 합니다",
|
||||
"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": "유효하지 않은 이메일 주소",
|
||||
"invalidUrl": "유효하지 않은 URL",
|
||||
"passwordMinLength": "비밀번호는 {{min}}자 이상이어야 합니다",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "배치 크기는 최대 200이어야 합니다",
|
||||
"singleRequestBatchSizeTooSmall": "배치 크기는 최소 10이어야 합니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Doorgaan",
|
||||
"createdAt": "Aangemaakt Op",
|
||||
"creationFailed": "Aanmaken mislukt, probeer het later opnieuw",
|
||||
"cronAdvanced": "Geavanceerde expressie",
|
||||
"cronDaily": "Dagelijks",
|
||||
"cronDayOfMonth": "Dag van de week",
|
||||
"cronDayOfWeek": "Dag van de week",
|
||||
"cronFrequency": "Frequentie",
|
||||
"cronFriday": "Vrijdag",
|
||||
"cronHour": "Uur",
|
||||
"cronMinute": "Minuut",
|
||||
"cronMonday": "Maandag",
|
||||
"cronMonthly": "Maandelijks",
|
||||
"cronSaturday": "Zaterdag",
|
||||
"cronSimple": "Simpele expressie",
|
||||
"cronSunday": "Zondag",
|
||||
"cronThursday": "Donderdag",
|
||||
"cronTimezoneNote": "Alle tijden zijn server-lokale tijd.",
|
||||
"cronTuesday": "Dinsdag",
|
||||
"cronWednesday": "Woensdag",
|
||||
"cronWeekly": "Wekelijks",
|
||||
"dateSelection": "Datumselectie",
|
||||
"dateSince": "Datum Sinds",
|
||||
"days": "Dagen",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Downloadtaak starten mislukt",
|
||||
"downloadInterval": "Download-interval (minuten)",
|
||||
"downloadIntervalPlaceholder": "Voer minuten in",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "6-velds Cron (sec min uur dag maand dag-week) in servertijd. Overschrijft interval.",
|
||||
"downloadSchedulePlaceholder": "bijv. 0 0 * * *",
|
||||
"downloadScope": "Downloadstrategie",
|
||||
"downloadScopeDescription": "Kies welke e-mails moeten worden geïndexeerd en gedownload.",
|
||||
"downloadStarted": "Downloadtaak gestart",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "elke {{minutes}} minuten",
|
||||
"field": "Veld",
|
||||
"fixed": "Vast",
|
||||
"folderLimit": "Mappenlimiet",
|
||||
"folderLimitDescription": "Beperk het aantal te synchroniseren e-mails per map (minimaal 100). Laat leeg voor geen limiet.",
|
||||
"folderLimitPlaceholder": "bv. 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Automatisch onderliggende items selecteren",
|
||||
"autoSelectParents": "Automatisch bovenliggende items selecteren",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Wijzigingen opslaan",
|
||||
"scheduleMode": "Downloadschema",
|
||||
"scheduleModeCron": "Cron-expressie",
|
||||
"scheduleModeDescription": "Downloaden via vaste intervallen o f Cron-expressies.",
|
||||
"scheduleModeInterval": "Vast interval",
|
||||
"selectAccountType": "Selecteer accounttype",
|
||||
"selectAtLeastOneFolder": "Selecteer alstublieft ten minste één map",
|
||||
"selectAuthMethod": "Selecteer een authenticatiemethode",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-mail is vereist",
|
||||
"folderLimitMustBeAtLeast100": "Mappenlimiet moet ten minste 100 zijn",
|
||||
"folderLimitMustBeNumber": "Mappenlimiet moet een nummer zijn",
|
||||
"imapHostCannotBeEmpty": "IMAP host mag niet leeg zijn",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "Ongeldige URL",
|
||||
"passwordMinLength": "Wachtwoord moet ten minste {{min}} tekens lang zijn",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batch‑grootte moet hoogstens 200 zijn",
|
||||
"singleRequestBatchSizeTooSmall": "Batch‑grootte moet ten minste 10 zijn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Fortsett",
|
||||
"createdAt": "Opprettet",
|
||||
"creationFailed": "Opprettelse mislyktes, prøv igjen senere",
|
||||
"cronAdvanced": "Avansert uttrykk",
|
||||
"cronDaily": "Daglig",
|
||||
"cronDayOfMonth": "Ugedag",
|
||||
"cronDayOfWeek": "Ugedag",
|
||||
"cronFrequency": "Frekvens",
|
||||
"cronFriday": "Fredag",
|
||||
"cronHour": "Time",
|
||||
"cronMinute": "Minutt",
|
||||
"cronMonday": "Mandag",
|
||||
"cronMonthly": "Månedlig",
|
||||
"cronSaturday": "Lørdag",
|
||||
"cronSimple": "Enkelt uttrykk",
|
||||
"cronSunday": "Søndag",
|
||||
"cronThursday": "Torsdag",
|
||||
"cronTimezoneNote": "Alle klokkeslett er serverens lokaltid.",
|
||||
"cronTuesday": "Tirsdag",
|
||||
"cronWednesday": "Onsdag",
|
||||
"cronWeekly": "Ukentlig",
|
||||
"dateSelection": "Datovalg",
|
||||
"dateSince": "Dato siden",
|
||||
"days": "Dager",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Kunne ikke starte nedlastingsoppgave",
|
||||
"downloadInterval": "Nedlastingsintervall (minutter)",
|
||||
"downloadIntervalPlaceholder": "Skriv inn minutter",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "6-felts Cron (sek min time dag mdr ukedag) i servertid. Overstyrer intervall.",
|
||||
"downloadSchedulePlaceholder": "f.eks.: 0 0 * * *",
|
||||
"downloadScope": "Nedlastingsstrategi",
|
||||
"downloadScopeDescription": "Velg hvilke e-poster som skal indekseres og lastes ned.",
|
||||
"downloadStarted": "Nedlastingsoppgave startet",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "hvert {{minutes}} minutt",
|
||||
"field": "Felt",
|
||||
"fixed": "Fast",
|
||||
"folderLimit": "Mappegrense",
|
||||
"folderLimitDescription": "Begrens antall e-poster som skal synkroniseres per mappe (minimum 100). La stå tomt for ingen grense.",
|
||||
"folderLimitPlaceholder": "f.eks. 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Velg etterkommere automatisk",
|
||||
"autoSelectParents": "Velg foreldre automatisk",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Lagre endringer",
|
||||
"scheduleMode": "Nedlastingsplan",
|
||||
"scheduleModeCron": "Cron-uttrykk",
|
||||
"scheduleModeDescription": "Nedlasting med faste intervaller eller Cron-uttrykk.",
|
||||
"scheduleModeInterval": "Fast intervall",
|
||||
"selectAccountType": "Velg kontotype",
|
||||
"selectAtLeastOneFolder": "Vennligst velg minst én mappe",
|
||||
"selectAuthMethod": "Velg en autentiseringsmetode",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-post er påkrevd",
|
||||
"folderLimitMustBeAtLeast100": "Mappegrense må være minst 100",
|
||||
"folderLimitMustBeNumber": "Mappegrense må være et tall",
|
||||
"imapHostCannotBeEmpty": "IMAP-vert kan ikke være tom",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "Ugyldig URL",
|
||||
"passwordMinLength": "Passordet må være minst {{min}} tegn langt",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batchstørrelse må være maksimalt 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batchstørrelse må være minst 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Kontynuj",
|
||||
"createdAt": "Utworzono",
|
||||
"creationFailed": "Bład tworzenia, spróbuj później",
|
||||
"cronAdvanced": "Zaawansowane wyrażenie",
|
||||
"cronDaily": "Codziennie",
|
||||
"cronDayOfMonth": "Dzień tygodnia",
|
||||
"cronDayOfWeek": "Dzień tygodnia",
|
||||
"cronFrequency": "Częstotliwość",
|
||||
"cronFriday": "Piątek",
|
||||
"cronHour": "Godzina",
|
||||
"cronMinute": "Minuta",
|
||||
"cronMonday": "Poniedziałek",
|
||||
"cronMonthly": "Co miesiąc",
|
||||
"cronSaturday": "Sobota",
|
||||
"cronSimple": "Proste wyrażenie",
|
||||
"cronSunday": "Niedziela",
|
||||
"cronThursday": "Czwartek",
|
||||
"cronTimezoneNote": "Czas według lokalnej strefy serwera.",
|
||||
"cronTuesday": "Wtorek",
|
||||
"cronWednesday": "Środa",
|
||||
"cronWeekly": "Co tydzień",
|
||||
"dateSelection": "Zaznaczenie daty",
|
||||
"dateSince": "Od kiedy",
|
||||
"days": "Dni",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Nie udało się uruchomić zadania pobierania",
|
||||
"downloadInterval": "Cykl pobierania (minuty)",
|
||||
"downloadIntervalPlaceholder": "Wprowadź minuty",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "6-polowy Cron (sek min godz dz msc dz-tyg) w czasie serwera. Nadpisuje interwał.",
|
||||
"downloadSchedulePlaceholder": "np. 0 0 * * *",
|
||||
"downloadScope": "Strategia pobierania",
|
||||
"downloadScopeDescription": "Wybierz, które wiadomości e-mail mają być indeksowane i pobierane.",
|
||||
"downloadStarted": "Uruchomiono zadanie pobierania",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "co {{minutes}} minut",
|
||||
"field": "Pole",
|
||||
"fixed": "Dokładnie",
|
||||
"folderLimit": "Limit folderu",
|
||||
"folderLimitDescription": "Ogranicz liczbę wiadomości email, które należy synchronizować w jednym folderze (minimum 100). Pozostaw puste, aby nie było limitu.",
|
||||
"folderLimitPlaceholder": "np. 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Automatyczny wybór dzieci (podrzędnych)",
|
||||
"autoSelectParents": "Automatyczny wybór rodziców (nadrzędnych)",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Zapisz zmiany",
|
||||
"scheduleMode": "Harmonogram pobierania",
|
||||
"scheduleModeCron": "Wyrażenie Cron",
|
||||
"scheduleModeDescription": "Pobieranie w stałych odstępach lub przez wyrażenia Cron.",
|
||||
"scheduleModeInterval": "Stały odstęp",
|
||||
"selectAccountType": "Wybierz typ konta",
|
||||
"selectAtLeastOneFolder": "Wybierz co najmniej jeden folder",
|
||||
"selectAuthMethod": "Zaznacz metodę uwierzytelniania IMAP",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "Email jest wymagany",
|
||||
"folderLimitMustBeAtLeast100": "Limit folderów musi być liczbą nie mniejszą niż 100",
|
||||
"folderLimitMustBeNumber": "Limit folderów musi być liczbą",
|
||||
"imapHostCannotBeEmpty": "Host IMAP nie może być pusty",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "Niewłaściwy URL",
|
||||
"passwordMinLength": "Hasło musi posiadać conajmniej {{min}} znaków",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Rozmiar partii może wynosić maksymalnie 200",
|
||||
"singleRequestBatchSizeTooSmall": "Rozmiar partii musi wynosić co najmniej 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Continuar",
|
||||
"createdAt": "Criado Em",
|
||||
"creationFailed": "Falha na criação, por favor, tente novamente mais tarde.",
|
||||
"cronAdvanced": "Expressão avançada",
|
||||
"cronDaily": "Diariamente",
|
||||
"cronDayOfMonth": "Dia da semana",
|
||||
"cronDayOfWeek": "Dia da semana",
|
||||
"cronFrequency": "Frequência",
|
||||
"cronFriday": "Sexta-feira",
|
||||
"cronHour": "Hora",
|
||||
"cronMinute": "Minuto",
|
||||
"cronMonday": "Segunda-feira",
|
||||
"cronMonthly": "Mensalmente",
|
||||
"cronSaturday": "Sábado",
|
||||
"cronSimple": "Expressão simples",
|
||||
"cronSunday": "Domingo",
|
||||
"cronThursday": "Quinta-feira",
|
||||
"cronTimezoneNote": "Horários no fuso horário do servidor.",
|
||||
"cronTuesday": "Terça-feira",
|
||||
"cronWednesday": "Quarta-feira",
|
||||
"cronWeekly": "Semanalmente",
|
||||
"dateSelection": "Seleção de Data",
|
||||
"dateSince": "Data de Início da Sincronização",
|
||||
"days": "Dias",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Falha ao iniciar tarefa de download",
|
||||
"downloadInterval": "Intervalo de download (minutos)",
|
||||
"downloadIntervalPlaceholder": "Insira os minutos",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "Cron de 6 campos (seg min hora dia mês dia-sem) na hora do servidor. Substitui o intervalo.",
|
||||
"downloadSchedulePlaceholder": "ex: 0 0 * * *",
|
||||
"downloadScope": "Estratégia de download",
|
||||
"downloadScopeDescription": "Escolha quais e-mails devem ser indexados e baixados.",
|
||||
"downloadStarted": "Tarefa de download iniciada",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "A cada {{minutes}} minutos",
|
||||
"field": "Campo",
|
||||
"fixed": "Fixo",
|
||||
"folderLimit": "Limite de Pasta",
|
||||
"folderLimitDescription": "Limita o número de emails a sincronizar por pasta (mínimo 100). Deixe vazio para nenhum limite.",
|
||||
"folderLimitPlaceholder": "Ex: 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Selecionar automaticamente os descendentes",
|
||||
"autoSelectParents": "Selecionar automaticamente os pais",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Salvar Alterações",
|
||||
"scheduleMode": "Agendamento de download",
|
||||
"scheduleModeCron": "Expressão Cron",
|
||||
"scheduleModeDescription": "Download por intervalos fixos ou expressões Cron.",
|
||||
"scheduleModeInterval": "Intervalo fixo",
|
||||
"selectAccountType": "Selecionar tipo de conta",
|
||||
"selectAtLeastOneFolder": "Por favor, selecione pelo menos uma pasta",
|
||||
"selectAuthMethod": "Selecionar Método de Autenticação",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "O endereço de email é obrigatório",
|
||||
"folderLimitMustBeAtLeast100": "O Limite de Pasta deve ser pelo menos 100",
|
||||
"folderLimitMustBeNumber": "O Limite de Pasta deve ser um número",
|
||||
"imapHostCannotBeEmpty": "O Host IMAP não pode estar vazio",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "URL inválido",
|
||||
"passwordMinLength": "A senha deve ter pelo menos {{min}} caracteres",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "O tamanho do lote deve ser no máximo 200",
|
||||
"singleRequestBatchSizeTooSmall": "O tamanho do lote deve ser pelo menos 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Продолжить",
|
||||
"createdAt": "Создано",
|
||||
"creationFailed": "Ошибка создания, попробуйте позже",
|
||||
"cronAdvanced": "Расширенное выражение",
|
||||
"cronDaily": "Ежедневно",
|
||||
"cronDayOfMonth": "День недели",
|
||||
"cronDayOfWeek": "День недели",
|
||||
"cronFrequency": "Частота",
|
||||
"cronFriday": "Пятница",
|
||||
"cronHour": "Час",
|
||||
"cronMinute": "Минута",
|
||||
"cronMonday": "Понедельник",
|
||||
"cronMonthly": "Ежемесячно",
|
||||
"cronSaturday": "Суббота",
|
||||
"cronSimple": "Простое выражение",
|
||||
"cronSunday": "Воскресенье",
|
||||
"cronThursday": "Четверг",
|
||||
"cronTimezoneNote": "Время по местному часовому поясу сервера.",
|
||||
"cronTuesday": "Вторник",
|
||||
"cronWednesday": "Среда",
|
||||
"cronWeekly": "Еженедельно",
|
||||
"dateSelection": "Выбор даты",
|
||||
"dateSince": "Дата с",
|
||||
"days": "Дни",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Не удалось запустить задачу загрузки",
|
||||
"downloadInterval": "Интервал загрузки (мин.)",
|
||||
"downloadIntervalPlaceholder": "Введите минуты",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "6-польный Cron (сек мин час день мес день-нед) по времени сервера. Меняет интервал.",
|
||||
"downloadSchedulePlaceholder": "напр., 0 0 * * *",
|
||||
"downloadScope": "Стратегия загрузки",
|
||||
"downloadScopeDescription": "Выберите, какие электронные письма должны быть проиндексированы и скачаны.",
|
||||
"downloadStarted": "Задача загрузки запущена",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "каждые {{minutes}} мин.",
|
||||
"field": "Поле",
|
||||
"fixed": "Фиксированная",
|
||||
"folderLimit": "Лимит папки",
|
||||
"folderLimitDescription": "Ограничить количество писем для синхронизации в папке (минимум 100). Оставьте пустым для снятия ограничений.",
|
||||
"folderLimitPlaceholder": "например, 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Автоматически выбирать дочерние элементы",
|
||||
"autoSelectParents": "Автоматически выбирать родительские элементы",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Сохранить изменения",
|
||||
"scheduleMode": "Расписание загрузки",
|
||||
"scheduleModeCron": "Выражение Cron",
|
||||
"scheduleModeDescription": "Загрузка с фиксированным интервалом или по Cron.",
|
||||
"scheduleModeInterval": "Фиксированный интервал",
|
||||
"selectAccountType": "Выберите тип аккаунта",
|
||||
"selectAtLeastOneFolder": "Пожалуйста, выберите хотя бы одну папку",
|
||||
"selectAuthMethod": "Выберите метод авторизации",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "Email обязателен",
|
||||
"folderLimitMustBeAtLeast100": "Лимит папки должен быть не менее 100",
|
||||
"folderLimitMustBeNumber": "Лимит папки должен быть числом",
|
||||
"imapHostCannotBeEmpty": "IMAP хост не может быть пустым",
|
||||
"imapHostRequired": "IMAP хост обязателен",
|
||||
"imapPortMustBeLessThan65536": "IMAP порт должен быть меньше 65536",
|
||||
"imapPortMustBePositive": "IMAP порт должен быть положительным целым числом",
|
||||
"incrementalSyncMustBeAtLeast10": "Интервал инкрементальной синхронизации должен быть не менее 10 минут",
|
||||
"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": "Неверный адрес электронной почты",
|
||||
"invalidUrl": "Неверный URL",
|
||||
"passwordMinLength": "Пароль должен быть не менее {{min}} символов",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Размер пакета должен быть не более 200",
|
||||
"singleRequestBatchSizeTooSmall": "Размер пакета должен быть не менее 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "Fortsätt",
|
||||
"createdAt": "Skapad",
|
||||
"creationFailed": "Skapande misslyckades, försök igen senare",
|
||||
"cronAdvanced": "Avancerat uttryck",
|
||||
"cronDaily": "Dagligen",
|
||||
"cronDayOfMonth": "Veckodag",
|
||||
"cronDayOfWeek": "Veckodag",
|
||||
"cronFrequency": "Frekvens",
|
||||
"cronFriday": "Fredag",
|
||||
"cronHour": "Timme",
|
||||
"cronMinute": "Minut",
|
||||
"cronMonday": "Måndag",
|
||||
"cronMonthly": "Månadsvis",
|
||||
"cronSaturday": "Lördag",
|
||||
"cronSimple": "Enkelt uttryck",
|
||||
"cronSunday": "Söndag",
|
||||
"cronThursday": "Torsdag",
|
||||
"cronTimezoneNote": "Alla tider visas i serverns lokaltid.",
|
||||
"cronTuesday": "Tisdag",
|
||||
"cronWednesday": "Onsdag",
|
||||
"cronWeekly": "Veckovis",
|
||||
"dateSelection": "Datumsval",
|
||||
"dateSince": "Datum från",
|
||||
"days": "Dagar",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "Misslyckades med att starta hämtningsuppgift",
|
||||
"downloadInterval": "Nedladdningsintervall (minuter)",
|
||||
"downloadIntervalPlaceholder": "Ange minuter",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "6-fälts Cron (sek min tim dag mån veckodag) i servertid. Ersätter intervall.",
|
||||
"downloadSchedulePlaceholder": "t.ex. 0 0 * * *",
|
||||
"downloadScope": "Nedladdningsstrategi",
|
||||
"downloadScopeDescription": "Välj vilka e-postmeddelanden som ska indexeras och laddas ner.",
|
||||
"downloadStarted": "Hämtningsuppgift startad",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "varje {{minutes}} minut",
|
||||
"field": "Fält",
|
||||
"fixed": "Fast",
|
||||
"folderLimit": "Mappgräns",
|
||||
"folderLimitDescription": "Begränsa antalet e-postmeddelanden att synkronisera per mapp (minst 100). Lämna tomt för ingen gräns.",
|
||||
"folderLimitPlaceholder": "t.ex. 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "Välj underordnade automatiskt",
|
||||
"autoSelectParents": "Välj överordnade automatiskt",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "Spara ändringar",
|
||||
"scheduleMode": "Hämtningsschema",
|
||||
"scheduleModeCron": "Cron-uttryck",
|
||||
"scheduleModeDescription": "Hämta med fasta intervall eller Cron-uttryck.",
|
||||
"scheduleModeInterval": "Fast intervall",
|
||||
"selectAccountType": "Välj kontotyp",
|
||||
"selectAtLeastOneFolder": "Vänligen välj minst en mapp",
|
||||
"selectAuthMethod": "Välj en autentiseringsmetod",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-post krävs",
|
||||
"folderLimitMustBeAtLeast100": "Mappgräns måste vara minst 100",
|
||||
"folderLimitMustBeNumber": "Mappgräns måste vara ett tal",
|
||||
"imapHostCannotBeEmpty": "IMAP-värd kan inte vara tom",
|
||||
"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",
|
||||
"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",
|
||||
"invalidUrl": "Ogiltig URL",
|
||||
"passwordMinLength": "Lösenordet måste vara minst {{min}} tecken långt",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batchstorlek måste vara högst 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batchstorlek måste vara minst 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "繼續",
|
||||
"createdAt": "建立時間",
|
||||
"creationFailed": "建立失敗,請稍後再試。",
|
||||
"cronAdvanced": "高級表達式",
|
||||
"cronDaily": "每天",
|
||||
"cronDayOfMonth": "星期",
|
||||
"cronDayOfWeek": "星期",
|
||||
"cronFrequency": "頻率",
|
||||
"cronFriday": "星期五",
|
||||
"cronHour": "小時",
|
||||
"cronMinute": "分鐘",
|
||||
"cronMonday": "星期一",
|
||||
"cronMonthly": "每月",
|
||||
"cronSaturday": "星期六",
|
||||
"cronSimple": "简易表達式",
|
||||
"cronSunday": "星期日",
|
||||
"cronThursday": "星期四",
|
||||
"cronTimezoneNote": "所有時間均使用伺服器在地時區。",
|
||||
"cronTuesday": "星期二",
|
||||
"cronWednesday": "星期三",
|
||||
"cronWeekly": "每周",
|
||||
"dateSelection": "日期選擇",
|
||||
"dateSince": "同步起始日期",
|
||||
"days": "天",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "啟動下載任務失敗",
|
||||
"downloadInterval": "下載週期 (分鐘)",
|
||||
"downloadIntervalPlaceholder": "請輸入分鐘數",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "伺服器時間的 6 位 Cron 表達式(秒分時日月週),提供時覆蓋間隔設定。",
|
||||
"downloadSchedulePlaceholder": "例如:0 0 * * *",
|
||||
"downloadScope": "下載策略",
|
||||
"downloadScopeDescription": "選擇哪些郵件應被索引和下載。",
|
||||
"downloadStarted": "下載任務已啟動",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "每 {{minutes}} 分鐘",
|
||||
"field": "欄位",
|
||||
"fixed": "固定",
|
||||
"folderLimit": "資料夾限制",
|
||||
"folderLimitDescription": "限制每個資料夾要同步的郵件數量(最小 100)。若無限制請留空。",
|
||||
"folderLimitPlaceholder": "例如:1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "自動選取子項",
|
||||
"autoSelectParents": "自動選取父項",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "儲存變更",
|
||||
"scheduleMode": "下載排程",
|
||||
"scheduleModeCron": "Cron 表達式",
|
||||
"scheduleModeDescription": "設定固定間隔或 Cron 表達式下載郵件。",
|
||||
"scheduleModeInterval": "固定間隔",
|
||||
"selectAccountType": "選擇郵件帳戶類型",
|
||||
"selectAtLeastOneFolder": "請至少選擇一個資料夾",
|
||||
"selectAuthMethod": "選擇驗證方法",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "電子郵件地址為必填項",
|
||||
"folderLimitMustBeAtLeast100": "資料夾限制必須至少為 100",
|
||||
"folderLimitMustBeNumber": "資料夾限制必須是數字",
|
||||
"imapHostCannotBeEmpty": "IMAP 主機不可為空",
|
||||
"imapHostRequired": "IMAP 主機為必填項",
|
||||
"imapPortMustBeLessThan65536": "IMAP 連接埠必須小於 65536",
|
||||
"imapPortMustBePositive": "IMAP 連接埠必須是正整數",
|
||||
"incrementalSyncMustBeAtLeast10": "增量同步間隔必須至少為 10 分鐘",
|
||||
"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": "無效的電子郵件地址",
|
||||
"invalidUrl": "無效的網址",
|
||||
"passwordMinLength": "密碼長度必須至少 {{min}} 個字元",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "批次大小必須最多為200",
|
||||
"singleRequestBatchSizeTooSmall": "批次大小必須至少為10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
"continue": "继续",
|
||||
"createdAt": "创建时间",
|
||||
"creationFailed": "创建失败,请稍后重试",
|
||||
"cronAdvanced": "高级表达式",
|
||||
"cronDaily": "每天",
|
||||
"cronDayOfMonth": "星期",
|
||||
"cronDayOfWeek": "星期",
|
||||
"cronFrequency": "频率",
|
||||
"cronFriday": "星期五",
|
||||
"cronHour": "小时",
|
||||
"cronMinute": "分钟",
|
||||
"cronMonday": "星期一",
|
||||
"cronMonthly": "每月",
|
||||
"cronSaturday": "星期六",
|
||||
"cronSimple": "简易表达式",
|
||||
"cronSunday": "星期日",
|
||||
"cronThursday": "星期四",
|
||||
"cronTimezoneNote": "所有时间均使用服务器本地时区。",
|
||||
"cronTuesday": "星期二",
|
||||
"cronWednesday": "星期三",
|
||||
"cronWeekly": "每周",
|
||||
"dateSelection": "日期选择",
|
||||
"dateSince": "起始日期",
|
||||
"days": "天",
|
||||
@@ -133,6 +151,9 @@
|
||||
"downloadFailed": "启动下载任务失败",
|
||||
"downloadInterval": "下载周期 (分钟)",
|
||||
"downloadIntervalPlaceholder": "请输入分钟数",
|
||||
"downloadSchedule": "Cron Schedule",
|
||||
"downloadScheduleDescription": "服务器时间的 6 位 Cron 表达式(秒分时日月周),提供时覆盖间隔设置。",
|
||||
"downloadSchedulePlaceholder": "例如:0 0 * * *",
|
||||
"downloadScope": "下载策略",
|
||||
"downloadScopeDescription": "选择哪些邮件应被索引和下载。",
|
||||
"downloadStarted": "下载任务已启动",
|
||||
@@ -156,9 +177,6 @@
|
||||
"everyMinutes": "每 {{minutes}} 分钟",
|
||||
"field": "字段",
|
||||
"fixed": "固定",
|
||||
"folderLimit": "文件夹限制",
|
||||
"folderLimitDescription": "限制每个文件夹同步的邮件数量(最少 100)。留空表示不限制。",
|
||||
"folderLimitPlaceholder": "例如 1000",
|
||||
"folderSync": {
|
||||
"autoSelectDescendants": "自动选择子项",
|
||||
"autoSelectParents": "自动选择父项",
|
||||
@@ -247,6 +265,10 @@
|
||||
}
|
||||
},
|
||||
"saveChanges": "保存更改",
|
||||
"scheduleMode": "下载调度",
|
||||
"scheduleModeCron": "Cron 表达式",
|
||||
"scheduleModeDescription": "设置固定间隔或 Cron 表达式下载邮件。",
|
||||
"scheduleModeInterval": "固定间隔",
|
||||
"selectAccountType": "选择邮件账户类型",
|
||||
"selectAtLeastOneFolder": "请至少选择一个文件夹",
|
||||
"selectAuthMethod": "选择认证方法",
|
||||
@@ -1650,14 +1672,13 @@
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "邮箱为必填项",
|
||||
"folderLimitMustBeAtLeast100": "文件夹限制必须至少为 100",
|
||||
"folderLimitMustBeNumber": "文件夹限制必须是数字",
|
||||
"imapHostCannotBeEmpty": "IMAP 主机不能为空",
|
||||
"imapHostRequired": "IMAP 主机为必填项",
|
||||
"imapPortMustBeLessThan65536": "IMAP 端口必须小于 65536",
|
||||
"imapPortMustBePositive": "IMAP 端口必须是正整数",
|
||||
"incrementalSyncMustBeAtLeast10": "增量同步间隔必须至少为 10 分钟",
|
||||
"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": "无效的电子邮件地址",
|
||||
"invalidUrl": "无效的 URL",
|
||||
"passwordMinLength": "密码长度至少为 {{min}} 个字符",
|
||||
@@ -1669,4 +1690,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "批大小必须最多为200",
|
||||
"singleRequestBatchSizeTooSmall": "批大小必须至少为10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user