mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fcc9e0e8e | ||
|
|
220aa268c1 | ||
|
|
e3fd9d2f29 | ||
|
|
41c3b84e65 | ||
|
|
89557700ae | ||
|
|
2f5de48c6a | ||
|
|
debb119d3d | ||
|
|
ce3f8944a3 | ||
|
|
6f572b15ae | ||
|
|
3bfc080258 | ||
|
|
c3a725770c | ||
|
|
c736afffb0 | ||
|
|
62cb5264fd | ||
|
|
aebb94ee4e | ||
|
|
769630f9d7 | ||
|
|
368b18c45f | ||
|
|
42861f6cc9 | ||
|
|
327a3f39d9 | ||
|
|
427f7248d2 | ||
|
|
a2a51a2037 | ||
|
|
4d783d5301 | ||
|
|
4172f11f00 | ||
|
|
257736a47b | ||
|
|
e8469da3bc | ||
|
|
1346dd216a | ||
|
|
d40ba90b54 | ||
|
|
048d5f361c | ||
|
|
4597df515a | ||
|
|
f4be4a2e8c | ||
|
|
4a3c42c1eb | ||
|
|
8fcb55320f |
42
Cargo.lock
generated
42
Cargo.lock
generated
@@ -293,7 +293,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bichon-admin"
|
||||
version = "1.4.0"
|
||||
version = "1.5.3"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"console",
|
||||
@@ -311,7 +311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-cli"
|
||||
version = "1.4.0"
|
||||
version = "1.5.3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
@@ -337,7 +337,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-core"
|
||||
version = "1.4.0"
|
||||
version = "1.5.3"
|
||||
dependencies = [
|
||||
"async-imap",
|
||||
"base64 0.22.1",
|
||||
@@ -396,7 +396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-server"
|
||||
version = "1.4.0"
|
||||
version = "1.5.3"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"bichon-smtp",
|
||||
@@ -420,7 +420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-smtp"
|
||||
version = "1.4.0"
|
||||
version = "1.5.3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
@@ -656,9 +656,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
@@ -1295,9 +1295,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fjall"
|
||||
version = "3.1.4"
|
||||
version = "3.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b62b25b4d815ae178d7d9e4aa32ee59f072efd5431c736abede1e6ee13c8c453"
|
||||
checksum = "038acd422d607e0eca09e093f299f9eccf9bd097554343d93746afff81a45113"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"byteview",
|
||||
@@ -1789,9 +1789,9 @@ checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.1"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
dependencies = [
|
||||
"bytes 1.11.1",
|
||||
"itoa",
|
||||
@@ -2315,9 +2315,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lsm-tree"
|
||||
version = "3.1.4"
|
||||
version = "3.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e447ac67ff6aef4ec07fc19e507b219336cbba90a697c0dbeb1bf51b91536b67"
|
||||
checksum = "8ef86c3c797c10eefcc73407c43ae48c19d4df686131a8334b2895a513e91df4"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"bytes 1.11.1",
|
||||
@@ -4115,18 +4115,18 @@ checksum = "97c9f5dd7ec5cc6d743f33fcb96de4eb91bb1cc51c5e0ba40cb285a9012043da"
|
||||
|
||||
[[package]]
|
||||
name = "snafu"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1d4bced6a69f90b2056c03dcff2c4737f98d6fb9e0853493996e1d253ca29c6"
|
||||
checksum = "d1a012328be2e3f5d5f6f3218147ca02588cea4cb865e876849ab6debcf36522"
|
||||
dependencies = [
|
||||
"snafu-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "snafu-derive"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54254b8531cafa275c5e096f62d48c81435d1015405a91198ddb11e967301d40"
|
||||
checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@@ -4639,9 +4639,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-socks"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f"
|
||||
checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615"
|
||||
dependencies = [
|
||||
"either",
|
||||
"futures-util",
|
||||
@@ -5039,9 +5039,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
version = "1.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
|
||||
14
Cargo.toml
14
Cargo.toml
@@ -12,11 +12,11 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.4.0"
|
||||
version = "1.5.3"
|
||||
edition = "2021"
|
||||
|
||||
[workspace.dependencies]
|
||||
chrono = "0.4.44"
|
||||
chrono = "0.4.45"
|
||||
clap = { version = "4.6.1", features = ["derive", "env"] }
|
||||
memdb = { path = "crates/memdb" }
|
||||
itertools = "0.14.0"
|
||||
@@ -28,7 +28,7 @@ tracing = "0.1.44"
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||
base64 = "0.22.1"
|
||||
snafu = "0.9.0"
|
||||
snafu = "0.9.1"
|
||||
reqwest = { version = "0.12.24", default-features = false, features = [
|
||||
"json",
|
||||
"stream",
|
||||
@@ -37,8 +37,8 @@ reqwest = { version = "0.12.24", default-features = false, features = [
|
||||
"blocking",
|
||||
"socks",
|
||||
] }
|
||||
tokio-socks = "0.5.2"
|
||||
http = "1.4.1"
|
||||
tokio-socks = "0.5.3"
|
||||
http = "1.4.2"
|
||||
regex = "1.12.3"
|
||||
email_address = "0.2.9"
|
||||
futures = "0.3.32"
|
||||
@@ -84,8 +84,8 @@ mail-send = "0.6.0"
|
||||
rcgen = "0.14.8"
|
||||
rustls-pemfile = "2.2.0"
|
||||
blake3 = "1.8.5"
|
||||
uuid = { version = "1.23.1", features = ["v4", "serde"] }
|
||||
fjall = { version = "3.1.4", features = ["lz4", "metrics", "bytes_1"] }
|
||||
uuid = { version = "1.23.2", features = ["v4", "serde"] }
|
||||
fjall = { version = "3.1.5", features = ["lz4", "metrics", "bytes_1"] }
|
||||
tracing-log = "0.2.0"
|
||||
tokio-util = "0.7.18"
|
||||
indicatif = "0.18.4"
|
||||
|
||||
93
README.md
93
README.md
@@ -449,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).
|
||||
|
||||
@@ -485,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
|
||||
@@ -532,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]
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
base_url = "http://localhost:15630"
|
||||
api_token = "WuqNC0g8yNle7CVnxcvjUwjN"
|
||||
api_token = "eErI7WN3PtKeLwWAbIfSXCP6"
|
||||
|
||||
@@ -117,6 +117,7 @@ pub struct AccountV3 {
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
pub date_since: Option<DateSince>,
|
||||
pub date_before: Option<RelativeDate>,
|
||||
pub folder_limit: Option<u32>,
|
||||
pub sync_folders: Option<Vec<String>>,
|
||||
pub account_type: AccountType,
|
||||
pub sync_interval_min: Option<i64>,
|
||||
@@ -192,7 +193,7 @@ impl From<AccountV3> for AccountV2 {
|
||||
name: value.name,
|
||||
capabilities: value.capabilities,
|
||||
date_since: value.date_since,
|
||||
folder_limit: None,
|
||||
folder_limit: value.folder_limit,
|
||||
sync_folders: value.sync_folders,
|
||||
account_type: value.account_type,
|
||||
sync_interval_min: value.sync_interval_min,
|
||||
@@ -216,6 +217,7 @@ impl From<AccountV2> for AccountV3 {
|
||||
name: value.name,
|
||||
capabilities: value.capabilities,
|
||||
date_since: value.date_since,
|
||||
folder_limit: value.folder_limit,
|
||||
sync_folders: value.sync_folders,
|
||||
account_type: value.account_type,
|
||||
sync_interval_min: value.sync_interval_min,
|
||||
@@ -248,6 +250,7 @@ impl From<AccountV3> for AccountModel {
|
||||
account_type: value.account_type,
|
||||
download_interval_min: value.sync_interval_min,
|
||||
download_batch_size: value.sync_batch_size,
|
||||
max_email_size_bytes: None,
|
||||
known_folders: value.known_folders,
|
||||
created_at: value.created_at,
|
||||
updated_at: value.updated_at,
|
||||
@@ -259,6 +262,7 @@ impl From<AccountV3> for AccountModel {
|
||||
imap_quota_bytes: None,
|
||||
auto_download_new_mailboxes: None,
|
||||
download_schedule: None,
|
||||
deleting: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use bichon_core::migrate::{
|
||||
count_eml_segments, do_migrate_segment, is_tantivy_index_dir,
|
||||
store::{LegacyDirs, NewDirs},
|
||||
store::{LegacyDirs, NewDirs, NewIndexWriter},
|
||||
};
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
||||
@@ -326,6 +326,18 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
|
||||
let mut writer = match NewIndexWriter::open(NewDirs::new(
|
||||
new_index_path.clone(),
|
||||
new_data_path.clone(),
|
||||
)) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
pb.finish_with_message(format!("{}", style("Migration failed.").red()));
|
||||
eprintln!("\n{} {:?}", style("✘").red().bold(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut grand_total_migrated: usize = 0;
|
||||
let mut grand_total_skipped: usize = 0;
|
||||
|
||||
@@ -337,7 +349,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
match do_migrate_segment(
|
||||
batch_size,
|
||||
legacy,
|
||||
NewDirs::new(new_index_path.clone(), new_data_path.clone()),
|
||||
&mut writer,
|
||||
seg_idx,
|
||||
|msg| {
|
||||
if let Some(data) = msg.strip_prefix("TOTAL:") {
|
||||
@@ -407,6 +419,13 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
pb.set_position((seg_idx + 1) as u64);
|
||||
}
|
||||
|
||||
pb.set_message(style("Finalizing indexes...").dim().to_string());
|
||||
if let Err(e) = writer.finish_writers() {
|
||||
pb.finish_with_message(format!("{}", style("Migration failed.").red()));
|
||||
eprintln!("\n{} {:?}", style("✘").red().bold(), e);
|
||||
return;
|
||||
}
|
||||
|
||||
pb.finish_with_message(format!(
|
||||
"Migration finished. Total: {}, Skipped: {}",
|
||||
grand_total_migrated, grand_total_skipped
|
||||
|
||||
@@ -10,13 +10,17 @@ use crate::BichonCliConfig;
|
||||
pub async fn search_messages(
|
||||
client: &Client,
|
||||
config: &BichonCliConfig,
|
||||
account_ids: Option<std::collections::HashSet<u64>>,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> Option<DataPage<Envelope>> {
|
||||
let url = format!("{}/api/v1/search-messages", config.base_url);
|
||||
|
||||
let payload = EmailSearchRequest {
|
||||
filter: EmailSearchFilter::default(),
|
||||
filter: EmailSearchFilter {
|
||||
account_ids,
|
||||
..Default::default()
|
||||
},
|
||||
page,
|
||||
page_size,
|
||||
sort_by: Some(SortBy::DATE),
|
||||
|
||||
@@ -146,20 +146,23 @@ pub async fn handle_account_export(
|
||||
let mut total_pages;
|
||||
|
||||
loop {
|
||||
if let Some(batch) = search_messages(&client, config, current_page, page_size).await {
|
||||
let account_ids = Some(std::collections::HashSet::from([account.id]));
|
||||
if let Some(batch) = search_messages(&client, config, account_ids, current_page, page_size).await {
|
||||
total_pages = batch.total_pages.unwrap();
|
||||
|
||||
pb.set_message(format!("Page {}/{}", current_page, total_pages));
|
||||
|
||||
for envelope in batch.items {
|
||||
let success =
|
||||
download_and_export_with_json_header(&client, config, envelope, &mut file)
|
||||
download_and_export_with_json_header(&client, config, envelope.clone(), &mut file)
|
||||
.await;
|
||||
|
||||
if !success {
|
||||
pb.finish_with_message("Failed");
|
||||
eprintln!(" ✘ Failed to export an email. Aborting process...");
|
||||
return;
|
||||
eprintln!(
|
||||
" ✘ Failed to export email {}, skipping...",
|
||||
envelope.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
pb.inc(1);
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ pub struct Account {
|
||||
pub account_type: AccountType,
|
||||
pub download_interval_min: Option<i64>,
|
||||
pub download_batch_size: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub max_email_size_bytes: Option<u64>,
|
||||
pub known_folders: Option<BTreeSet<String>>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
@@ -95,6 +97,8 @@ pub struct Account {
|
||||
pub imap_quota_window: Option<QuotaWindow>,
|
||||
pub auto_download_new_mailboxes: Option<bool>,
|
||||
pub download_schedule: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deleting: bool,
|
||||
}
|
||||
|
||||
impl MemDbModel for Account {
|
||||
@@ -128,11 +132,13 @@ impl Account {
|
||||
pgp_key: request.pgp_key,
|
||||
created_by: user_id,
|
||||
download_batch_size: request.download_batch_size,
|
||||
max_email_size_bytes: request.max_email_size_bytes,
|
||||
date_before: request.date_before,
|
||||
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,
|
||||
deleting: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,14 +226,46 @@ impl Account {
|
||||
|
||||
pub async fn delete(account_id: u64) -> BichonResult<()> {
|
||||
let account = Self::get(account_id)?;
|
||||
if let Err(error) = Self::cleanup_account_resources_sequential(&account).await {
|
||||
tracing::error!(
|
||||
"[CLEANUP_ACCOUNT_ERROR] Account {}: failed to cleanup resources: {:#?}",
|
||||
account_id,
|
||||
error
|
||||
);
|
||||
return Err(error);
|
||||
|
||||
// Immediately stop scheduling to prevent new downloads
|
||||
if matches!(account.account_type, AccountType::IMAP) {
|
||||
SYNC_TASKS.stop(account.id).await?;
|
||||
}
|
||||
|
||||
// Mark as deleting and disabled so frontend shows status and download tasks skip it
|
||||
update_impl(
|
||||
DB_MANAGER.db(),
|
||||
&account_id.to_string(),
|
||||
move |current: Account| {
|
||||
let mut updated = current.clone();
|
||||
updated.deleting = true;
|
||||
updated.enabled = false;
|
||||
Ok(updated)
|
||||
},
|
||||
)?;
|
||||
|
||||
// Spawn background cleanup — heavy work (Tantivy, attachments) runs off the request path
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = Self::cleanup_account_resources_sequential(&account).await {
|
||||
tracing::error!(
|
||||
"[CLEANUP_ACCOUNT_ERROR] Account {}: cleanup failed, reverting deleting flag: {:#?}",
|
||||
account_id,
|
||||
error
|
||||
);
|
||||
// Revert deleting flag so the user can retry (only if account record still exists)
|
||||
let _ = update_impl(
|
||||
DB_MANAGER.db(),
|
||||
&account_id.to_string(),
|
||||
move |current: Account| {
|
||||
let mut updated = current.clone();
|
||||
updated.deleting = false;
|
||||
updated.enabled = true;
|
||||
Ok(updated)
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -236,8 +274,8 @@ impl Account {
|
||||
}
|
||||
|
||||
async fn cleanup_account_resources_sequential(account: &AccountModel) -> BichonResult<()> {
|
||||
// Sync task already stopped in delete() before spawning this background task
|
||||
if matches!(account.account_type, AccountType::IMAP) {
|
||||
SYNC_TASKS.stop(account.id).await?;
|
||||
DownloadState::delete(account.id)?;
|
||||
}
|
||||
OAuth2AccessToken::try_delete(account.id)?;
|
||||
@@ -395,6 +433,10 @@ impl Account {
|
||||
new.download_batch_size = Some(*download_batch_size);
|
||||
}
|
||||
|
||||
if let Some(max_email_size_bytes) = request.max_email_size_bytes {
|
||||
new.max_email_size_bytes = Some(max_email_size_bytes);
|
||||
}
|
||||
|
||||
if let Some(use_proxy) = request.use_proxy {
|
||||
new.use_proxy = Some(use_proxy);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ pub struct AccountCreateRequest {
|
||||
oai(validator(minimum(value = "10"), maximum(value = "200")))
|
||||
)]
|
||||
pub download_batch_size: Option<u32>,
|
||||
pub max_email_size_bytes: Option<u64>,
|
||||
pub use_proxy: Option<u64>,
|
||||
pub use_dangerous: bool,
|
||||
pub pgp_key: Option<String>,
|
||||
@@ -165,6 +166,7 @@ pub struct AccountUpdateRequest {
|
||||
oai(validator(minimum(value = "10"), maximum(value = "200")))
|
||||
)]
|
||||
pub download_batch_size: Option<u32>,
|
||||
pub max_email_size_bytes: Option<u64>,
|
||||
/// Optional proxy ID for establishing the connection to external APIs (e.g., Gmail, Outlook).
|
||||
/// - If `None` or not provided, the client will connect directly to the API server.
|
||||
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests.
|
||||
|
||||
@@ -44,6 +44,7 @@ pub struct AccountResp {
|
||||
pub account_type: AccountType,
|
||||
pub download_interval_min: Option<i64>,
|
||||
pub download_batch_size: Option<u32>,
|
||||
pub max_email_size_bytes: Option<u64>,
|
||||
pub known_folders: Option<BTreeSet<String>>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
@@ -57,6 +58,7 @@ pub struct AccountResp {
|
||||
pub imap_quota_window: Option<QuotaWindow>,
|
||||
pub auto_download_new_mailboxes: Option<bool>,
|
||||
pub download_schedule: Option<String>,
|
||||
pub deleting: bool,
|
||||
}
|
||||
|
||||
impl AccountResp {
|
||||
@@ -76,6 +78,7 @@ impl AccountResp {
|
||||
account_type: account.account_type,
|
||||
download_interval_min: account.download_interval_min,
|
||||
download_batch_size: account.download_batch_size,
|
||||
max_email_size_bytes: account.max_email_size_bytes,
|
||||
known_folders: account.known_folders,
|
||||
created_at: account.created_at,
|
||||
updated_at: account.updated_at,
|
||||
@@ -93,6 +96,7 @@ impl AccountResp {
|
||||
imap_quota_window: account.imap_quota_window,
|
||||
auto_download_new_mailboxes: account.auto_download_new_mailboxes,
|
||||
download_schedule: account.download_schedule,
|
||||
deleting: account.deleting,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1197
crates/core/src/cache/imap/download/flow.rs
vendored
1197
crates/core/src/cache/imap/download/flow.rs
vendored
File diff suppressed because it is too large
Load Diff
7
crates/core/src/cache/imap/task.rs
vendored
7
crates/core/src/cache/imap/task.rs
vendored
@@ -113,6 +113,9 @@ impl AccountDownTask {
|
||||
let account = AccountModel::get(account_id).ok();
|
||||
match account {
|
||||
Some(account) => {
|
||||
if account.deleting {
|
||||
return Ok(());
|
||||
}
|
||||
if !account.enabled {
|
||||
let last = LAST_WARN_TIME.load(Ordering::Relaxed);
|
||||
let now = utc_now!();
|
||||
@@ -246,6 +249,10 @@ impl AccountDownTask {
|
||||
}
|
||||
};
|
||||
|
||||
if account.deleting {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = process_imap_download(&account, token_clone, TriggerType::Manual).await
|
||||
{
|
||||
error!("Manual download failed for {}: {:?}", account_id, e);
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::imap::executor::ImapExecutor;
|
||||
use crate::message::content::AttachmentInfo;
|
||||
use crate::store::blob::{DetachedEmail, BLOB_MANAGER};
|
||||
use crate::store::tantivy::attachment::ATTACHMENT_MANAGER;
|
||||
use crate::store::tantivy::dedup_cache::DEDUP_CACHE;
|
||||
use crate::store::tantivy::envelope::ENVELOPE_MANAGER;
|
||||
use crate::store::tantivy::model::{AttachmentModel, EnvelopeWithAttachments};
|
||||
use crate::utils::html::extract_text;
|
||||
@@ -50,9 +51,17 @@ pub async fn extract_envelope_and_store_it(
|
||||
.map(|d| d.timestamp_millis())
|
||||
.unwrap_or(0);
|
||||
let uid = fetch.uid.unwrap_or(0);
|
||||
let body = fetch
|
||||
.body()
|
||||
.ok_or_else(|| raise_error!("No body available".into(), ErrorCode::InternalError))?;
|
||||
let body = match fetch.body() {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
uid = fetch.uid,
|
||||
"FETCH response has no body, skipping message"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let size = fetch.size.unwrap_or(body.len() as u32);
|
||||
extract_envelope_core(body, uid, size, internal_date, account_id, mailbox_id).await
|
||||
}
|
||||
@@ -91,6 +100,11 @@ async fn extract_envelope_core(
|
||||
) -> BichonResult<()> {
|
||||
//The content hash of the original raw EML
|
||||
let email_content_hash = compute_content_hash(body);
|
||||
if DEDUP_CACHE.contains(account_id, mailbox_id, &email_content_hash) {
|
||||
tracing::debug!("Duplicate email detected");
|
||||
//println!("Duplicate email detected");
|
||||
return Ok(());
|
||||
}
|
||||
let message: Message<'_> = MessageParser::new().parse(body).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Email header parse result is not available".into(),
|
||||
@@ -252,7 +266,7 @@ async fn extract_envelope_core(
|
||||
tags: (!final_tags.is_empty()).then_some(final_tags),
|
||||
account_email: None,
|
||||
mailbox_name: None,
|
||||
content_hash: email_content_hash,
|
||||
content_hash: email_content_hash.clone(),
|
||||
};
|
||||
// 'attachments' contains both regular and inline attachments
|
||||
let ea = EnvelopeWithAttachments {
|
||||
@@ -269,6 +283,7 @@ async fn extract_envelope_core(
|
||||
&ea.envelope.content_hash,
|
||||
);
|
||||
ENVELOPE_MANAGER.queue(doc).await;
|
||||
DEDUP_CACHE.insert(account_id, mailbox_id, &email_content_hash);
|
||||
for doc in attachment_docs {
|
||||
ATTACHMENT_MANAGER.queue(doc).await;
|
||||
}
|
||||
@@ -412,23 +427,36 @@ pub async fn detach_and_store_attachments(
|
||||
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(false);
|
||||
.unwrap_or_else(|| att.content_id().is_some());
|
||||
let file_type = att
|
||||
.content_type()
|
||||
.map(|ct| {
|
||||
@@ -755,4 +783,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,12 @@ pub enum BichonError {
|
||||
},
|
||||
}
|
||||
|
||||
impl BichonError {
|
||||
pub fn code(&self) -> ErrorCode {
|
||||
match self {
|
||||
BichonError::Generic { code, .. } => *code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type BichonResult<T, E = BichonError> = std::result::Result<T, E>;
|
||||
|
||||
@@ -34,6 +34,25 @@ use std::ops::DerefMut;
|
||||
use tokio::io::BufWriter;
|
||||
use tracing::debug;
|
||||
|
||||
/// Classify an `io::Error` (from TLS stream I/O) for IMAP connection errors.
|
||||
/// `UnexpectedEof` is treated as a network error because many servers skip
|
||||
/// the TLS `close_notify` alert, causing rustls to emit this error when the
|
||||
/// TCP connection is dropped normally.
|
||||
fn classify_io_error(e: &std::io::Error) -> ErrorCode {
|
||||
use std::io::ErrorKind;
|
||||
matches!(
|
||||
e.kind(),
|
||||
ErrorKind::BrokenPipe
|
||||
| ErrorKind::ConnectionReset
|
||||
| ErrorKind::ConnectionAborted
|
||||
| ErrorKind::TimedOut
|
||||
| ErrorKind::UnexpectedEof
|
||||
| ErrorKind::NotConnected
|
||||
)
|
||||
.then_some(ErrorCode::NetworkError)
|
||||
.unwrap_or(ErrorCode::ImapCommandFailed)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Client {
|
||||
inner: ImapClient<Box<dyn SessionStream>>,
|
||||
@@ -141,7 +160,7 @@ impl Client {
|
||||
let _greeting = client
|
||||
.read_response()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_io_error(&e)))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is SSL.".into(),
|
||||
@@ -171,7 +190,7 @@ impl Client {
|
||||
let _greeting = client
|
||||
.read_response()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_io_error(&e)))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"failed to read greeting".into(),
|
||||
@@ -202,7 +221,7 @@ impl Client {
|
||||
let _greeting = client
|
||||
.read_response()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_io_error(&e)))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is STARTTLS.".into(),
|
||||
|
||||
@@ -27,11 +27,29 @@ use crate::{error::BichonResult, imap::manager::ImapConnectionManager};
|
||||
use async_imap::types::Name;
|
||||
use async_imap::Session;
|
||||
use futures::TryStreamExt;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
const BODY_FETCH_COMMAND: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[])";
|
||||
const SIZE_ONLY_FETCH: &str = "(UID RFC822.SIZE)";
|
||||
|
||||
fn classify_imap_error(e: &async_imap::error::Error) -> ErrorCode {
|
||||
match e {
|
||||
async_imap::error::Error::Io(io) => matches!(
|
||||
io.kind(),
|
||||
std::io::ErrorKind::BrokenPipe
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::TimedOut
|
||||
| std::io::ErrorKind::UnexpectedEof
|
||||
)
|
||||
.then_some(ErrorCode::NetworkError)
|
||||
.unwrap_or(ErrorCode::ImapCommandFailed),
|
||||
async_imap::error::Error::ConnectionLost => ErrorCode::NetworkError,
|
||||
_ => ErrorCode::ImapCommandFailed,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImapExecutor;
|
||||
|
||||
@@ -42,11 +60,11 @@ impl ImapExecutor {
|
||||
let list = session
|
||||
.list(Some(""), Some("*"))
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
let result = list
|
||||
.try_collect::<Vec<Name>>()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -58,11 +76,11 @@ impl ImapExecutor {
|
||||
session
|
||||
.examine(mailbox_name)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
let result = session
|
||||
.uid_search(query)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -76,7 +94,7 @@ impl ImapExecutor {
|
||||
session
|
||||
.append(mailbox_name, flags, internaldate, content)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))
|
||||
}
|
||||
|
||||
/// Fetches new mail for a mailbox.
|
||||
@@ -101,7 +119,7 @@ impl ImapExecutor {
|
||||
session
|
||||
.examine(&mailbox.encoded_name())
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
match before {
|
||||
Some(date) => {
|
||||
@@ -131,7 +149,7 @@ impl ImapExecutor {
|
||||
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)
|
||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
||||
})?;
|
||||
|
||||
if results.is_empty() {
|
||||
@@ -183,15 +201,16 @@ impl ImapExecutor {
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
Self::uid_batch_retrieve_emails(
|
||||
let processed = Self::uid_batch_retrieve_emails(
|
||||
session,
|
||||
account.id,
|
||||
mailbox.id,
|
||||
&batch.0,
|
||||
account.max_email_size_bytes,
|
||||
token.clone(),
|
||||
)
|
||||
.await?;
|
||||
count += batch.1;
|
||||
count += processed;
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
@@ -235,15 +254,19 @@ impl ImapExecutor {
|
||||
.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)
|
||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
||||
})?;
|
||||
|
||||
let mut count = 0u64;
|
||||
let mut skipped = 0u64;
|
||||
let mut max_uid: Option<u32> = None;
|
||||
let size_limit = account
|
||||
.max_email_size_bytes
|
||||
.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||
while let Some(fetch) = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
tracing::info!("Account {}: fetch_new_mail stream interrupted.", account.id);
|
||||
@@ -258,6 +281,20 @@ impl ImapExecutor {
|
||||
));
|
||||
}
|
||||
|
||||
let msg_size = fetch.size.unwrap_or(0) as u64;
|
||||
if msg_size > 0 && msg_size > size_limit {
|
||||
tracing::warn!(
|
||||
account_id = account.id,
|
||||
mailbox_id = mailbox.id,
|
||||
uid = fetch.uid,
|
||||
size = msg_size,
|
||||
limit = size_limit,
|
||||
"Skipping oversized email (streaming mode)"
|
||||
);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(uid) = fetch.uid {
|
||||
max_uid = Some(max_uid.unwrap_or(0).max(uid));
|
||||
}
|
||||
@@ -265,7 +302,8 @@ impl ImapExecutor {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
let total = count + skipped;
|
||||
if total == 0 {
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
@@ -278,10 +316,14 @@ impl ImapExecutor {
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
count,
|
||||
total,
|
||||
count,
|
||||
FolderStatus::Success,
|
||||
None,
|
||||
if skipped > 0 {
|
||||
Some(format!("{skipped} email(s) skipped due to size limit"))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -296,6 +338,7 @@ impl ImapExecutor {
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
encoded_mailbox_name: &str,
|
||||
max_email_size_bytes: Option<u64>,
|
||||
token: CancellationToken,
|
||||
max_uid: &mut Option<u32>,
|
||||
) -> BichonResult<usize> {
|
||||
@@ -315,16 +358,55 @@ impl ImapExecutor {
|
||||
encoded_mailbox_name, sequence_set, page, page_size
|
||||
);
|
||||
|
||||
let mut stream = session
|
||||
.fetch(sequence_set.as_str(), BODY_FETCH_COMMAND)
|
||||
let limit = max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||
|
||||
// PASS 1: fetch only SIZE to identify oversized messages
|
||||
let acceptable_uids = {
|
||||
let mut size_stream = session
|
||||
.fetch(sequence_set.as_str(), SIZE_ONLY_FETCH)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
let mut uids: Vec<u32> = Vec::new();
|
||||
while let Some(fetch) = size_stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
{
|
||||
let uid = fetch.uid.unwrap_or(0);
|
||||
let msg_size = fetch.size.unwrap_or(0) as u64;
|
||||
if msg_size == 0 || msg_size <= limit {
|
||||
uids.push(uid);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
mailbox_id,
|
||||
uid,
|
||||
size = msg_size,
|
||||
limit,
|
||||
"Skipping oversized email"
|
||||
);
|
||||
}
|
||||
}
|
||||
uids
|
||||
};
|
||||
|
||||
if acceptable_uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// PASS 2: fetch bodies only for acceptable UIDs
|
||||
let filtered = compress_uid_list(acceptable_uids);
|
||||
let mut body_stream = session
|
||||
.uid_fetch(&filtered, BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
let mut count = 0;
|
||||
while let Some(fetch) = stream
|
||||
while let Some(fetch) = body_stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
tracing::info!("Account {}: UID fetch stream interrupted.", account_id);
|
||||
@@ -347,16 +429,58 @@ impl ImapExecutor {
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
uid_set: &str,
|
||||
max_email_size_bytes: Option<u64>,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<()> {
|
||||
let mut stream = session
|
||||
.uid_fetch(uid_set, BODY_FETCH_COMMAND)
|
||||
) -> BichonResult<u64> {
|
||||
let limit = max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||
|
||||
// PASS 1: fetch only SIZE to identify oversized messages
|
||||
let acceptable_uids = {
|
||||
let mut size_stream = session
|
||||
.uid_fetch(uid_set, SIZE_ONLY_FETCH)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
let mut uids: Vec<u32> = Vec::new();
|
||||
while let Some(fetch) = size_stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
{
|
||||
let uid = fetch.uid.unwrap_or(0);
|
||||
let msg_size = fetch.size.unwrap_or(0) as u64;
|
||||
if msg_size == 0 || msg_size <= limit {
|
||||
uids.push(uid);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
mailbox_id,
|
||||
uid,
|
||||
size = msg_size,
|
||||
limit,
|
||||
"Skipping oversized email"
|
||||
);
|
||||
}
|
||||
}
|
||||
uids
|
||||
};
|
||||
|
||||
if acceptable_uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// PASS 2: fetch bodies only for acceptable UIDs
|
||||
let filtered = compress_uid_list(acceptable_uids);
|
||||
let mut body_stream = session
|
||||
.uid_fetch(&filtered, BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
while let Some(fetch) = stream
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
let mut count = 0u64;
|
||||
while let Some(fetch) = body_stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
tracing::info!("Account {}: UID fetch stream interrupted.", account_id);
|
||||
@@ -366,8 +490,9 @@ impl ImapExecutor {
|
||||
));
|
||||
}
|
||||
extract_envelope_and_store_it(fetch, account_id, mailbox_id).await?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(())
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Fetches the raw RFC822 body of a single message by UID.
|
||||
@@ -383,17 +508,17 @@ impl ImapExecutor {
|
||||
session
|
||||
.examine(encoded_mailbox_name)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
let mut stream = session
|
||||
.uid_fetch(uid.to_string(), BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
let fetch = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("UID {uid} not found on IMAP server"),
|
||||
@@ -415,7 +540,7 @@ impl ImapExecutor {
|
||||
// while stream
|
||||
// .try_next()
|
||||
// .await
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
// .is_some()
|
||||
// {}
|
||||
|
||||
@@ -427,9 +552,41 @@ impl ImapExecutor {
|
||||
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||
ImapConnectionManager::build(account_id).await
|
||||
}
|
||||
|
||||
/// Fetch UID → Message-ID mapping without downloading bodies.
|
||||
/// `uid_set` is an IMAP sequence-set string (e.g. "1:100" or "1,3,5").
|
||||
pub async fn fetch_uid_metadata(
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
uid_set: &str,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<HashMap<u32, Option<String>>> {
|
||||
let mut stream = session
|
||||
.uid_fetch(uid_set, "(UID BODY.PEEK[HEADER])")
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
let mut result = HashMap::new();
|
||||
while let Some(fetch) = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
return Err(raise_error!(
|
||||
"Stream cancelled".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
let uid = fetch.uid.unwrap_or(0);
|
||||
let msg_id = fetch.header().and_then(parse_message_id_header);
|
||||
result.insert(uid, msg_id);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
pub const DEFAULT_BATCH_SIZE: u32 = 30;
|
||||
pub const DEFAULT_MAX_EMAIL_SIZE: u64 = 100 * 1024 * 1024;
|
||||
|
||||
/// Compresses a sorted list of UIDs into an IMAP sequence-set string.
|
||||
/// Consecutive UIDs become ranges (e.g. `1:5`), non-consecutive are
|
||||
@@ -489,6 +646,27 @@ pub fn generate_uid_sequence_hashset(
|
||||
result
|
||||
}
|
||||
|
||||
fn parse_message_id_header(header_bytes: &[u8]) -> Option<String> {
|
||||
let header = std::str::from_utf8(header_bytes).ok()?;
|
||||
for line in header.lines() {
|
||||
if let Some(value) = line
|
||||
.strip_prefix("Message-ID:")
|
||||
.or_else(|| line.strip_prefix("Message-Id:"))
|
||||
.or_else(|| line.strip_prefix("Message-id:"))
|
||||
{
|
||||
// mail_parser strips angle brackets, so we must do the same
|
||||
// to ensure comparisons against the Tantivy index match.
|
||||
let trimmed = value.trim();
|
||||
let stripped = trimmed.strip_prefix('<').unwrap_or(trimmed);
|
||||
let stripped = stripped.strip_suffix('>').unwrap_or(stripped);
|
||||
if !stripped.is_empty() {
|
||||
return Some(stripped.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
@@ -544,4 +722,80 @@ mod test {
|
||||
assert_eq!(batches[2].0, "5");
|
||||
assert_eq!(batches[2].1, 1);
|
||||
}
|
||||
|
||||
// ── parse_message_id_header ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_standard_message_id() {
|
||||
let header = b"Message-ID: <abc123@example.com>\r\n";
|
||||
assert_eq!(
|
||||
parse_message_id_header(header),
|
||||
Some("abc123@example.com".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_message_id_lowercase() {
|
||||
let header = b"Message-Id: <foo@bar.com>\r\n";
|
||||
assert_eq!(
|
||||
parse_message_id_header(header),
|
||||
Some("foo@bar.com".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_message_id_extra_whitespace() {
|
||||
let header = b"Message-ID: <spaces@test.com> \r\n";
|
||||
assert_eq!(
|
||||
parse_message_id_header(header),
|
||||
Some("spaces@test.com".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_message_id_returns_none() {
|
||||
let header = b"Message-ID: <>\r\n";
|
||||
assert_eq!(parse_message_id_header(header), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_header_returns_none() {
|
||||
let header = b"X-Custom: something\r\n";
|
||||
assert_eq!(parse_message_id_header(header), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_body_returns_none() {
|
||||
assert_eq!(parse_message_id_header(b""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_message_id_in_full_header() {
|
||||
// The Message-ID line is in the middle, not at the start.
|
||||
let header = b"From: sender@example.com\r\n\
|
||||
Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
|
||||
Subject: test\r\n\
|
||||
Message-ID: <mid@example.com>\r\n\
|
||||
To: recipient@example.com\r\n\r\n";
|
||||
assert_eq!(
|
||||
parse_message_id_header(header),
|
||||
Some("mid@example.com".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_message_id_only_in_full_header() {
|
||||
// Only a few headers, Message-ID is among them.
|
||||
let header = b"From: a@b.com\r\nMessage-ID: <x@y.com>\r\n\r\n";
|
||||
assert_eq!(parse_message_id_header(header), Some("x@y.com".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_message_id_no_brackets_still_works() {
|
||||
let header = b"Message-ID: plain@example.com\r\n";
|
||||
assert_eq!(
|
||||
parse_message_id_header(header),
|
||||
Some("plain@example.com".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,16 +95,40 @@ impl ImapConnectionManager {
|
||||
|
||||
pub async fn build(account_id: u64) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||
let account = AccountModel::get(account_id)?;
|
||||
let client = match Self::create_client(&account).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
error!(
|
||||
"Failed to create IMAP {}'s client: {:#?}",
|
||||
&account.email, error
|
||||
);
|
||||
return Err(error);
|
||||
let account_email = account.email.clone();
|
||||
|
||||
let mut client = None;
|
||||
for attempt in 0..3u32 {
|
||||
match Self::create_client(&account).await {
|
||||
Ok(c) => {
|
||||
client = Some(c);
|
||||
break;
|
||||
}
|
||||
Err(error) if error.code() == ErrorCode::NetworkError && attempt < 2 => {
|
||||
warn!(
|
||||
"IMAP connection attempt {}/3 to {} failed (network error), retrying...",
|
||||
attempt + 1,
|
||||
account_email
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
error!(
|
||||
"Failed to create IMAP {}'s client: {:#?}",
|
||||
account_email, error
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let client = client.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("Failed to create IMAP {}'s client after 3 attempts", account_email),
|
||||
ErrorCode::NetworkError
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut session = match Self::authenticate(client, &account).await {
|
||||
Ok(session) => session,
|
||||
|
||||
538
crates/core/src/imap/mock_server.rs
Normal file
538
crates/core/src/imap/mock_server.rs
Normal file
@@ -0,0 +1,538 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
//! A minimal scriptable IMAP server for integration testing.
|
||||
//!
|
||||
//! Each instance listens on a random localhost port and responds to a
|
||||
//! pre-configured script of (expected_command, response) pairs. Commands
|
||||
//! are matched by substring — the first matching pattern wins.
|
||||
//!
|
||||
//! # Example
|
||||
//! ```ignore
|
||||
//! let server = MockImapServer::new()
|
||||
//! .greeting("* OK ready\r\n")
|
||||
//! .respond("LOGIN", "A0 OK logged in\r\n")
|
||||
//! .respond("CAPABILITY", "* CAPABILITY IMAP4rev1\r\nA0 OK done\r\n")
|
||||
//! .respond("STATUS", "* STATUS INBOX (MESSAGES 10 UIDVALIDITY 42)\r\nA0 OK\r\n")
|
||||
//! .respond("LOGOUT", "* BYE\r\nA0 OK\r\n")
|
||||
//! .start()
|
||||
//! .await;
|
||||
//!
|
||||
//! let (host, port) = server.addr();
|
||||
//! // connect to host:port with Encryption::None
|
||||
//! ```
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
type Response = Vec<u8>;
|
||||
|
||||
pub struct MockImapServer {
|
||||
greeting: Vec<u8>,
|
||||
script: Vec<(String, Response)>,
|
||||
}
|
||||
|
||||
impl MockImapServer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
greeting: b"* OK Mock IMAP server ready\r\n".to_vec(),
|
||||
script: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the greeting banner sent immediately after connection.
|
||||
pub fn greeting(mut self, banner: impl Into<Vec<u8>>) -> Self {
|
||||
self.greeting = banner.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a script step: when a client command *contains* `pattern` (case-insensitive),
|
||||
/// respond with `response`. Steps are checked in insertion order.
|
||||
pub fn respond(mut self, pattern: impl Into<String>, response: impl Into<Vec<u8>>) -> Self {
|
||||
self.script.push((pattern.into(), response.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Start the server on a random port. Returns a handle whose `addr()` gives
|
||||
/// the `(host, port)` to connect to.
|
||||
pub async fn start(self) -> MockImapServerHandle {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||
let addr = listener.local_addr().expect("local_addr");
|
||||
|
||||
let server = Arc::new(self);
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
let srv = server.clone();
|
||||
tokio::spawn(async move {
|
||||
srv.handle_connection(stream).await;
|
||||
});
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
MockImapServerHandle { addr }
|
||||
}
|
||||
|
||||
async fn handle_connection(&self, mut stream: TcpStream) {
|
||||
let (reader, mut writer) = stream.split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
// Send greeting
|
||||
if writer.write_all(&self.greeting).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
|
||||
let tag = extract_tag(&line).unwrap_or("A0");
|
||||
let matched = self.find_match(&line);
|
||||
if let Some(response) = matched {
|
||||
let substituted = substitute_tag(response, tag);
|
||||
if writer.write_all(&substituted).await.is_err() {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Default: send tagged OK for commands we don't handle
|
||||
let fallback = format!("{tag} OK done\r\n");
|
||||
if writer.write_all(fallback.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_match(&self, line: &str) -> Option<&[u8]> {
|
||||
let line_lower = line.to_lowercase();
|
||||
for (pattern, response) in &self.script {
|
||||
if line_lower.contains(&pattern.to_lowercase()) {
|
||||
return Some(response);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MockImapServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle to a running mock IMAP server. The server stops when this handle
|
||||
/// is dropped.
|
||||
pub struct MockImapServerHandle {
|
||||
addr: SocketAddr,
|
||||
}
|
||||
|
||||
impl MockImapServerHandle {
|
||||
pub fn host(&self) -> String {
|
||||
self.addr.ip().to_string()
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.addr.port()
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_tag(line: &str) -> Option<&str> {
|
||||
line.split_whitespace().next()
|
||||
}
|
||||
|
||||
/// Replace `{TAG}` placeholders in `response` with `tag`.
|
||||
fn substitute_tag(response: &[u8], tag: &str) -> Vec<u8> {
|
||||
let placeholder = b"{TAG}";
|
||||
if response.is_empty() || !contains_slice(response, placeholder) {
|
||||
return response.to_vec();
|
||||
}
|
||||
let tag_bytes = tag.as_bytes();
|
||||
let mut result = Vec::with_capacity(response.len());
|
||||
let mut pos = 0;
|
||||
while let Some(idx) = find_slice(&response[pos..], placeholder) {
|
||||
result.extend_from_slice(&response[pos..pos + idx]);
|
||||
result.extend_from_slice(tag_bytes);
|
||||
pos += idx + placeholder.len();
|
||||
}
|
||||
result.extend_from_slice(&response[pos..]);
|
||||
result
|
||||
}
|
||||
|
||||
fn contains_slice(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack.windows(needle.len()).any(|w| w == needle)
|
||||
}
|
||||
|
||||
fn find_slice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|w| w == needle)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Pre-built response helpers
|
||||
// ============================================================
|
||||
|
||||
/// Build a tagged OK response.
|
||||
pub fn ok(tag: impl AsRef<str>, msg: impl AsRef<str>) -> Vec<u8> {
|
||||
format!("{} OK {}\r\n", tag.as_ref(), msg.as_ref()).into_bytes()
|
||||
}
|
||||
|
||||
/// Build a STATUS response line.
|
||||
pub fn status_response(
|
||||
mailbox: &str,
|
||||
messages: u32,
|
||||
unseen: u32,
|
||||
uid_next: u32,
|
||||
uid_validity: Option<u32>,
|
||||
) -> Vec<u8> {
|
||||
let uv = uid_validity
|
||||
.map(|v| format!(" UIDVALIDITY {v}"))
|
||||
.unwrap_or_default();
|
||||
let text = format!(
|
||||
"* STATUS \"{mailbox}\" (MESSAGES {messages} UNSEEN {unseen} UIDNEXT {uid_next}{uv})\r\n"
|
||||
);
|
||||
// Clients expect a tagged response after the untagged STATUS line.
|
||||
// We produce a generic OK that works for any tag.
|
||||
let mut out = text.into_bytes();
|
||||
out.extend_from_slice(b"{TAG} OK STATUS completed\r\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Build an EXAMINE response with mailbox data.
|
||||
pub fn examine_response(
|
||||
_mailbox: &str,
|
||||
exists: u32,
|
||||
uid_validity: u32,
|
||||
uid_next: u32,
|
||||
) -> Vec<u8> {
|
||||
format!(
|
||||
"* FLAGS (\\Seen \\Answered \\Flagged \\Deleted \\Draft)\r\n\
|
||||
* OK [PERMANENTFLAGS ()]\r\n\
|
||||
* {exists} EXISTS\r\n\
|
||||
* 0 RECENT\r\n\
|
||||
* OK [UIDVALIDITY {uid_validity}]\r\n\
|
||||
* OK [UIDNEXT {uid_next}]\r\n\
|
||||
* OK [HIGHESTMODSEQ 1]\r\n\
|
||||
{{TAG}} OK [READ-ONLY] EXAMINE completed\r\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// Build a UID SEARCH response for the given UID list.
|
||||
pub fn uid_search_response(uids: &[u32]) -> Vec<u8> {
|
||||
let uid_str = uids
|
||||
.iter()
|
||||
.map(|u| u.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
format!("* SEARCH {uid_str}\r\n{{TAG}} OK SEARCH completed\r\n").into_bytes()
|
||||
}
|
||||
|
||||
/// Build a UID FETCH response returning full headers (for BODY[HEADER]).
|
||||
/// Each entry: (uid, message_id)
|
||||
pub fn uid_fetch_metadata_response(entries: &[(u32, &str)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for (uid, msg_id) in entries {
|
||||
// Build a minimal header that contains the Message-ID line.
|
||||
let header_data = format!(
|
||||
"From: sender@example.com\r\n\
|
||||
To: recipient@example.com\r\n\
|
||||
Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
|
||||
Subject: test\r\n\
|
||||
Message-ID: {msg_id}\r\n\r\n"
|
||||
);
|
||||
let header_len = header_data.len();
|
||||
let line = format!(
|
||||
"* {uid} FETCH (UID {uid} BODY[HEADER] {{{header_len}}}\r\n\
|
||||
{header_data}\
|
||||
)\r\n",
|
||||
);
|
||||
out.extend_from_slice(line.as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"{TAG} OK FETCH completed\r\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a UID FETCH RFC822 response with a full email body.
|
||||
pub fn uid_fetch_rfc822_response(uid: u32, eml: &[u8]) -> Vec<u8> {
|
||||
let header = format!(
|
||||
"* {uid} FETCH (UID {uid} RFC822 {{{len}}}\r\n",
|
||||
len = eml.len()
|
||||
);
|
||||
let mut out = header.into_bytes();
|
||||
out.extend_from_slice(eml);
|
||||
out.extend_from_slice(b")\r\n{TAG} OK FETCH completed\r\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// A minimal RFC822 email fixture for testing.
|
||||
pub fn minimal_eml(subject: &str, message_id: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"From: sender@example.com\r\n\
|
||||
To: recipient@example.com\r\n\
|
||||
Subject: {subject}\r\n\
|
||||
Message-ID: <{message_id}>\r\n\
|
||||
Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
|
||||
MIME-Version: 1.0\r\n\
|
||||
Content-Type: text/plain; charset=utf-8\r\n\
|
||||
\r\n\
|
||||
This is a test email: {subject}.\r\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Self-tests for the mock server itself
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn connect_and_read_greeting(host: &str, port: u16) -> String {
|
||||
let mut stream = TcpStream::connect((host, port)).await.unwrap();
|
||||
let (reader, _writer) = stream.split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
line
|
||||
}
|
||||
|
||||
async fn send_and_recv(host: &str, port: u16, cmd: &str) -> String {
|
||||
let mut stream = TcpStream::connect((host, port)).await.unwrap();
|
||||
let (reader, mut writer) = stream.split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
// Read greeting
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
|
||||
// Send command
|
||||
writer.write_all(cmd.as_bytes()).await.unwrap();
|
||||
writer.write_all(b"\r\n").await.unwrap();
|
||||
|
||||
// Read response (may be multi-line; read until tagged response)
|
||||
let mut out = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
out.push_str(&line);
|
||||
if line.starts_with("A0") || line.starts_with("A1") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_greeting() {
|
||||
let handle = MockImapServer::new().start().await;
|
||||
let greeting = connect_and_read_greeting(&handle.host(), handle.port()).await;
|
||||
assert!(greeting.starts_with("* OK"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_scripted_response() {
|
||||
let handle = MockImapServer::new()
|
||||
.respond(
|
||||
"LOGIN",
|
||||
"A0 OK LOGIN completed\r\n",
|
||||
)
|
||||
.start()
|
||||
.await;
|
||||
|
||||
let resp = send_and_recv(&handle.host(), handle.port(), "A0 LOGIN u p").await;
|
||||
assert!(resp.contains("LOGIN completed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_fallback_on_unmatched() {
|
||||
let handle = MockImapServer::new().start().await;
|
||||
|
||||
// Send a command that has no scripted response
|
||||
let resp = send_and_recv(&handle.host(), handle.port(), "A0 NOOP").await;
|
||||
assert!(resp.contains("OK done"), "unmatched command should get fallback OK");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_status_response_helper() {
|
||||
let resp = status_response("INBOX", 10, 2, 11, Some(42));
|
||||
let text = String::from_utf8(resp).unwrap();
|
||||
assert!(text.contains("MESSAGES 10"));
|
||||
assert!(text.contains("UNSEEN 2"));
|
||||
assert!(text.contains("UIDNEXT 11"));
|
||||
assert!(text.contains("UIDVALIDITY 42"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_status_response_without_uidvalidity() {
|
||||
let resp = status_response("INBOX", 10, 2, 11, None);
|
||||
let text = String::from_utf8(resp).unwrap();
|
||||
assert!(!text.contains("UIDVALIDITY"));
|
||||
assert!(text.contains("MESSAGES 10"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_examine_response() {
|
||||
let resp = examine_response("INBOX", 10, 42, 11);
|
||||
let text = String::from_utf8(resp).unwrap();
|
||||
assert!(text.contains("UIDVALIDITY 42"));
|
||||
assert!(text.contains("10 EXISTS"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_uid_search_response() {
|
||||
let resp = uid_search_response(&[1, 3, 5]);
|
||||
let text = String::from_utf8(resp).unwrap();
|
||||
assert!(text.contains("SEARCH 1 3 5"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_uid_fetch_metadata_response() {
|
||||
let resp = uid_fetch_metadata_response(&[(1, "msg-a@x.com"), (2, "msg-b@x.com")]);
|
||||
let text = String::from_utf8(resp).unwrap();
|
||||
assert!(text.contains("Message-ID: msg-a@x.com"));
|
||||
assert!(text.contains("Message-ID: msg-b@x.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_commands_in_sequence() {
|
||||
let handle = MockImapServer::new()
|
||||
.respond("LOGIN", "A0 OK LOGIN\r\n")
|
||||
.respond("STATUS", status_response("INBOX", 5, 1, 6, Some(99)))
|
||||
.respond("LOGOUT", "* BYE\r\nA0 OK\r\n")
|
||||
.start()
|
||||
.await;
|
||||
|
||||
let mut stream = TcpStream::connect((handle.host(), handle.port()))
|
||||
.await
|
||||
.unwrap();
|
||||
let (reader, mut writer) = stream.split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
// Read greeting
|
||||
let mut buf = String::new();
|
||||
reader.read_line(&mut buf).await.unwrap();
|
||||
|
||||
// LOGIN
|
||||
writer.write_all(b"A0 LOGIN u p\r\n").await.unwrap();
|
||||
buf.clear();
|
||||
reader.read_line(&mut buf).await.unwrap();
|
||||
assert!(buf.contains("LOGIN"));
|
||||
|
||||
// STATUS
|
||||
writer
|
||||
.write_all(b"A0 STATUS INBOX (MESSAGES UNSEEN UIDNEXT UIDVALIDITY)\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
buf.clear();
|
||||
// Read multi-line STATUS response (untagged line + tagged OK)
|
||||
loop {
|
||||
reader.read_line(&mut buf).await.unwrap();
|
||||
if buf.contains("UIDVALIDITY 99") {
|
||||
// Consume the tagged OK line that follows
|
||||
buf.clear();
|
||||
reader.read_line(&mut buf).await.unwrap();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// LOGOUT
|
||||
writer.write_all(b"A0 LOGOUT\r\n").await.unwrap();
|
||||
buf.clear();
|
||||
reader.read_line(&mut buf).await.unwrap();
|
||||
assert!(buf.contains("BYE"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tag_substitution_in_response() {
|
||||
// Use {TAG} placeholder in the response and verify it gets the
|
||||
// client's actual tag ("A5") substituted in.
|
||||
let handle = MockImapServer::new()
|
||||
.respond("LOGIN", "{TAG} OK LOGIN succeeded\r\n")
|
||||
.start()
|
||||
.await;
|
||||
|
||||
let mut stream = TcpStream::connect((handle.host(), handle.port()))
|
||||
.await
|
||||
.unwrap();
|
||||
let (reader, mut writer) = stream.split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
// Read greeting
|
||||
let mut buf = String::new();
|
||||
reader.read_line(&mut buf).await.unwrap();
|
||||
|
||||
// Send LOGIN with non-standard tag
|
||||
writer.write_all(b"A5 LOGIN u p\r\n").await.unwrap();
|
||||
buf.clear();
|
||||
reader.read_line(&mut buf).await.unwrap();
|
||||
|
||||
assert!(
|
||||
buf.contains("A5 OK LOGIN succeeded"),
|
||||
"expected 'A5 OK LOGIN succeeded', got '{buf}'"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tag_substitution_multiple_placeholders() {
|
||||
let handle = MockImapServer::new()
|
||||
.respond("NOOP", "* 0 RECENT\r\n{TAG} OK NOOP done\r\n")
|
||||
.start()
|
||||
.await;
|
||||
|
||||
let mut stream = TcpStream::connect((handle.host(), handle.port()))
|
||||
.await
|
||||
.unwrap();
|
||||
let (reader, mut writer) = stream.split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
// Read greeting
|
||||
let mut buf = String::new();
|
||||
reader.read_line(&mut buf).await.unwrap();
|
||||
|
||||
// Send with tag "B99"
|
||||
writer.write_all(b"B99 NOOP\r\n").await.unwrap();
|
||||
|
||||
// Read all lines
|
||||
let mut all = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
reader.read_line(&mut buf).await.unwrap();
|
||||
all.push_str(&buf);
|
||||
if buf.starts_with("B99") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(all.contains("* 0 RECENT\r\n"));
|
||||
assert!(all.contains("B99 OK NOOP done\r\n"));
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,5 @@ pub mod session;
|
||||
pub mod stats;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
pub mod mock_server;
|
||||
|
||||
@@ -53,6 +53,7 @@ pub struct AttachmentInfo {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -205,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() {
|
||||
@@ -301,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();
|
||||
@@ -361,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
migrate::{
|
||||
legacy::schema::SchemaTools,
|
||||
store::{LegacyDirs, NewDirs, NewIndexWriter},
|
||||
store::{LegacyDirs, NewIndexWriter},
|
||||
},
|
||||
raise_error,
|
||||
settings::cli::SETTINGS,
|
||||
@@ -121,7 +121,7 @@ fn is_dir_not_empty(path: &PathBuf) -> std::io::Result<bool> {
|
||||
pub fn do_migrate_segment<F>(
|
||||
batch_size: u32,
|
||||
legacy: LegacyDirs,
|
||||
new_dirs: NewDirs,
|
||||
writer: &mut NewIndexWriter,
|
||||
segment_index: usize,
|
||||
mut on_progress: F,
|
||||
) -> BichonResult<()>
|
||||
@@ -226,8 +226,6 @@ where
|
||||
drop(envelope_index);
|
||||
|
||||
// ── Phase 2: process EML docs, streaming one at a time ─────────────
|
||||
let mut writer = NewIndexWriter::open(new_dirs)?;
|
||||
|
||||
let mut total_migrated = 0usize;
|
||||
let mut total_skipped = 0usize;
|
||||
|
||||
@@ -308,7 +306,6 @@ where
|
||||
chunk_start = chunk_end;
|
||||
}
|
||||
|
||||
writer.finish_writers()?;
|
||||
on_progress(&format!("DONE:{}:{}", total_migrated, total_skipped));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
641
crates/core/src/store/tantivy/dedup_cache.rs
Normal file
641
crates/core/src/store/tantivy/dedup_cache.rs
Normal file
@@ -0,0 +1,641 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use crate::store::tantivy::envelope::ENVELOPE_MANAGER;
|
||||
use crate::store::tantivy::fields::{F_ACCOUNT_ID, F_CONTENT_HASH, F_INGEST_AT, F_MAILBOX_ID};
|
||||
use crate::utc_now;
|
||||
|
||||
/// Max entries before evicting the oldest.
|
||||
/// At ~152 bytes/entry, 300_000 ≈ 45 MB, within the 50 MB budget.
|
||||
const MAX_ENTRIES: usize = 300_000;
|
||||
|
||||
/// Fraction of entries to keep when evicting (newest 3/4).
|
||||
const KEEP_FRACTION_NUM: usize = 3;
|
||||
const KEEP_FRACTION_DEN: usize = 4;
|
||||
|
||||
/// Populate only loads entries ingested within this window.
|
||||
const POPULATE_WINDOW_MS: i64 = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
pub static DEDUP_CACHE: LazyLock<DedupCache> = LazyLock::new(DedupCache::new);
|
||||
|
||||
pub struct DedupCache {
|
||||
entries: Mutex<HashMap<(u64, u64, String), i64>>,
|
||||
max_entries: usize,
|
||||
populated: AtomicBool,
|
||||
}
|
||||
|
||||
impl DedupCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
max_entries: MAX_ENTRIES,
|
||||
populated: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_test() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
max_entries: MAX_ENTRIES,
|
||||
populated: AtomicBool::new(true),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_test_small(max_entries: usize) -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
max_entries,
|
||||
populated: AtomicBool::new(true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this `(account_id, mailbox_id, content_hash)` triple
|
||||
/// has already been seen.
|
||||
///
|
||||
/// On the very first call the cache is populated from the Tantivy index
|
||||
/// FAST columns (only entries ingested within [`POPULATE_WINDOW_MS`]).
|
||||
/// If that scan fails the cache starts empty and still operates correctly
|
||||
/// for newly-arriving emails.
|
||||
pub fn contains(&self, account_id: u64, mailbox_id: u64, hash: &str) -> bool {
|
||||
self.ensure_populated();
|
||||
|
||||
let entries = self.entries.lock().unwrap();
|
||||
entries.contains_key(&(account_id, mailbox_id, hash.to_string()))
|
||||
}
|
||||
|
||||
/// Insert a triple into the cache after it has been queued for indexing.
|
||||
///
|
||||
/// Each entry is stamped with the current time. When the cache exceeds
|
||||
/// [`MAX_ENTRIES`], the oldest entries are evicted, keeping the newest
|
||||
/// `MAX_ENTRIES * 3/4`.
|
||||
pub fn insert(&self, account_id: u64, mailbox_id: u64, hash: &str) {
|
||||
let mut entries = self.entries.lock().unwrap();
|
||||
let now = utc_now!();
|
||||
entries.insert((account_id, mailbox_id, hash.to_string()), now);
|
||||
|
||||
if entries.len() > self.max_entries {
|
||||
let keep = self.max_entries * KEEP_FRACTION_NUM / KEEP_FRACTION_DEN;
|
||||
let mut vec: Vec<_> = entries.drain().collect();
|
||||
// Sort descending by timestamp (newest first)
|
||||
vec.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
for (k, v) in vec.into_iter().take(keep) {
|
||||
entries.insert(k, v);
|
||||
}
|
||||
tracing::warn!(
|
||||
"DedupCache evicted oldest entries, kept {}/{}",
|
||||
entries.len(),
|
||||
keep
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── private ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn ensure_populated(&self) {
|
||||
if self.populated.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
self.do_populate();
|
||||
}
|
||||
|
||||
fn do_populate(&self) {
|
||||
if self
|
||||
.populated
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let reader = match ENVELOPE_MANAGER.create_reader() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("DedupCache: failed to create reader for populate: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let searcher = reader.searcher();
|
||||
let cutoff = utc_now!() - POPULATE_WINDOW_MS;
|
||||
let mut entries = self.entries.lock().unwrap();
|
||||
|
||||
for segment_reader in searcher.segment_readers() {
|
||||
let account_col = match segment_reader.fast_fields().u64(F_ACCOUNT_ID) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mailbox_col = match segment_reader.fast_fields().u64(F_MAILBOX_ID) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let hash_col = match segment_reader.fast_fields().str(F_CONTENT_HASH) {
|
||||
Ok(Some(c)) => c,
|
||||
_ => continue,
|
||||
};
|
||||
let ingest_col = match segment_reader.fast_fields().i64(F_INGEST_AT) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let max_doc = segment_reader.max_doc();
|
||||
for doc_id in 0..max_doc {
|
||||
if segment_reader.is_deleted(doc_id) {
|
||||
continue;
|
||||
}
|
||||
let ingest_at = ingest_col.values.get_val(doc_id);
|
||||
if ingest_at < cutoff {
|
||||
continue;
|
||||
}
|
||||
let account_id = account_col.values.get_val(doc_id);
|
||||
let mailbox_id = mailbox_col.values.get_val(doc_id);
|
||||
|
||||
let hash_ord = hash_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut hash_buf = String::new();
|
||||
if hash_col.ord_to_str(hash_ord, &mut hash_buf).is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.insert((account_id, mailbox_id, hash_buf), ingest_at);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"DedupCache populated with {} entries from index (cutoff {}d ago)",
|
||||
entries.len(),
|
||||
POPULATE_WINDOW_MS / (24 * 60 * 60 * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove all entries for a specific account.
|
||||
pub fn remove_by_account(&self, account_id: u64) {
|
||||
let mut entries = self.entries.lock().unwrap();
|
||||
let before = entries.len();
|
||||
entries.retain(|(aid, _, _), _| *aid != account_id);
|
||||
let removed = before - entries.len();
|
||||
if removed > 0 {
|
||||
tracing::info!(
|
||||
"DedupCache: removed {} entries for account {}",
|
||||
removed,
|
||||
account_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove all entries for a specific mailbox (across all accounts).
|
||||
pub fn remove_by_mailbox(&self, mailbox_id: u64) {
|
||||
let mut entries = self.entries.lock().unwrap();
|
||||
let before = entries.len();
|
||||
entries.retain(|(_, mid, _), _| *mid != mailbox_id);
|
||||
let removed = before - entries.len();
|
||||
if removed > 0 {
|
||||
tracing::info!(
|
||||
"DedupCache: removed {} entries for mailbox {}",
|
||||
removed,
|
||||
mailbox_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific triple (most precise removal).
|
||||
pub fn remove(&self, account_id: u64, mailbox_id: u64, hash: &str) {
|
||||
let mut entries = self.entries.lock().unwrap();
|
||||
if entries
|
||||
.remove(&(account_id, mailbox_id, hash.to_string()))
|
||||
.is_some()
|
||||
{
|
||||
tracing::debug!(
|
||||
"DedupCache: removed specific entry ({}, {}, {})",
|
||||
account_id,
|
||||
mailbox_id,
|
||||
hash
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::tantivy::fields::EmailFields;
|
||||
use crate::store::tantivy::schema::SchemaTools;
|
||||
use crate::store::tantivy::tokenizers::EuroTokenizer;
|
||||
use std::fs;
|
||||
use tantivy::{Index, TantivyDocument};
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir()
|
||||
.join("bichon-dedup-cache-test")
|
||||
.join(name)
|
||||
.join(uuid::Uuid::new_v4().to_string());
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
// ── basic contains / insert ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn contains_after_insert() {
|
||||
let cache = DedupCache::new_for_test();
|
||||
assert!(!cache.contains(1, 10, "hash-aaa"));
|
||||
cache.insert(1, 10, "hash-aaa");
|
||||
assert!(cache.contains(1, 10, "hash-aaa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_hash_not_matched() {
|
||||
let cache = DedupCache::new_for_test();
|
||||
cache.insert(1, 10, "hash-aaa");
|
||||
assert!(!cache.contains(1, 10, "hash-bbb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_account_not_matched() {
|
||||
let cache = DedupCache::new_for_test();
|
||||
cache.insert(1, 10, "hash-aaa");
|
||||
assert!(!cache.contains(2, 10, "hash-aaa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_mailbox_not_matched() {
|
||||
let cache = DedupCache::new_for_test();
|
||||
cache.insert(1, 10, "hash-aaa");
|
||||
assert!(!cache.contains(1, 20, "hash-aaa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_account_allowed() {
|
||||
let cache = DedupCache::new_for_test();
|
||||
cache.insert(1, 10, "hash-aaa");
|
||||
cache.insert(2, 10, "hash-aaa");
|
||||
assert!(cache.contains(2, 10, "hash-aaa"));
|
||||
assert!(cache.contains(1, 10, "hash-aaa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_mailbox_allowed() {
|
||||
let cache = DedupCache::new_for_test();
|
||||
cache.insert(1, 10, "hash-aaa");
|
||||
cache.insert(1, 20, "hash-aaa");
|
||||
assert!(cache.contains(1, 20, "hash-aaa"));
|
||||
assert!(cache.contains(1, 10, "hash-aaa"));
|
||||
}
|
||||
|
||||
// ── time-based eviction ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn eviction_keeps_newest() {
|
||||
let cap = 100;
|
||||
let cache = DedupCache::new_for_test_small(cap);
|
||||
|
||||
// Fill to exact capacity. Entry hash-0 is oldest.
|
||||
for i in 0..cap {
|
||||
cache.insert(1, 1, &format!("hash-{}", i));
|
||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
||||
}
|
||||
assert!(cache.contains(1, 1, "hash-0"));
|
||||
assert!(cache.contains(1, 1, &format!("hash-{}", cap - 1)));
|
||||
|
||||
// One more triggers eviction
|
||||
cache.insert(1, 1, "hash-overflow");
|
||||
|
||||
// Newest survives, oldest evicted
|
||||
assert!(cache.contains(1, 1, "hash-overflow"));
|
||||
assert!(cache.contains(1, 1, &format!("hash-{}", cap - 1)));
|
||||
assert!(!cache.contains(1, 1, "hash-0"));
|
||||
|
||||
let keep = cap * KEEP_FRACTION_NUM / KEEP_FRACTION_DEN;
|
||||
assert!(cache.entries.lock().unwrap().len() <= keep);
|
||||
}
|
||||
|
||||
// ── memory bound ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn memory_bound_within_budget() {
|
||||
let cache = DedupCache::new_for_test();
|
||||
|
||||
for i in 0..MAX_ENTRIES {
|
||||
cache.insert(1, 1, &format!("{:064x}", i));
|
||||
}
|
||||
|
||||
let entries = cache.entries.lock().unwrap();
|
||||
assert_eq!(entries.len(), MAX_ENTRIES);
|
||||
|
||||
let capacity = entries.capacity();
|
||||
// HashMap with (u64,u64,String) key + i64 value ≈ 112 + map overhead
|
||||
let approx_bytes = capacity * (104 + 8 + 8);
|
||||
let approx_mb = approx_bytes as f64 / (1024.0 * 1024.0);
|
||||
println!(
|
||||
"DedupCache: {} entries, {} buckets, ~{:.1} MB",
|
||||
MAX_ENTRIES, capacity, approx_mb
|
||||
);
|
||||
assert!(
|
||||
approx_mb < 55.0,
|
||||
"memory estimate {:.1} MB exceeds 55 MB buffer",
|
||||
approx_mb
|
||||
);
|
||||
}
|
||||
|
||||
// ── populate guard ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn populate_cas_is_idempotent() {
|
||||
let cache = DedupCache::new_for_test();
|
||||
assert!(cache.populated.load(Ordering::Acquire));
|
||||
cache.ensure_populated();
|
||||
assert!(cache.populated.load(Ordering::Acquire));
|
||||
cache.do_populate();
|
||||
}
|
||||
|
||||
// ── populate from test index ────────────────────────────────────────────
|
||||
|
||||
fn build_test_index() -> (Index, &'static EmailFields) {
|
||||
let dir = temp_dir("populate");
|
||||
let schema = SchemaTools::email_schema();
|
||||
let fields = SchemaTools::email_fields();
|
||||
let index = Index::create_in_dir(&dir, schema).unwrap();
|
||||
index.tokenizers().register("euro", EuroTokenizer::new());
|
||||
(index, fields)
|
||||
}
|
||||
|
||||
fn add_email_doc(
|
||||
fields: &EmailFields,
|
||||
writer: &mut tantivy::IndexWriter,
|
||||
account: u64,
|
||||
mailbox: u64,
|
||||
hash: &str,
|
||||
ingest_at: i64,
|
||||
) {
|
||||
let mut doc = TantivyDocument::new();
|
||||
doc.add_u64(fields.f_account_id, account);
|
||||
doc.add_u64(fields.f_mailbox_id, mailbox);
|
||||
doc.add_text(fields.f_content_hash, hash);
|
||||
doc.add_i64(fields.f_ingest_at, ingest_at);
|
||||
doc.add_text(fields.f_id, &uuid::Uuid::new_v4().to_string());
|
||||
doc.add_text(fields.f_subject, "test");
|
||||
doc.add_text(fields.f_body, "test body");
|
||||
doc.add_u64(fields.f_uid, 1);
|
||||
doc.add_i64(fields.f_date, 1);
|
||||
doc.add_i64(fields.f_internal_date, 1);
|
||||
doc.add_u64(fields.f_size, 100);
|
||||
writer.add_document(doc).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populate_reads_all_docs_in_window() {
|
||||
let (index, fields) = build_test_index();
|
||||
let mut writer = index.writer_with_num_threads(1, 50_000_000).unwrap();
|
||||
|
||||
let recent = utc_now!();
|
||||
add_email_doc(&fields, &mut writer, 1, 10, "hash-recent", recent);
|
||||
add_email_doc(&fields, &mut writer, 2, 10, "hash-recent", recent);
|
||||
add_email_doc(&fields, &mut writer, 1, 20, "hash-recent", recent);
|
||||
writer.commit().unwrap();
|
||||
drop(writer);
|
||||
|
||||
let reader = index.reader().unwrap();
|
||||
let cache = DedupCache::new_for_test();
|
||||
{
|
||||
let searcher = reader.searcher();
|
||||
let cutoff = utc_now!() - POPULATE_WINDOW_MS;
|
||||
let mut entries = cache.entries.lock().unwrap();
|
||||
entries.clear();
|
||||
|
||||
for segment_reader in searcher.segment_readers() {
|
||||
let account_col = segment_reader.fast_fields().u64(F_ACCOUNT_ID).unwrap();
|
||||
let mailbox_col = segment_reader.fast_fields().u64(F_MAILBOX_ID).unwrap();
|
||||
let hash_col = segment_reader
|
||||
.fast_fields()
|
||||
.str(F_CONTENT_HASH)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let ingest_col = segment_reader.fast_fields().i64(F_INGEST_AT).unwrap();
|
||||
|
||||
let max_doc = segment_reader.max_doc();
|
||||
for doc_id in 0..max_doc {
|
||||
if segment_reader.is_deleted(doc_id) {
|
||||
continue;
|
||||
}
|
||||
let ingest_at = ingest_col.values.get_val(doc_id);
|
||||
if ingest_at < cutoff {
|
||||
continue;
|
||||
}
|
||||
let account_id = account_col.values.get_val(doc_id);
|
||||
let mailbox_id = mailbox_col.values.get_val(doc_id);
|
||||
|
||||
let hash_ord = hash_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut hash_buf = String::new();
|
||||
hash_col.ord_to_str(hash_ord, &mut hash_buf).unwrap();
|
||||
|
||||
entries.insert((account_id, mailbox_id, hash_buf), ingest_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(cache.entries.lock().unwrap().len(), 3);
|
||||
assert!(cache.contains(1, 10, "hash-recent"));
|
||||
assert!(cache.contains(2, 10, "hash-recent"));
|
||||
assert!(cache.contains(1, 20, "hash-recent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populate_skips_old_entries() {
|
||||
let (index, fields) = build_test_index();
|
||||
let mut writer = index.writer_with_num_threads(1, 50_000_000).unwrap();
|
||||
|
||||
let recent = utc_now!();
|
||||
let old = recent - POPULATE_WINDOW_MS - 60_000; // 1 minute past the window
|
||||
add_email_doc(&fields, &mut writer, 1, 10, "hash-recent", recent);
|
||||
add_email_doc(&fields, &mut writer, 1, 10, "hash-old", old);
|
||||
writer.commit().unwrap();
|
||||
drop(writer);
|
||||
|
||||
let reader = index.reader().unwrap();
|
||||
let cache = DedupCache::new_for_test();
|
||||
{
|
||||
let searcher = reader.searcher();
|
||||
let cutoff = utc_now!() - POPULATE_WINDOW_MS;
|
||||
let mut entries = cache.entries.lock().unwrap();
|
||||
entries.clear();
|
||||
|
||||
for segment_reader in searcher.segment_readers() {
|
||||
let account_col = segment_reader.fast_fields().u64(F_ACCOUNT_ID).unwrap();
|
||||
let mailbox_col = segment_reader.fast_fields().u64(F_MAILBOX_ID).unwrap();
|
||||
let hash_col = segment_reader
|
||||
.fast_fields()
|
||||
.str(F_CONTENT_HASH)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let ingest_col = segment_reader.fast_fields().i64(F_INGEST_AT).unwrap();
|
||||
|
||||
let max_doc = segment_reader.max_doc();
|
||||
for doc_id in 0..max_doc {
|
||||
if segment_reader.is_deleted(doc_id) {
|
||||
continue;
|
||||
}
|
||||
let ingest_at = ingest_col.values.get_val(doc_id);
|
||||
if ingest_at < cutoff {
|
||||
continue;
|
||||
}
|
||||
let account_id = account_col.values.get_val(doc_id);
|
||||
let mailbox_id = mailbox_col.values.get_val(doc_id);
|
||||
|
||||
let hash_ord = hash_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut hash_buf = String::new();
|
||||
hash_col.ord_to_str(hash_ord, &mut hash_buf).unwrap();
|
||||
|
||||
entries.insert((account_id, mailbox_id, hash_buf), ingest_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(cache.contains(1, 10, "hash-recent"));
|
||||
assert!(!cache.contains(1, 10, "hash-old"));
|
||||
assert_eq!(cache.entries.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populate_skips_deleted_docs() {
|
||||
let (index, fields) = build_test_index();
|
||||
let mut writer = index.writer_with_num_threads(1, 50_000_000).unwrap();
|
||||
|
||||
let recent = utc_now!();
|
||||
add_email_doc(&fields, &mut writer, 1, 10, "hash-keep", recent);
|
||||
add_email_doc(&fields, &mut writer, 1, 10, "hash-delete", recent);
|
||||
writer.commit().unwrap();
|
||||
|
||||
let term = tantivy::Term::from_field_text(fields.f_content_hash, "hash-delete");
|
||||
writer.delete_term(term);
|
||||
writer.commit().unwrap();
|
||||
drop(writer);
|
||||
|
||||
let reader = index.reader().unwrap();
|
||||
let cache = DedupCache::new_for_test();
|
||||
{
|
||||
let searcher = reader.searcher();
|
||||
let cutoff = utc_now!() - POPULATE_WINDOW_MS;
|
||||
let mut entries = cache.entries.lock().unwrap();
|
||||
entries.clear();
|
||||
|
||||
for segment_reader in searcher.segment_readers() {
|
||||
let account_col = segment_reader.fast_fields().u64(F_ACCOUNT_ID).unwrap();
|
||||
let mailbox_col = segment_reader.fast_fields().u64(F_MAILBOX_ID).unwrap();
|
||||
let hash_col = segment_reader
|
||||
.fast_fields()
|
||||
.str(F_CONTENT_HASH)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let ingest_col = segment_reader.fast_fields().i64(F_INGEST_AT).unwrap();
|
||||
|
||||
let max_doc = segment_reader.max_doc();
|
||||
for doc_id in 0..max_doc {
|
||||
if segment_reader.is_deleted(doc_id) {
|
||||
continue;
|
||||
}
|
||||
let ingest_at = ingest_col.values.get_val(doc_id);
|
||||
if ingest_at < cutoff {
|
||||
continue;
|
||||
}
|
||||
let account_id = account_col.values.get_val(doc_id);
|
||||
let mailbox_id = mailbox_col.values.get_val(doc_id);
|
||||
|
||||
let hash_ord = hash_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut hash_buf = String::new();
|
||||
hash_col.ord_to_str(hash_ord, &mut hash_buf).unwrap();
|
||||
|
||||
entries.insert((account_id, mailbox_id, hash_buf), ingest_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(cache.contains(1, 10, "hash-keep"));
|
||||
assert!(!cache.contains(1, 10, "hash-delete"));
|
||||
}
|
||||
|
||||
// ── removal methods ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn remove_by_account_works() {
|
||||
let cache = DedupCache::new_for_test();
|
||||
|
||||
cache.insert(1, 10, "hash-a1");
|
||||
cache.insert(1, 20, "hash-a2");
|
||||
cache.insert(2, 10, "hash-b1");
|
||||
cache.insert(2, 30, "hash-b2");
|
||||
cache.insert(1, 10, "hash-a3");
|
||||
|
||||
assert_eq!(cache.entries.lock().unwrap().len(), 5);
|
||||
|
||||
cache.remove_by_account(1);
|
||||
|
||||
let entries = cache.entries.lock().unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert!(!entries.contains_key(&(1, 10, "hash-a1".to_string())));
|
||||
assert!(!entries.contains_key(&(1, 20, "hash-a2".to_string())));
|
||||
assert!(!entries.contains_key(&(1, 10, "hash-a3".to_string())));
|
||||
|
||||
assert!(entries.contains_key(&(2, 10, "hash-b1".to_string())));
|
||||
assert!(entries.contains_key(&(2, 30, "hash-b2".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_by_mailbox_works() {
|
||||
let cache = DedupCache::new_for_test();
|
||||
|
||||
cache.insert(1, 10, "hash-1");
|
||||
cache.insert(1, 20, "hash-2");
|
||||
cache.insert(2, 10, "hash-3");
|
||||
cache.insert(3, 20, "hash-4");
|
||||
cache.insert(1, 10, "hash-5");
|
||||
|
||||
cache.remove_by_mailbox(10);
|
||||
|
||||
let entries = cache.entries.lock().unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
|
||||
assert!(entries.contains_key(&(1, 20, "hash-2".to_string())));
|
||||
assert!(entries.contains_key(&(3, 20, "hash-4".to_string())));
|
||||
|
||||
assert!(!entries.contains_key(&(1, 10, "hash-1".to_string())));
|
||||
assert!(!entries.contains_key(&(2, 10, "hash-3".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_specific_triple_works() {
|
||||
let cache = DedupCache::new_for_test();
|
||||
|
||||
cache.insert(1, 10, "hash-aaa");
|
||||
cache.insert(1, 10, "hash-bbb");
|
||||
cache.insert(2, 20, "hash-aaa");
|
||||
|
||||
assert!(cache.contains(1, 10, "hash-aaa"));
|
||||
assert!(cache.contains(1, 10, "hash-bbb"));
|
||||
assert!(cache.contains(2, 20, "hash-aaa"));
|
||||
|
||||
cache.remove(1, 10, "hash-aaa");
|
||||
|
||||
assert!(!cache.contains(1, 10, "hash-aaa"));
|
||||
assert!(cache.contains(1, 10, "hash-bbb"));
|
||||
assert!(cache.contains(2, 20, "hash-aaa"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ use crate::{
|
||||
|
||||
pub mod attachment;
|
||||
pub mod dedup;
|
||||
pub mod dedup_cache;
|
||||
pub mod envelope;
|
||||
pub mod fields;
|
||||
pub mod filter;
|
||||
|
||||
@@ -21,6 +21,7 @@ use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::{prelude::BASE64_STANDARD, Engine as _};
|
||||
use bichon_core::account::migration::AccountType;
|
||||
use bichon_core::cache::imap::mailbox::{Attribute, AttributeEnum};
|
||||
use bichon_core::common::signal::SIGNAL_MANAGER;
|
||||
use bichon_core::envelope::extractor::extract_envelope_from_smtp;
|
||||
@@ -429,8 +430,20 @@ where
|
||||
}
|
||||
|
||||
if is_allowed {
|
||||
session.rcpt_to.push(account);
|
||||
stream.write_all(b"250 OK\r\n").await?;
|
||||
if !matches!(account.account_type, AccountType::NoSync) {
|
||||
tracing::warn!(
|
||||
"SMTP: Rejected journaling attempt to IMAP account <{}>",
|
||||
addr
|
||||
);
|
||||
let err = format!(
|
||||
"550 5.7.1 <{}>: Not a Bichon local account, journaling is not supported\r\n",
|
||||
account.email
|
||||
);
|
||||
stream.write_all(err.as_bytes()).await?;
|
||||
} else {
|
||||
session.rcpt_to.push(account);
|
||||
stream.write_all(b"250 OK\r\n").await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -615,26 +628,40 @@ async fn parse_email(data: &[u8], session: &Session) -> BichonResult<()> {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let mailbox = MailBox {
|
||||
id: create_hash(rcpt.id, "INBOX"),
|
||||
account_id: rcpt.id,
|
||||
name: "INBOX".into(),
|
||||
delimiter: Some("/".to_string()),
|
||||
attributes: vec![Attribute {
|
||||
attr: AttributeEnum::Extension,
|
||||
extension: Some("CreatedByBichon".into()),
|
||||
}],
|
||||
exists: 0,
|
||||
unseen: None,
|
||||
uid_next: None,
|
||||
uid_validity: None,
|
||||
highest_uid: None,
|
||||
};
|
||||
let mailbox_id = mailbox.id;
|
||||
let mailbox_id = create_hash(rcpt.id, "INBOX");
|
||||
|
||||
if let Err(e) = MailBox::batch_upsert(&[mailbox]) {
|
||||
tracing::error!("SMTP: Failed to upsert mailbox for {}: {:?}", rcpt.email, e);
|
||||
return Err(e.into());
|
||||
// The INBOX row is owned by the IMAP sync, which maintains `uid_validity`,
|
||||
// `highest_uid` and `uid_next` on it. `batch_upsert` replaces the *whole*
|
||||
// row, so blindly upserting here (with those fields = None) clobbers the
|
||||
// IMAP-maintained state back to None. The next reconcile then sees
|
||||
// `uid_validity` change from Some -> None, treats the mailbox as invalid,
|
||||
// and wipes + rebuilds it — silently losing the local copy of a large
|
||||
// mailbox when that rebuild is interrupted (see #297).
|
||||
//
|
||||
// We only need the row to *exist* so the journaled envelope can attach to
|
||||
// it, so create it only when it is missing and otherwise leave the
|
||||
// IMAP-owned row untouched.
|
||||
if MailBox::find_mailbox(rcpt.id, mailbox_id)?.is_none() {
|
||||
let mailbox = MailBox {
|
||||
id: mailbox_id,
|
||||
account_id: rcpt.id,
|
||||
name: "INBOX".into(),
|
||||
delimiter: Some("/".to_string()),
|
||||
attributes: vec![Attribute {
|
||||
attr: AttributeEnum::Extension,
|
||||
extension: Some("CreatedByBichon".into()),
|
||||
}],
|
||||
exists: 0,
|
||||
unseen: None,
|
||||
uid_next: None,
|
||||
uid_validity: None,
|
||||
highest_uid: None,
|
||||
};
|
||||
|
||||
if let Err(e) = MailBox::batch_upsert(&[mailbox]) {
|
||||
tracing::error!("SMTP: Failed to upsert mailbox for {}: {:?}", rcpt.email, e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
extract_envelope_from_smtp(data, rcpt.id, mailbox_id)
|
||||
|
||||
58
funding.json
Normal file
58
funding.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"$schema": "https://fundingjson.org/schema/v1.1.0.json",
|
||||
"version": "v1.0.0",
|
||||
"entity": {
|
||||
"type": "individual",
|
||||
"role": "maintainer",
|
||||
"name": "rustmailer",
|
||||
"email": "rustmailer.git@gmail.com",
|
||||
"phone": "",
|
||||
"description": "I'm an indie developer and the sole maintainer of Bichon, a lightweight open-source email archiver built in Rust. I believe in privacy, data ownership, and the right to self-host your own digital life.",
|
||||
"webpageUrl": {
|
||||
"url": "https://github.com/rustmailer"
|
||||
}
|
||||
},
|
||||
"projects": [
|
||||
{
|
||||
"guid": "bichon",
|
||||
"name": "Bichon",
|
||||
"description": "Bichon is a lightweight, high-performance, self-hosted email archiver built in Rust. It synchronizes emails from IMAP servers, indexes them for full-text search, and provides a clean WebUI and REST API for access.\n\nBichon requires no external database and runs as a single binary — making it easy to deploy and maintain. It supports multiple accounts, OAuth2, SOCKS5 proxy, scheduled sync, bulk import (EML/MBOX/PST), and multi-user RBAC.\n\nAs the sole maintainer, I develop and support Bichon in my personal time. With 1.8k GitHub stars and 327k+ Docker pulls, the project has grown well beyond a personal tool and is actively used by individuals and teams worldwide — including a real-world deployment archiving 1.15 million emails across 28 accounts (800 GB original data, compressed to 421 GB on disk).",
|
||||
"webpageUrl": {
|
||||
"url": "https://github.com/rustmailer/bichon"
|
||||
},
|
||||
"repositoryUrl": {
|
||||
"url": "https://github.com/rustmailer/bichon"
|
||||
},
|
||||
"licenses": ["spdx:AGPL-3.0"],
|
||||
"tags": ["email", "rust", "self-hosted", "archiver", "imap", "full-text-search", "privacy", "webui"]
|
||||
}
|
||||
],
|
||||
"funding": {
|
||||
"channels": [
|
||||
{
|
||||
"guid": "buymeacoffee",
|
||||
"type": "payment-provider",
|
||||
"address": "https://buymeacoffee.com/rustmailer",
|
||||
"description": "Support via Buy Me a Coffee."
|
||||
},
|
||||
{
|
||||
"guid": "bank",
|
||||
"type": "bank",
|
||||
"address": "",
|
||||
"description": "Direct bank transfer also accepted. Please email rustmailer.git@gmail.com for details."
|
||||
}
|
||||
],
|
||||
"plans": [
|
||||
{
|
||||
"guid": "maintainer-time",
|
||||
"status": "active",
|
||||
"name": "Maintainer Time",
|
||||
"description": "Cover the cost of dedicated development and maintenance time for Bichon — including bug fixes, feature development, security updates, issue triage, and community support.",
|
||||
"amount": 10000,
|
||||
"currency": "USD",
|
||||
"frequency": "yearly",
|
||||
"channels": ["bank"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ export interface AccountModel {
|
||||
download_folders: string[];
|
||||
download_interval_min?: number;
|
||||
download_batch_size?: number;
|
||||
max_email_size_bytes?: number;
|
||||
created_by: number;
|
||||
created_user_name: string;
|
||||
created_user_email: string;
|
||||
@@ -143,6 +144,7 @@ export interface AccountModel {
|
||||
imap_quota_bytes?: number;
|
||||
auto_download_new_mailboxes?: boolean;
|
||||
download_schedule?: string;
|
||||
deleting?: boolean;
|
||||
}
|
||||
|
||||
export const download_state = async (account_id: number) => {
|
||||
|
||||
@@ -28,18 +28,54 @@ interface GithubLinkButtonProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const CACHE_KEY = "github_stars_cache";
|
||||
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours
|
||||
|
||||
interface StarsCache {
|
||||
stars: number;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
function getCachedStars(repo: string): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${CACHE_KEY}_${repo}`);
|
||||
if (!raw) return null;
|
||||
const cache: StarsCache = JSON.parse(raw);
|
||||
if (Date.now() - cache.fetchedAt > CACHE_TTL) return null;
|
||||
return cache.stars;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCachedStars(repo: string, stars: number) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${CACHE_KEY}_${repo}`,
|
||||
JSON.stringify({ stars, fetchedAt: Date.now() })
|
||||
);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
export const GithubLinkButton: React.FC<GithubLinkButtonProps> = ({
|
||||
href = "https://github.com/rustmailer/bichon",
|
||||
repo = "rustmailer/bichon",
|
||||
size = 18,
|
||||
title = "View on GitHub",
|
||||
}) => {
|
||||
const [stars, setStars] = useState<number | null>(null);
|
||||
const [stars, setStars] = useState<number | null>(() => getCachedStars(repo));
|
||||
|
||||
useEffect(() => {
|
||||
if (stars !== null) return; // already have cached value, skip fetch
|
||||
fetch(`https://api.github.com/repos/${repo}`)
|
||||
.then(res => res.json())
|
||||
.then(data => setStars(data.stargazers_count))
|
||||
.then(data => {
|
||||
const count = data.stargazers_count;
|
||||
if (typeof count === "number") {
|
||||
setStars(count);
|
||||
setCachedStars(repo, count);
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
}, [repo]);
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
<span className="text-muted-foreground">{t('accounts.downloadBatchSize')}:</span>
|
||||
<span>{currentRow.download_batch_size}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.maxEmailSizeBytes')}:</span>
|
||||
<span>{currentRow.max_email_size_bytes ? `${(currentRow.max_email_size_bytes / 1024 / 1024).toFixed(0)} MB` : t('accounts.maxEmailSizeBytesUnlimited')}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.capabilities')}:</span>
|
||||
<code className="rounded-md bg-muted/50 px-2 py-1 text-sm border overflow-x-auto inline-block">
|
||||
|
||||
@@ -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", "download_interval_min", "download_batch_size", "auto_download_new_mailboxes", "download_schedule"] },
|
||||
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "date_before", "download_interval_min", "download_batch_size", "max_email_size_bytes", "auto_download_new_mailboxes", "download_schedule"] },
|
||||
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
|
||||
];
|
||||
|
||||
@@ -81,6 +81,7 @@ const defaultValues: Account = {
|
||||
date_before: undefined,
|
||||
download_interval_min: 60,
|
||||
download_batch_size: 30,
|
||||
max_email_size_bytes: 100 * 1024 * 1024,
|
||||
auto_download_new_mailboxes: true,
|
||||
download_schedule: undefined,
|
||||
};
|
||||
@@ -111,6 +112,7 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
|
||||
date_before: currentRow.date_before ?? undefined,
|
||||
download_interval_min: currentRow.download_interval_min ?? 60,
|
||||
download_batch_size: currentRow.download_batch_size ?? 30,
|
||||
max_email_size_bytes: currentRow.max_email_size_bytes ?? 100 * 1024 * 1024,
|
||||
auto_download_new_mailboxes: currentRow.auto_download_new_mailboxes ?? true,
|
||||
download_schedule: currentRow.download_schedule ?? undefined,
|
||||
};
|
||||
@@ -193,6 +195,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
date_before: data.date_before,
|
||||
download_interval_min: data.download_interval_min,
|
||||
download_batch_size: data.download_batch_size,
|
||||
max_email_size_bytes: data.max_email_size_bytes,
|
||||
auto_download_new_mailboxes: data.auto_download_new_mailboxes,
|
||||
download_schedule: data.download_schedule || null,
|
||||
};
|
||||
|
||||
@@ -50,13 +50,16 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const hasPermission = require_any_permission(['system:root', 'account:manage'], row.original.id);
|
||||
const hasReadPermission = require_any_permission(['system:root', 'account:read_details'], row.original.id);
|
||||
|
||||
const isDeleting = row.original.deleting === true;
|
||||
|
||||
const canShowAnyAction =
|
||||
!isDeleting && (
|
||||
(hasPermission) ||
|
||||
(account_type === 'IMAP' && hasPermission) ||
|
||||
(account_type === 'IMAP' && hasReadPermission);
|
||||
(account_type === 'IMAP' && hasReadPermission)
|
||||
);
|
||||
|
||||
const showDownload = account_type === 'IMAP' && hasPermission;
|
||||
const showDownload = !isDeleting && account_type === 'IMAP' && hasPermission;
|
||||
|
||||
const handleStartDownload = async () => {
|
||||
try {
|
||||
|
||||
@@ -43,8 +43,8 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: t('dialogs.accountDeleted'),
|
||||
description: t('dialogs.accountDeletedDesc'),
|
||||
title: t('dialogs.accountDeletionStarted'),
|
||||
description: t('dialogs.accountDeletionStartedDesc'),
|
||||
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
onCheckedChange={() => setOpen(true)}
|
||||
disabled={!hasPermission || updateMutation.isPending}
|
||||
disabled={!hasPermission || updateMutation.isPending || row.original.deleting}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
|
||||
@@ -35,6 +35,9 @@ export function RunningStateCellAction({ row }: Props) {
|
||||
const { setOpen, setCurrentRow } = useAccountContext()
|
||||
const { require_any_permission } = useCurrentUser()
|
||||
|
||||
if (row.original.deleting) {
|
||||
return <span className="text-xs text-muted-foreground italic">Deleting...</span>
|
||||
}
|
||||
let account_type = row.original.account_type;
|
||||
if (account_type === "NoSync") {
|
||||
return <span className="text-xs text-muted-foreground">n/a</span>
|
||||
|
||||
@@ -100,6 +100,13 @@ export const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
.max(200, {
|
||||
message: t('validation.singleRequestBatchSizeTooLarge'),
|
||||
}),
|
||||
max_email_size_bytes: z
|
||||
.number({
|
||||
invalid_type_error: t('validation.maxEmailSizeMustBeNumber'),
|
||||
})
|
||||
.int()
|
||||
.min(1 * 1024 * 1024, { message: t('validation.maxEmailSizeTooSmall') })
|
||||
.max(100 * 1024 * 1024, { message: t('validation.maxEmailSizeTooLarge') }),
|
||||
auto_download_new_mailboxes: z.boolean(),
|
||||
download_schedule: z
|
||||
.string()
|
||||
|
||||
@@ -392,6 +392,38 @@ export default function Step3() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="max_email_size_bytes"
|
||||
render={({ field }) => {
|
||||
const BYTES_PER_MB = 1024 * 1024;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t('accounts.maxEmailSizeBytes')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('accounts.maxEmailSizeBytesPlaceholder')}
|
||||
className="flex-1"
|
||||
value={field.value ? field.value / BYTES_PER_MB : ''}
|
||||
onChange={(e) => {
|
||||
const parsed = parseInt(e.target.value, 10);
|
||||
field.onChange(isNaN(parsed) ? parsed : parsed * BYTES_PER_MB);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">MB</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t('accounts.maxEmailSizeBytesDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,7 +50,11 @@ export default function Step4() {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl">
|
||||
<Accordion type="multiple" defaultValue={['email', 'account_name', 'login_name', 'imap', 'date_since', 'sync_interval', 'sync_scope', 'sync_batch_size', 'download_schedule']}>
|
||||
<Accordion type="multiple" defaultValue={[
|
||||
'email', 'account_name', 'login_name', 'imap', 'date_since',
|
||||
'max_email_size_bytes', '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>
|
||||
@@ -164,6 +168,11 @@ export default function Step4() {
|
||||
<AccordionContent>{summaryData.download_batch_size}</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem key="max_email_size_bytes" value="max_email_size_bytes">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.maxEmailSizeBytes')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.max_email_size_bytes ? `${(summaryData.max_email_size_bytes / 1024 / 1024).toFixed(0)} MB` : t('accounts.maxEmailSizeBytesUnlimited')}</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>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
@@ -64,7 +64,19 @@ export function AccountTable({ columns, data }: DataTableProps) {
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [sorting, setSorting] = useState<SortingState>(() => {
|
||||
const saved = localStorage.getItem('bichon_accounts_sorting');
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
})
|
||||
|
||||
// Persist sorting state to localStorage
|
||||
const prevSortingRef = useRef(sorting);
|
||||
useEffect(() => {
|
||||
if (prevSortingRef.current !== sorting) {
|
||||
localStorage.setItem('bichon_accounts_sorting', JSON.stringify(sorting));
|
||||
prevSortingRef.current = sorting;
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
@@ -127,7 +139,7 @@ export function AccountTable({ columns, data }: DataTableProps) {
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className='group/row'
|
||||
className={row.original.deleting ? 'opacity-50' : 'group/row'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
|
||||
@@ -55,6 +55,10 @@ export default function Accounts() {
|
||||
const { data: accountList, isLoading } = useQuery({
|
||||
queryKey: ['account-list'],
|
||||
queryFn: list_accounts,
|
||||
refetchInterval: (query) => {
|
||||
const items = (query.state.data as { items?: { deleting?: boolean }[] })?.items;
|
||||
return items?.some((item) => item.deleting) ? 5000 : false;
|
||||
},
|
||||
})
|
||||
|
||||
const hasAccounts = accountList != null && accountList.items.length > 0;
|
||||
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "اترك فارغًا للاحتفاظ بكلمة المرور الحالية، أو أدخل كلمة مرور جديدة لتحديثها.",
|
||||
"leaveEmptyToKeepPassword": "اترك فارغًا للاحتفاظ بكلمة المرور الحالية",
|
||||
"login_name": "اسم الدخول",
|
||||
"maxEmailSizeBytes": "الحد الأقصى لحجم البريد",
|
||||
"maxEmailSizeBytesDescription": "سيتم تخطي الرسائل الأكبر من هذا الحجم. اتركه فارغاً لاستخدام الحد الافتراضي (100 ميجابايت).",
|
||||
"maxEmailSizeBytesPlaceholder": "الافتراضي: 100 ميجابايت",
|
||||
"maxEmailSizeBytesUnlimited": "الافتراضي: 100 ميجابايت",
|
||||
"minutes": "دقائق",
|
||||
"months": "أشهر",
|
||||
"mustBeAtLeast1": "يجب أن يكون 1 على الأقل",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "فشل حذف الحساب",
|
||||
"accountDeleted": "تم حذف الحساب",
|
||||
"accountDeletedDesc": "تم حذف حسابك بنجاح.",
|
||||
"accountDeletionStarted": "بدء حذف الحساب",
|
||||
"accountDeletionStartedDesc": "جاري حذف الحساب في الخلفية، وسيختفي بعد اكتمال التنظيف.",
|
||||
"allResourcesErased": "سيتم مسح جميع الموارد ذات الصلة نهائيًا.",
|
||||
"cannotBeUndone": "لا يمكن التراجع عن هذا الإجراء!",
|
||||
"confirmDelete": "تأكيد الحذف",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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 غير صالح",
|
||||
"maxEmailSizeMustBeNumber": "يجب أن يكون الحد الأقصى لحجم البريد رقماً.",
|
||||
"maxEmailSizeTooLarge": "يجب ألا يتجاوز الحد الأقصى لحجم البريد 100 ميجابايت.",
|
||||
"maxEmailSizeTooSmall": "يجب أن يكون الحد الأقصى لحجم البريد 1 ميجابايت على الأقل.",
|
||||
"passwordMinLength": "يجب أن تتكون كلمة المرور من {{min}} أحرف على الأقل",
|
||||
"passwordRequired": "كلمة المرور مطلوبة عندما تكون طريقة المصادقة هي كلمة المرور",
|
||||
"pleaseEnterPassword": "الرجاء إدخال كلمة المرور الخاصة بك",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "يجب أن يكون حجم الدُفعة على الأكثر 200",
|
||||
"singleRequestBatchSizeTooSmall": "يجب أن يكون حجم الدُفعة على الأقل 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Lad stå tomt for at beholde den eksisterende adgangskode, eller indtast en ny for at opdatere den.",
|
||||
"leaveEmptyToKeepPassword": "Lad stå tomt for at beholde nuværende adgangskode",
|
||||
"login_name": "Logindnavn",
|
||||
"maxEmailSizeBytes": "Maks. e-mailstørrelse",
|
||||
"maxEmailSizeBytesDescription": "E-mails større end dette vil blive oversprunget. Lad være tom for at bruge standarden (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Standard: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Standard: 100 MB",
|
||||
"minutes": "minutter",
|
||||
"months": "Måneder",
|
||||
"mustBeAtLeast1": "Skal være mindst 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Sletning af konto mislykkedes",
|
||||
"accountDeleted": "Konto slettet",
|
||||
"accountDeletedDesc": "Din konto er blevet slettet.",
|
||||
"accountDeletionStarted": "Kontoen slettes nu",
|
||||
"accountDeletionStartedDesc": "Kontoen slettes i baggrunden og forsvinder, når oprydningen er færdig.",
|
||||
"allResourcesErased": "Alle relaterede ressourcer vil blive slettet permanent.",
|
||||
"cannotBeUndone": "Denne handling kan ikke fortrydes!",
|
||||
"confirmDelete": "Bekræft Sletning",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "Maks. e-mailstørrelse skal være et tal.",
|
||||
"maxEmailSizeTooLarge": "Maks. e-mailstørrelse må ikke overstige 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Maks. e-mailstørrelse skal være mindst 1 MB.",
|
||||
"passwordMinLength": "Adgangskoden skal være mindst {{min}} tegn lang",
|
||||
"passwordRequired": "Adgangskode er påkrævet, når godkendelsesmetoden er Adgangskode",
|
||||
"pleaseEnterPassword": "Indtast venligst din adgangskode",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batch‑størrelse skal være højst 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batch‑størrelse skal være mindst 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Leer lassen, um das bestehende Passwort beizubehalten, oder einen neuen Wert eingeben, um es zu aktualisieren.",
|
||||
"leaveEmptyToKeepPassword": "Leer lassen, um das aktuelle Passwort beizubehalten",
|
||||
"login_name": "Anmeldename",
|
||||
"maxEmailSizeBytes": "Max. E-Mail-Größe",
|
||||
"maxEmailSizeBytesDescription": "Größere E-Mails werden übersprungen. Leer lassen, um den Standardwert (100 MB) zu verwenden.",
|
||||
"maxEmailSizeBytesPlaceholder": "Standard: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Standard: 100 MB",
|
||||
"minutes": "Minuten",
|
||||
"months": "Monate",
|
||||
"mustBeAtLeast1": "Muss mindestens 1 sein",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Löschen des Kontos fehlgeschlagen",
|
||||
"accountDeleted": "Konto gelöscht",
|
||||
"accountDeletedDesc": "Ihr Konto wurde erfolgreich gelöscht.",
|
||||
"accountDeletionStarted": "Kontolöschung gestartet",
|
||||
"accountDeletionStartedDesc": "Konto wird im Hintergrund gelöscht und verschwindet nach der Bereinigung.",
|
||||
"allResourcesErased": "Alle zugehörigen Ressourcen werden dauerhaft gelöscht.",
|
||||
"cannotBeUndone": "Diese Aktion kann nicht rückgängig gemacht werden!",
|
||||
"confirmDelete": "Löschung bestätigen",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "Die maximale E-Mail-Größe muss eine Zahl sein.",
|
||||
"maxEmailSizeTooLarge": "Die maximale E-Mail-Größe darf 100 MB nicht überschreiten.",
|
||||
"maxEmailSizeTooSmall": "Die maximale E-Mail-Größe muss mindestens 1 MB betragen.",
|
||||
"passwordMinLength": "Das Passwort muss mindestens {{min}} Zeichen lang sein",
|
||||
"passwordRequired": "Passwort ist erforderlich, wenn die Authentifizierungsmethode Passwort ist",
|
||||
"pleaseEnterPassword": "Bitte geben Sie Ihr Passwort ein",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Die Stapelgröße darf höchstens 200 sein",
|
||||
"singleRequestBatchSizeTooSmall": "Die Stapelgröße muss mindestens 10 sein"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Leave empty to keep the existing password, or enter a new password to update it.",
|
||||
"leaveEmptyToKeepPassword": "Leave empty to keep current password",
|
||||
"login_name": "Login Name",
|
||||
"maxEmailSizeBytes": "Max email size",
|
||||
"maxEmailSizeBytesDescription": "Emails larger than this will be skipped. Leave empty to use the default (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Default: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Default: 100 MB",
|
||||
"minutes": "minutes",
|
||||
"months": "Months",
|
||||
"mustBeAtLeast1": "Must be at least 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Account delete Failed",
|
||||
"accountDeleted": "Account Deleted",
|
||||
"accountDeletedDesc": "Your account has been successfully deleted.",
|
||||
"accountDeletionStarted": "Account deletion started",
|
||||
"accountDeletionStartedDesc": "Account is being deleted in the background and will disappear after cleanup.",
|
||||
"allResourcesErased": "All related resources will be permanently erased.",
|
||||
"cannotBeUndone": "This action cannot be undone!",
|
||||
"confirmDelete": "Confirm Delete",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "Max email size must be a number.",
|
||||
"maxEmailSizeTooLarge": "Max email size must not exceed 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Max email size must be at least 1 MB.",
|
||||
"passwordMinLength": "Password must be at least {{min}} characters long",
|
||||
"passwordRequired": "Password is required when auth method is Password",
|
||||
"pleaseEnterPassword": "Please enter your password",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batch size must be at most 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batch size must be at least 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Deja vacío para mantener la contraseña existente, o introduce un nuevo valor para actualizarla.",
|
||||
"leaveEmptyToKeepPassword": "Deja vacío para mantener la contraseña actual",
|
||||
"login_name": "Nombre de usuario",
|
||||
"maxEmailSizeBytes": "Tamaño máx. de correo",
|
||||
"maxEmailSizeBytesDescription": "Se omitirán los correos más grandes. Déjelo vacío para usar el valor predeterminado (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Predeterminado: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Predeterminado: 100 MB",
|
||||
"minutes": "minutos",
|
||||
"months": "Meses",
|
||||
"mustBeAtLeast1": "Debe ser al menos 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Error al eliminar la cuenta",
|
||||
"accountDeleted": "Cuenta eliminada",
|
||||
"accountDeletedDesc": "Tu cuenta ha sido eliminada con éxito.",
|
||||
"accountDeletionStarted": "Eliminación de cuenta iniciada",
|
||||
"accountDeletionStartedDesc": "La cuenta se está eliminando en segundo plano y desaparecerá tras la limpieza.",
|
||||
"allResourcesErased": "Todos los recursos asociados se borrarán permanentemente.",
|
||||
"cannotBeUndone": "¡Esta acción no se puede deshacer!",
|
||||
"confirmDelete": "Confirmar eliminación",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "El tamaño máximo de correo debe ser un número.",
|
||||
"maxEmailSizeTooLarge": "El tamaño máximo de correo no debe superar los 100 MB.",
|
||||
"maxEmailSizeTooSmall": "El tamaño máximo de correo debe ser de al menos 1 MB.",
|
||||
"passwordMinLength": "La contraseña debe tener al menos {{min}} caracteres",
|
||||
"passwordRequired": "La contraseña es obligatoria cuando el método de autenticación es Contraseña",
|
||||
"pleaseEnterPassword": "Por favor, introduce tu contraseña",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "El tamaño del lote debe ser como máximo 200",
|
||||
"singleRequestBatchSizeTooSmall": "El tamaño del lote debe ser al menos 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Jätä tyhjäksi säilyttääksesi olemassa olevan salasanan, tai syötä uusi päivittääksesi sen.",
|
||||
"leaveEmptyToKeepPassword": "Jätä tyhjäksi säilyttääksesi nykyisen salasanan",
|
||||
"login_name": "Kirjautumisnimi",
|
||||
"maxEmailSizeBytes": "Sähköpostin maksimikoko",
|
||||
"maxEmailSizeBytesDescription": "Tätä suuremmat sähköpostit ohitetaan. Jätä tyhjäksi käyttääksesi oletusarvoa (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Oletus: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Oletus: 100 MB",
|
||||
"minutes": "minuuttia",
|
||||
"months": "Kuukautta",
|
||||
"mustBeAtLeast1": "Täytyy olla vähintään 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Tilin poistaminen epäonnistui",
|
||||
"accountDeleted": "Tili poistettu",
|
||||
"accountDeletedDesc": "Tilisi on poistettu onnistuneesti.",
|
||||
"accountDeletionStarted": "Tilin poistaminen aloitettu",
|
||||
"accountDeletionStartedDesc": "Tiliä poistetaan taustalla. Se katoaa, kun puhdistus on valmis.",
|
||||
"allResourcesErased": "Kaikki liittyvät resurssit poistetaan pysyvästi.",
|
||||
"cannotBeUndone": "Tätä toimenpidettä ei voi kumota!",
|
||||
"confirmDelete": "Vahvista poisto",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "Sähköpostin maksimikoon on oltava numero.",
|
||||
"maxEmailSizeTooLarge": "Sähköpostin maksimikoko ei saa ylittää 100 megatavua.",
|
||||
"maxEmailSizeTooSmall": "Sähköpostin maksimikoon on oltava vähintään 1 MB.",
|
||||
"passwordMinLength": "Salasanan on oltava vähintään {{min}} merkkiä pitkä",
|
||||
"passwordRequired": "Salasana on pakollinen, kun todennusmenetelmä on Salasana",
|
||||
"pleaseEnterPassword": "Syötä salasanasi",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Eräkoko tulee olla enintään 200",
|
||||
"singleRequestBatchSizeTooSmall": "Eräkoko tulee olla vähintään 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Laissez vide pour conserver le mot de passe existant, ou entrez-en un nouveau pour le mettre à jour.",
|
||||
"leaveEmptyToKeepPassword": "Laisser vide pour conserver le mot de passe actuel",
|
||||
"login_name": "Nom de connexion",
|
||||
"maxEmailSizeBytes": "Taille max. des e-mails",
|
||||
"maxEmailSizeBytesDescription": "Les e-mails plus grands seront ignorés. Laisser vide pour utiliser la valeur par défaut (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Par défaut : 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Par défaut : 100 MB",
|
||||
"minutes": "minutes",
|
||||
"months": "Mois",
|
||||
"mustBeAtLeast1": "Doit être au moins 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Échec de la suppression du compte",
|
||||
"accountDeleted": "Compte Supprimé",
|
||||
"accountDeletedDesc": "Votre compte a été supprimé avec succès.",
|
||||
"accountDeletionStarted": "Suppression du compte lancée",
|
||||
"accountDeletionStartedDesc": "Compte en cours de suppression en arrière-plan, disparaîtra après nettoyage.",
|
||||
"allResourcesErased": "Toutes les ressources associées seront effacées définitivement.",
|
||||
"cannotBeUndone": "Cette action ne peut pas être annulée !",
|
||||
"confirmDelete": "Confirmer la Suppression",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "La taille maximale des e-mails doit être un nombre.",
|
||||
"maxEmailSizeTooLarge": "La taille maximale des e-mails ne doit pas dépasser 100 MB.",
|
||||
"maxEmailSizeTooSmall": "La taille maximale des e-mails doit être d'au moins 1 MB.",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins {{min}} caractères",
|
||||
"passwordRequired": "Le mot de passe est obligatoire lorsque la méthode d'authentification est Mot de passe",
|
||||
"pleaseEnterPassword": "Veuillez entrer votre mot de passe",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "La taille du lot doit être au plus 200",
|
||||
"singleRequestBatchSizeTooSmall": "La taille du lot doit être au moins 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Lascia vuoto per mantenere la password esistente, o inseriscine una nuova per aggiornarla.",
|
||||
"leaveEmptyToKeepPassword": "Lascia vuoto per mantenere la password attuale",
|
||||
"login_name": "Nome di accesso",
|
||||
"maxEmailSizeBytes": "Dimensione massima email",
|
||||
"maxEmailSizeBytesDescription": "Le email più grandi saranno ignorate. Lascia vuoto per utilizzare il valore predefinito (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Predefinito: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Predefinito: 100 MB",
|
||||
"minutes": "minuti",
|
||||
"months": "Mesi",
|
||||
"mustBeAtLeast1": "Deve essere almeno 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Eliminazione account fallita",
|
||||
"accountDeleted": "Account Eliminato",
|
||||
"accountDeletedDesc": "Il tuo account è stato eliminato con successo.",
|
||||
"accountDeletionStarted": "Eliminazione account avviata",
|
||||
"accountDeletionStartedDesc": "L'account è in fase di eliminazione in background e scomparirà dopo la pulizia.",
|
||||
"allResourcesErased": "Tutte le risorse correlate verranno cancellate permanentemente.",
|
||||
"cannotBeUndone": "Questa azione non può essere annullata!",
|
||||
"confirmDelete": "Conferma Eliminazione",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "La dimensione massima dell'email deve essere un numero.",
|
||||
"maxEmailSizeTooLarge": "La dimensione massima dell'email non deve superare i 100 MB.",
|
||||
"maxEmailSizeTooSmall": "La dimensione massima dell'email deve essere di almeno 1 MB.",
|
||||
"passwordMinLength": "La password deve contenere almeno {{min}} caratteri",
|
||||
"passwordRequired": "La password è obbligatoria quando il metodo di autenticazione è Password",
|
||||
"pleaseEnterPassword": "Inserisci la tua password",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "La dimensione del batch deve essere al massimo 200",
|
||||
"singleRequestBatchSizeTooSmall": "La dimensione del batch deve essere almeno 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "既存のパスワードを保持する場合は空欄にしてください。更新する場合は新しいパスワードを入力してください。",
|
||||
"leaveEmptyToKeepPassword": "現在のパスワードを保持する場合は空欄にしてください",
|
||||
"login_name": "ログイン名",
|
||||
"maxEmailSizeBytes": "最大メールサイズ",
|
||||
"maxEmailSizeBytesDescription": "これより大きいメールはスキップされます。空欄にするとデフォルト(100 MB)が使用されます。",
|
||||
"maxEmailSizeBytesPlaceholder": "デフォルト:100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "デフォルト:100 MB",
|
||||
"minutes": "分",
|
||||
"months": "月",
|
||||
"mustBeAtLeast1": "1以上である必要があります",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "アカウントの削除に失敗しました",
|
||||
"accountDeleted": "アカウントが削除されました",
|
||||
"accountDeletedDesc": "アカウントが正常に削除されました。",
|
||||
"accountDeletionStarted": "アカウントの削除を開始しました",
|
||||
"accountDeletionStartedDesc": "バックグラウンドで削除中です。完了するとリストから消えます。",
|
||||
"allResourcesErased": "関連するすべてのリソースは完全に消去されます。",
|
||||
"cannotBeUndone": "この操作は元に戻せません!",
|
||||
"confirmDelete": "削除の確認",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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です",
|
||||
"maxEmailSizeMustBeNumber": "最大メールサイズは数値で入力してください。",
|
||||
"maxEmailSizeTooLarge": "最大メールサイズは 100 MB 以下にしてください。",
|
||||
"maxEmailSizeTooSmall": "最大メールサイズは 1 MB 以上にしてください。",
|
||||
"passwordMinLength": "パスワードは{{min}}文字以上である必要があります",
|
||||
"passwordRequired": "認証方式がパスワードの場合、パスワードは必須です",
|
||||
"pleaseEnterPassword": "パスワードを入力してください",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "バッチサイズは最大でも200でなければなりません",
|
||||
"singleRequestBatchSizeTooSmall": "バッチサイズは最低でも10でなければなりません"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "기존 비밀번호를 유지하려면 비워 두십시오. 업데이트할 경우에만 새 비밀번호를 입력하십시오.",
|
||||
"leaveEmptyToKeepPassword": "현재 비밀번호를 유지하려면 비워 두십시오",
|
||||
"login_name": "로그인 이름",
|
||||
"maxEmailSizeBytes": "최대 이메일 크기",
|
||||
"maxEmailSizeBytesDescription": "이보다 큰 이메일은 건너뜁니다. 기본값(100 MB)을 사용하려면 비워두세요.",
|
||||
"maxEmailSizeBytesPlaceholder": "기본값: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "기본값: 100 MB",
|
||||
"minutes": "분",
|
||||
"months": "개월",
|
||||
"mustBeAtLeast1": "최소 1 이상이어야 합니다",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "계정 삭제 실패",
|
||||
"accountDeleted": "계정 삭제됨",
|
||||
"accountDeletedDesc": "계정이 성공적으로 삭제되었습니다.",
|
||||
"accountDeletionStarted": "계정 삭제 시작됨",
|
||||
"accountDeletionStartedDesc": "백그라운드에서 삭제 중이며, 정리가 끝나면 목록에서 사라집니다.",
|
||||
"allResourcesErased": "모든 관련 리소스가 영구적으로 지워집니다.",
|
||||
"cannotBeUndone": "이 작업은 되돌릴 수 없습니다!",
|
||||
"confirmDelete": "삭제 확인",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "최대 이메일 크기는 숫자여야 합니다.",
|
||||
"maxEmailSizeTooLarge": "최대 이메일 크기는 100 MB를 초과할 수 없습니다.",
|
||||
"maxEmailSizeTooSmall": "최대 이메일 크기는 최소 1 MB여야 합니다.",
|
||||
"passwordMinLength": "비밀번호는 {{min}}자 이상이어야 합니다",
|
||||
"passwordRequired": "인증 방법이 비밀번호인 경우 비밀번호는 필수입니다",
|
||||
"pleaseEnterPassword": "비밀번호를 입력하십시오",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "배치 크기는 최대 200이어야 합니다",
|
||||
"singleRequestBatchSizeTooSmall": "배치 크기는 최소 10이어야 합니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Laat leeg om het bestaande wachtwoord te behouden, of voer een nieuw wachtwoord in om het bij te werken.",
|
||||
"leaveEmptyToKeepPassword": "Laat leeg om huidig wachtwoord te behouden",
|
||||
"login_name": "Inlognaam",
|
||||
"maxEmailSizeBytes": "Max. e-mailgrootte",
|
||||
"maxEmailSizeBytesDescription": "E-mails groter dan dit worden overgeslagen. Laat leeg om de standaard (100 MB) te gebruiken.",
|
||||
"maxEmailSizeBytesPlaceholder": "Standaard: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Standaard: 100 MB",
|
||||
"minutes": "minuten",
|
||||
"months": "Maanden",
|
||||
"mustBeAtLeast1": "Moet ten minste 1 zijn",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Account verwijderen Mislukt",
|
||||
"accountDeleted": "Account Verwijderd",
|
||||
"accountDeletedDesc": "Uw account is succesvol verwijderd.",
|
||||
"accountDeletionStarted": "Verwijdering account gestart",
|
||||
"accountDeletionStartedDesc": "Account wordt op de achtergrond verwijderd en verdwijnt na opschonen.",
|
||||
"allResourcesErased": "Alle gerelateerde bronnen worden permanent gewist.",
|
||||
"cannotBeUndone": "Deze actie kan niet ongedaan worden gemaakt!",
|
||||
"confirmDelete": "Verwijdering Bevestigen",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "Maximale e-mailgrootte moet un nummer zijn.",
|
||||
"maxEmailSizeTooLarge": "Maximale e-mailgrootte mag niet groter zijn dan 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Maximale e-mailgrootte moet minstens 1 MB zijn.",
|
||||
"passwordMinLength": "Wachtwoord moet ten minste {{min}} tekens lang zijn",
|
||||
"passwordRequired": "Wachtwoord is vereist wanneer de authenticatiemethode Wachtwoord is",
|
||||
"pleaseEnterPassword": "Voer uw wachtwoord in",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batch‑grootte moet hoogstens 200 zijn",
|
||||
"singleRequestBatchSizeTooSmall": "Batch‑grootte moet ten minste 10 zijn"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "La stå tomt for å beholde det eksisterende passordet, eller skriv inn et nytt passord for å oppdatere det.",
|
||||
"leaveEmptyToKeepPassword": "La stå tomt for å beholde nåværende passord",
|
||||
"login_name": "Påloggingsnavn",
|
||||
"maxEmailSizeBytes": "Maks. e-poststørrelse",
|
||||
"maxEmailSizeBytesDescription": "E-poster større enn dette vil bli hoppet over. La stå tom for å bruke standarden (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Standard: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Standard: 100 MB",
|
||||
"minutes": "minutter",
|
||||
"months": "Måneder",
|
||||
"mustBeAtLeast1": "Må være minst 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Sletting av konto mislyktes",
|
||||
"accountDeleted": "Konto slettet",
|
||||
"accountDeletedDesc": "Kontoen din har blitt slettet.",
|
||||
"accountDeletionStarted": "Kontosletting startet",
|
||||
"accountDeletionStartedDesc": "Kontoen slettes i bakgrunnen og forsvinner når opprydningen er ferdig.",
|
||||
"allResourcesErased": "Alle relaterte ressurser vil bli permanent slettet.",
|
||||
"cannotBeUndone": "Denne handlingen kan ikke angres!",
|
||||
"confirmDelete": "Bekreft sletting",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "Maks. e-poststørrelse må være et tall.",
|
||||
"maxEmailSizeTooLarge": "Maks. e-poststørrelse må ikke overstige 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Maks. e-poststørrelse må være minst 1 MB.",
|
||||
"passwordMinLength": "Passordet må være minst {{min}} tegn langt",
|
||||
"passwordRequired": "Passord er påkrevd når autentiseringsmetoden er Passord",
|
||||
"pleaseEnterPassword": "Vennligst skriv inn passordet ditt",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batchstørrelse må være maksimalt 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batchstørrelse må være minst 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Pozostaw to pole puste, jeśli chcesz zachować dotychczasowe hasło lub wpisz nowe, aby zaktualizować.",
|
||||
"leaveEmptyToKeepPassword": "Pozostaw to pole puste, jeśli chcesz zachować dotychczasowe hasło",
|
||||
"login_name": "Login",
|
||||
"maxEmailSizeBytes": "Maks. rozmiar e-maila",
|
||||
"maxEmailSizeBytesDescription": "Większe wiadomości zostaną pominięte. Pozostaw puste, aby użyć domyślnego limitu (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Domyślnie: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Domyślnie: 100 MB",
|
||||
"minutes": "minut",
|
||||
"months": "Miesiące",
|
||||
"mustBeAtLeast1": "Nie mniej jak 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Bład podczas usuwania konta",
|
||||
"accountDeleted": "Konto usunięte",
|
||||
"accountDeletedDesc": "Konto zostało usunięte.",
|
||||
"accountDeletionStarted": "Rozpoczęto usuwanie konta",
|
||||
"accountDeletionStartedDesc": "Konto jest usuwane w tle i zniknie po zakończeniu czyszczenia.",
|
||||
"allResourcesErased": "Wszystkie powiązane zasoby zostaną trwale usunięte.",
|
||||
"cannotBeUndone": "Tej czynności nie można cofnąć!",
|
||||
"confirmDelete": "Potwierdź usunięcie",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "Maksymalny rozmiar e-maila musi być liczbą.",
|
||||
"maxEmailSizeTooLarge": "Maksymalny rozmiar e-maila nie może przekraczać 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Maksymalny rozmiar e-maila musi wynosić co najmniej 1 MB.",
|
||||
"passwordMinLength": "Hasło musi posiadać conajmniej {{min}} znaków",
|
||||
"passwordRequired": "Hasło jest wymagane, gdy metodą uwierzytelniania jest hasło",
|
||||
"pleaseEnterPassword": "Proszę podać hasło",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Rozmiar partii może wynosić maksymalnie 200",
|
||||
"singleRequestBatchSizeTooSmall": "Rozmiar partii musi wynosić co najmniej 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Deixe vazio para manter a senha existente. Insira a nova senha apenas se estiver atualizando.",
|
||||
"leaveEmptyToKeepPassword": "Deixe vazio para manter a senha atual",
|
||||
"login_name": "Nome de login",
|
||||
"maxEmailSizeBytes": "Tamanho máx. do email",
|
||||
"maxEmailSizeBytesDescription": "Emails maiores do que isso serão ignorados. Deixe vazio para usar o padrão (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Padrão: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Padrão: 100 MB",
|
||||
"minutes": "minutos",
|
||||
"months": "Meses",
|
||||
"mustBeAtLeast1": "Deve ser pelo menos 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Falha ao Excluir Conta",
|
||||
"accountDeleted": "Conta Excluída",
|
||||
"accountDeletedDesc": "A conta foi excluída com sucesso.",
|
||||
"accountDeletionStarted": "Exclusão da conta iniciada",
|
||||
"accountDeletionStartedDesc": "A conta está sendo excluída em segundo plano e desaparecerá após a limpeza.",
|
||||
"allResourcesErased": "Todos os recursos relacionados serão permanentemente apagados.",
|
||||
"cannotBeUndone": "Esta ação não pode ser desfeita!",
|
||||
"confirmDelete": "Confirmar Exclusão",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "O tamanho máximo do email deve ser um número.",
|
||||
"maxEmailSizeTooLarge": "O tamanho máximo do email não deve exceder 100 MB.",
|
||||
"maxEmailSizeTooSmall": "O tamanho máximo do email deve ser de pelo menos 1 MB.",
|
||||
"passwordMinLength": "A senha deve ter pelo menos {{min}} caracteres",
|
||||
"passwordRequired": "A senha é obrigatória se o método de autenticação for Senha",
|
||||
"pleaseEnterPassword": "Por favor, insira a senha",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "O tamanho do lote deve ser no máximo 200",
|
||||
"singleRequestBatchSizeTooSmall": "O tamanho do lote deve ser pelo menos 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Оставьте пустым, чтобы сохранить существующий пароль, или введите новый для обновления.",
|
||||
"leaveEmptyToKeepPassword": "Оставьте пустым, чтобы сохранить текущий пароль",
|
||||
"login_name": "Имя для входа",
|
||||
"maxEmailSizeBytes": "Макс. размер письма",
|
||||
"maxEmailSizeBytesDescription": "Письма больше этого размера будут пропущены. Оставьте пустым для использования значения по умолчанию (100 МБ).",
|
||||
"maxEmailSizeBytesPlaceholder": "По умолчанию: 100 МБ",
|
||||
"maxEmailSizeBytesUnlimited": "По умолчанию: 100 МБ",
|
||||
"minutes": "минут",
|
||||
"months": "Месяцы",
|
||||
"mustBeAtLeast1": "Должно быть не менее 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Ошибка удаления аккаунта",
|
||||
"accountDeleted": "Аккаунт удален",
|
||||
"accountDeletedDesc": "Ваш аккаунт был успешно удален.",
|
||||
"accountDeletionStarted": "Удаление аккаунта запущено",
|
||||
"accountDeletionStartedDesc": "Аккаунт удаляется в фоновом режиме и исчезнет после очистки.",
|
||||
"allResourcesErased": "Все связанные ресурсы будут безвозвратно стерты.",
|
||||
"cannotBeUndone": "Это действие нельзя отменить!",
|
||||
"confirmDelete": "Подтвердить удаление",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "Максимальный размер письма должен быть числом.",
|
||||
"maxEmailSizeTooLarge": "Максимальный размер письма не должен превышать 100 МБ.",
|
||||
"maxEmailSizeTooSmall": "Максимальный размер письма должен быть не менее 1 МБ.",
|
||||
"passwordMinLength": "Пароль должен быть не менее {{min}} символов",
|
||||
"passwordRequired": "Пароль обязателен, когда метод авторизации - Пароль",
|
||||
"pleaseEnterPassword": "Пожалуйста, введите ваш пароль",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Размер пакета должен быть не более 200",
|
||||
"singleRequestBatchSizeTooSmall": "Размер пакета должен быть не менее 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Lämna tomt för att behålla det befintliga lösenordet, eller ange ett nytt för att uppdatera det.",
|
||||
"leaveEmptyToKeepPassword": "Lämna tomt för att behålla nuvarande lösenord",
|
||||
"login_name": "Inloggningsnamn",
|
||||
"maxEmailSizeBytes": "Max e-poststorlek",
|
||||
"maxEmailSizeBytesDescription": "E-post större än detta kommer att hoppas över. Lämna tomt för att använda standard (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Standard: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Standard: 100 MB",
|
||||
"minutes": "minuter",
|
||||
"months": "Månader",
|
||||
"mustBeAtLeast1": "Måste vara minst 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Borttagning av konto misslyckades",
|
||||
"accountDeleted": "Konto raderat",
|
||||
"accountDeletedDesc": "Ditt konto har tagits bort.",
|
||||
"accountDeletionStarted": "Kontoradering har startat",
|
||||
"accountDeletionStartedDesc": "Kontot raderas i bakgrunden och försvinner när rensningen är klar.",
|
||||
"allResourcesErased": "Alla relaterade resurser kommer att raderas permanent.",
|
||||
"cannotBeUndone": "Denna åtgärd kan inte ångras!",
|
||||
"confirmDelete": "Bekräfta borttagning",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "Max e-poststorlek måste vara ett nummer.",
|
||||
"maxEmailSizeTooLarge": "Max e-poststorlek får inte överstiga 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Max e-poststorlek måste vara minst 1 MB.",
|
||||
"passwordMinLength": "Lösenordet måste vara minst {{min}} tecken långt",
|
||||
"passwordRequired": "Lösenord krävs när autentiseringsmetoden är Lösenord",
|
||||
"pleaseEnterPassword": "Vänligen ange ditt lösenord",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batchstorlek måste vara högst 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batchstorlek måste vara minst 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "保留現有密碼請留空。若要更新,請輸入新密碼。",
|
||||
"leaveEmptyToKeepPassword": "保留現有密碼請留空",
|
||||
"login_name": "登入名稱",
|
||||
"maxEmailSizeBytes": "最大郵件大小",
|
||||
"maxEmailSizeBytesDescription": "超出此大小的郵件將被跳過。留空則使用預設值(100 MB)。",
|
||||
"maxEmailSizeBytesPlaceholder": "預設:100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "預設:100 MB",
|
||||
"minutes": "分鐘",
|
||||
"months": "月",
|
||||
"mustBeAtLeast1": "必須大於或等於 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "帳號刪除失敗",
|
||||
"accountDeleted": "帳號已刪除",
|
||||
"accountDeletedDesc": "帳號已成功刪除。",
|
||||
"accountDeletionStarted": "帳戶刪除已開始",
|
||||
"accountDeletionStartedDesc": "帳戶正在背景刪除,清理完成後將從列表中消失。",
|
||||
"allResourcesErased": "所有相關資源將被永久清除。",
|
||||
"cannotBeUndone": "此操作無法復原!",
|
||||
"confirmDelete": "確認刪除",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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": "無效的網址",
|
||||
"maxEmailSizeMustBeNumber": "最大郵件大小必須是數字。",
|
||||
"maxEmailSizeTooLarge": "最大郵件大小不能超過 100 MB。",
|
||||
"maxEmailSizeTooSmall": "最大郵件大小不能小於 1 MB。",
|
||||
"passwordMinLength": "密碼長度必須至少 {{min}} 個字元",
|
||||
"passwordRequired": "如果驗證方法是密碼,則密碼為必填項",
|
||||
"pleaseEnterPassword": "請輸入密碼",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "批次大小必須最多為200",
|
||||
"singleRequestBatchSizeTooSmall": "批次大小必須至少為10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "留空以保持现有密码,或输入新密码进行更新。",
|
||||
"leaveEmptyToKeepPassword": "留空以保持当前密码",
|
||||
"login_name": "登录名",
|
||||
"maxEmailSizeBytes": "最大邮件大小",
|
||||
"maxEmailSizeBytesDescription": "超出此大小的邮件将被跳过。留空则使用默认值(100 MB)。",
|
||||
"maxEmailSizeBytesPlaceholder": "默认:100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "默认:100 MB",
|
||||
"minutes": "分钟",
|
||||
"months": "月",
|
||||
"mustBeAtLeast1": "必须至少为 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "账户删除失败",
|
||||
"accountDeleted": "账户已删除",
|
||||
"accountDeletedDesc": "您的账户已成功删除。",
|
||||
"accountDeletionStarted": "账户删除已开始",
|
||||
"accountDeletionStartedDesc": "账户正在后台删除,清理完成后将从列表中消失。",
|
||||
"allResourcesErased": "所有相关资源将被永久删除。",
|
||||
"cannotBeUndone": "此操作无法撤销!",
|
||||
"confirmDelete": "确认删除",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"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",
|
||||
"maxEmailSizeMustBeNumber": "最大邮件大小必须是数字。",
|
||||
"maxEmailSizeTooLarge": "最大邮件大小不能超过 100 MB。",
|
||||
"maxEmailSizeTooSmall": "最大邮件大小不能小于 1 MB。",
|
||||
"passwordMinLength": "密码长度至少为 {{min}} 个字符",
|
||||
"passwordRequired": "当认证方法为密码时,密码为必填项",
|
||||
"pleaseEnterPassword": "请输入您的密码",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "批大小必须最多为200",
|
||||
"singleRequestBatchSizeTooSmall": "批大小必须至少为10"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user