31 Commits
1.4.0 ... 1.5.3

Author SHA1 Message Date
rustmailer
6fcc9e0e8e bump to v1.5.3 2026-06-23 17:18:46 +08:00
rustmailer
220aa268c1 feat(imap): handle UIDVALIDITY changes via Message-ID comparison instead of full rebuild 2026-06-23 10:46:36 +08:00
rustmailer
e3fd9d2f29 fix: purge DedupCache entries on account/mailbox/envelope removal 2026-06-22 17:11:20 +08:00
rustmailer
41c3b84e65 feat(ui): persist account table sorting to localStorage 2026-06-21 12:50:21 +08:00
rustmailer
89557700ae Merge branch 'main' of https://github.com/rustmailer/bichon 2026-06-11 09:23:00 +08:00
rustmailer
2f5de48c6a fix(smtp): reject journaling attempts to non-local accounts 2026-06-11 09:22:57 +08:00
rustmailer
debb119d3d Merge pull request #298 from shadowdao/fix/smtp-inbox-uidvalidity-clobber-297
fix(smtp): don't clobber the IMAP-owned INBOX uid_validity on journal ingest (#297)
2026-06-11 09:10:01 +08:00
Josh
ce3f8944a3 fix(smtp): don't clobber the IMAP-owned INBOX uid_validity on journal ingest
When SMTP journaling ingests a message, parse_email() upserts the account's
INBOX MailBox row so the journaled envelope has a row to attach to. But it
built the row with uid_validity/highest_uid/uid_next = None and called
batch_upsert, which replaces the WHOLE row. The INBOX row id
(create_hash(account_id, "INBOX")) is the same id the IMAP sync maintains,
so every journaled delivery reset the IMAP-maintained uid_validity to None.

On the next reconcile, local_mailbox.uid_validity != Some(remote) is then
true, so the mailbox is treated as invalid and wiped + rebuilt. For a large,
UID-sparse INBOX whose rebuild gets interrupted, the local copy is silently
lost and never restored (the incremental fetch resumes past all existing
UIDs). See #297 for the full diagnosis and DB evidence.

Fix: only create the INBOX row when it does not already exist; otherwise
leave the IMAP-owned row untouched. The journaling path only needs the row
to exist so the envelope can attach to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:36:38 -07:00
rustmailer
6f572b15ae deps: upgrade fjall to 3.1.5 to address NFS Bad file descriptor error #216 #242 #239 #231 #240 2026-06-09 01:33:17 +08:00
rustmailer
3bfc080258 bump to 1.5.1 2026-06-07 18:21:07 +08:00
rustmailer
c3a725770c fix: reconnect and retry IMAP batch on BrokenPipe/network errors 2026-06-07 18:18:37 +08:00
rustmailer
c736afffb0 fix: account deletion times out #291 2026-06-07 15:34:38 +08:00
rustmailer
62cb5264fd Add funding.json for project funding details 2026-06-06 15:56:47 +08:00
rustmailer
aebb94ee4e fix: preserve non-stored search fields when updating envelope tags
update_envelope_tags lost f_body, f_from_text, f_to_text, f_cc_text,
  f_bcc_text, f_attachment_name_text and f_attachment_name_exact because
  they are not STORED and field_values() skipped them during delete+add.
  Rebuild these from stored counterparts and the blob-store EML.
2026-06-05 09:13:25 +08:00
rustmailer
769630f9d7 Merge branch 'main' of https://github.com/rustmailer/bichon 2026-06-04 23:46:28 +08:00
rustmailer
368b18c45f bump to v1.5.0 2026-06-04 23:46:25 +08:00
rustmailer
42861f6cc9 Merge pull request #288 from Korov/fix/tencent-mail-uidvalidity
fix: Add fallback UIDVALIDITY support for non-compliant IMAP servers
2026-06-04 23:45:40 +08:00
rustmailer
327a3f39d9 Merge pull request #287 from fama/dedup-cache-fix
fix: open NewIndexWriter once across all migration segments
2026-06-04 10:24:22 +08:00
fama
427f7248d2 fix: open NewIndexWriter once across all migration segments
Previously, do_migrate_segment created a fresh NewIndexWriter (and
therefore a new Fjall Database) on every call, meaning the Fjall
database at bichon-storage/ was opened and closed once per segment.

This caused the migration to fail mid-way through (observed at segment
9/16) with:

  Storage(InvalidTag(("ChecksumType", 171)))

Root cause: after segment N writes email blobs via Fjall's ingestion
API (start_ingestion / write / finish), those SSTables and KV-separated
blob files are flushed to disk and the Database is dropped. When segment
N+1 calls Database::builder(storage_dir).open(), Fjall must discover and
catalog all on-disk files produced by the previous segments. During that
discovery it reads SSTable or blob-file block headers and encounters a
ChecksumType discriminant byte (171 / 0xAB) that lsm-tree 3.1.4 does
not recognise, causing the fatal error.

The first N segments succeed because the cumulative set of ingested
SSTables stays small enough that Fjall does not need to read the
offending headers during reopen. Once enough data has accumulated the
reopen triggers a manifest or compaction read that exposes the mismatch.

Fix: open NewIndexWriter once, before the segment loop, and pass a
&mut reference into each do_migrate_segment call. finish_writers() is
called a single time after all segments complete. The Fjall Database
stays open for the entire migration and is never closed and reopened,
eliminating the incompatible-reopen path entirely.
2026-06-03 15:10:03 -06:00
rustmailer
a2a51a2037 feat(imap): add message size check before download 2026-06-03 21:17:35 +08:00
rustmailer
4d783d5301 Update Cargo.lock 2026-05-31 06:05:50 +08:00
rustmailer
4172f11f00 Update Cargo.toml 2026-05-31 06:05:41 +08:00
rustmailer
257736a47b fix: add in-memory dedup cache to prevent duplicate emails before indexing
Use (account_id, mailbox_id, content_hash) as dedup key with time-based
  eviction to bound memory at ~50MB. Check happens before mail parsing,
  so duplicate emails skip all expensive work entirely.

  - Populate cache from Tantivy FAST columns on startup (7-day window)
  - Evict oldest 1/4 of entries when exceeding 300K capacity
  - Graceful degradation: populate failure → empty cache, still works
2026-05-31 06:04:58 +08:00
Lei Zhu
e8469da3bc fix: Add fallback UIDVALIDITY support for non-compliant IMAP servers
This commit adds support for IMAP servers that don't provide UIDVALIDITY,
such as Tencent Enterprise Mail (腾讯企业邮箱).

Changes:
- Added `generate_synthetic_uidvalidity()` function that creates a stable
  hash-based UIDVALIDITY from the mailbox name
- Modified `reconcile_mailboxes()` to use synthetic UIDVALIDITY when the
  server doesn't provide one
- Servers without UIDVALIDITY can now sync all mailboxes including system
  folders (Sent Messages, Drafts, Deleted Messages)
- Incremental sync is supported via the synthetic UIDVALIDITY
- Added warning logs to indicate when synthetic UIDVALIDITY is in use
- Updated mailbox metadata to store the resolved UIDVALIDITY

Fixes issues with:
- Tencent Enterprise Mail (腾讯企业邮箱)
- Other non-compliant IMAP servers
- Mailboxes that don't properly support UIDVALIDITY"
2026-05-30 16:32:06 +08:00
rustmailer
1346dd216a fix: "No body available" #262
When the request returns an empty body, choose to skip it and print the account ID and UID information, leaving it for the user to investigate themselves. Otherwise, the IMAP download process will be blocked by this.
2026-05-29 16:43:38 +08:00
rustmailer
d40ba90b54 fix: inline attachment detection and account-scoped export
- Treat MIME parts with Content-ID but no Content-Disposition as inline
  - Add account_ids filter to CLI export search to avoid pulling all accounts
  - Skip failed emails during export instead of aborting the entire batch
2026-05-29 16:13:19 +08:00
rustmailer
048d5f361c fix: Cant migrate with version >= 1.4.0 #277 2026-05-28 22:49:24 +08:00
rustmailer
4597df515a Update content.rs 2026-05-28 17:55:13 +08:00
rustmailer
f4be4a2e8c bump to 1.4.1 2026-05-28 17:49:18 +08:00
rustmailer
4a3c42c1eb fix: HTTP Error 500 Internal Server Error: Failed for 58344335-2e86-4009-979d-6da0331bff63 - Failed to export an email. Aborting process... #275 2026-05-28 17:47:10 +08:00
rustmailer
8fcb55320f Update README.md 2026-05-28 02:09:58 +08:00
59 changed files with 4426 additions and 282 deletions

42
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,13 +10,17 @@ use crate::BichonCliConfig;
pub async fn search_messages(
client: &Client,
config: &BichonCliConfig,
account_ids: Option<std::collections::HashSet<u64>>,
page: u64,
page_size: u64,
) -> Option<DataPage<Envelope>> {
let url = format!("{}/api/v1/search-messages", config.base_url);
let payload = EmailSearchRequest {
filter: EmailSearchFilter::default(),
filter: EmailSearchFilter {
account_ids,
..Default::default()
},
page,
page_size,
sort_by: Some(SortBy::DATE),

View File

@@ -146,20 +146,23 @@ pub async fn handle_account_export(
let mut total_pages;
loop {
if let Some(batch) = search_messages(&client, config, current_page, page_size).await {
let account_ids = Some(std::collections::HashSet::from([account.id]));
if let Some(batch) = search_messages(&client, config, account_ids, current_page, page_size).await {
total_pages = batch.total_pages.unwrap();
pb.set_message(format!("Page {}/{}", current_page, total_pages));
for envelope in batch.items {
let success =
download_and_export_with_json_header(&client, config, envelope, &mut file)
download_and_export_with_json_header(&client, config, envelope.clone(), &mut file)
.await;
if !success {
pb.finish_with_message("Failed");
eprintln!(" ✘ Failed to export an email. Aborting process...");
return;
eprintln!(
" ✘ Failed to export email {}, skipping...",
envelope.id
);
continue;
}
pb.inc(1);
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -26,3 +26,5 @@ pub mod session;
pub mod stats;
#[cfg(test)]
mod tests;
#[cfg(test)]
pub mod mock_server;

View File

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

View File

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

View File

@@ -86,13 +86,22 @@ pub fn detach_attachments_standalone(
for (raw_start, raw_end, att) in ranges {
let content_hash = compute_content_hash(att.contents());
blobs.push((
content_hash.clone(),
Bytes::copy_from_slice(&original_body[raw_start..raw_end]),
));
let body_len = original_body.len();
let raw_start = raw_start.min(body_len);
let raw_end = raw_end.min(body_len);
let range_valid = raw_start < raw_end;
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
stripped_eml.splice(raw_start..raw_end, placeholder.as_bytes().iter().cloned());
if range_valid {
blobs.push((
content_hash.clone(),
Bytes::copy_from_slice(&original_body[raw_start..raw_end]),
));
}
if range_valid {
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
stripped_eml.splice(raw_start..raw_end, placeholder.as_bytes().iter().cloned());
}
infos.push(AttachmentInfo {
filename: att.attachment_name().map(|n| n.to_string()),
@@ -100,7 +109,7 @@ pub fn detach_attachments_standalone(
inline: att
.content_disposition()
.map(|d| d.is_inline())
.unwrap_or(false),
.unwrap_or_else(|| att.content_id().is_some()),
file_type: att
.content_type()
.map(|ct| {

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ export type Steps = [...Step[]];
const getSteps = (t: (key: string) => string): Steps => [
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email", "account_name"] },
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous", "login_name"] },
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "date_before", "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,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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