26 Commits
1.2.0 ... 1.4.2

Author SHA1 Message Date
rustmailer
d40ba90b54 fix: inline attachment detection and account-scoped export
- Treat MIME parts with Content-ID but no Content-Disposition as inline
  - Add account_ids filter to CLI export search to avoid pulling all accounts
  - Skip failed emails during export instead of aborting the entire batch
2026-05-29 16:13:19 +08:00
rustmailer
048d5f361c fix: Cant migrate with version >= 1.4.0 #277 2026-05-28 22:49:24 +08:00
rustmailer
4597df515a Update content.rs 2026-05-28 17:55:13 +08:00
rustmailer
f4be4a2e8c bump to 1.4.1 2026-05-28 17:49:18 +08:00
rustmailer
4a3c42c1eb fix: HTTP Error 500 Internal Server Error: Failed for 58344335-2e86-4009-979d-6da0331bff63 - Failed to export an email. Aborting process... #275 2026-05-28 17:47:10 +08:00
rustmailer
8fcb55320f Update README.md 2026-05-28 02:09:58 +08:00
rustmailer
86869ac848 Update release.yml 2026-05-26 20:11:49 +08:00
rustmailer
38453accc6 Update release.yml 2026-05-26 20:07:40 +08:00
rustmailer
a60b2c7dc4 Update release.yml 2026-05-26 20:02:55 +08:00
rustmailer
8e46c7a162 fix: use valid IMAP UID SEARCH instead of BEFORE in UID FETCH for incremental sync 2026-05-26 19:42:26 +08:00
rustmailer
1a615e1c45 bump to v1.4.0 2026-05-26 15:27:34 +08:00
rustmailer
83dd9cdd6b perf: optimize IMAP account fetch flow 2026-05-26 15:23:34 +08:00
rustmailer
f30cd66e00 feat: remove folder limit 2026-05-26 15:22:14 +08:00
rustmailer
4bd714a670 chore: remove custom global allocator 2026-05-26 15:20:59 +08:00
rustmailer
c0a63a1e3c feat: enhance autoconfig detection 2026-05-26 15:19:39 +08:00
rustmailer
04a022e850 add features endpoint 2026-05-24 20:31:58 +08:00
rustmailer
f9c2fc77ff feat(core): wire up attachment text extraction in IMAP sync pipeline 2026-05-24 19:52:28 +08:00
rustmailer
ec3e842bbb chore: add trace logging for duplicate email diagnosis #214 2026-05-24 17:27:01 +08:00
rustmailer
7311529908 bump to v1.3.0 2026-05-24 16:50:15 +08:00
rustmailer
0792bb546d perf: reduce tokio worker thread blocking to improve responsiveness on low-core machines
- Switch memdb durability from Full to Batch(100) with 10s flush worker
  - Offload BlobManager fjall writes to spawn_blocking
  - Wrap Tantivy commit operations in block_in_place
  - Flush memdb WAL on graceful shutdown
2026-05-24 14:34:16 +08:00
rustmailer
0be2670600 update 2026-05-24 02:37:22 +08:00
rustmailer
575f851cfb update 2026-05-24 02:27:52 +08:00
rustmailer
61430b72b0 feat(core): add ext module with EventBus and AttachmentTextExtractor traits 2026-05-24 02:04:28 +08:00
rustmailer
15c0cfc1d9 Update README.md 2026-05-23 15:57:39 +08:00
rustmailer
ea8c493374 Update .gitignore 2026-05-23 15:41:39 +08:00
rustmailer
005b1c2116 feat: added Cron scheduling for email downloads #211 2026-05-23 15:40:46 +08:00
75 changed files with 3010 additions and 986 deletions

4
.gitignore vendored
View File

@@ -1,4 +1,6 @@
/target
.vscode
.idea
config.toml
config.toml
node_modules
dedup_report.txt

59
Cargo.lock generated
View File

@@ -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"

View File

@@ -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
View File

@@ -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

View File

@@ -1,2 +1,2 @@
base_url = "http://localhost:15630"
api_token = "WuqNC0g8yNle7CVnxcvjUwjN"
api_token = "eErI7WN3PtKeLwWAbIfSXCP6"

View File

@@ -17,5 +17,4 @@ serde_json.workspace = true
itertools.workspace = true
snafu.workspace = true
memdb.workspace = true
mimalloc = "0.1.50"
memdb.workspace = true

View File

@@ -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();

View File

@@ -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,
}
}
}

View File

@@ -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),

View File

@@ -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);
}

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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());
}
}

View File

@@ -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");
}
}

View File

@@ -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,
}
}
}

View File

@@ -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::*;

View 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
}

View File

@@ -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,
})
}

View File

@@ -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;

View 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());
}
}

View File

@@ -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");
}

View File

@@ -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));
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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}");
}
}
}

View File

@@ -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);
}
}

View 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);
}

View 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;

View 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)
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -1,4 +1,5 @@
pub mod account;
pub mod ext;
pub mod admin;
pub mod autoconfig;
pub mod cache;

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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,
});
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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!(

View File

@@ -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> {

View File

@@ -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
View 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(())
}

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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))
}

View 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(),
})
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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)', () => {

View File

@@ -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>

View File

@@ -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" });

View File

@@ -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,
},
{

View File

@@ -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)

View File

@@ -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<

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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"
}
}
}

View File

@@ -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": "Batchstørrelse skal være højst 200",
"singleRequestBatchSizeTooSmall": "Batchstørrelse skal være mindst 10"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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でなければなりません"
}
}
}

View File

@@ -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이어야 합니다"
}
}
}

View File

@@ -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": "Batchgrootte moet hoogstens 200 zijn",
"singleRequestBatchSizeTooSmall": "Batchgrootte moet ten minste 10 zijn"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}