Compare commits

112 Commits

Author SHA1 Message Date
rustmailer
5fe45795b9 bump to 1.6.1 2026-06-28 11:39:58 +08:00
rustmailer
4996dac1ea Update folder-hint.ts 2026-06-28 11:39:44 +08:00
rustmailer
42ce36a439 feat: add searchable account selector with alphabetical sort in user dialog #289 2026-06-28 11:35:39 +08:00
rustmailer
40ae2d49d4 feat: web upload supports PST, configurable MBOX/PST size limits
- Make MBOX/PST upload size limits configurable via SETTINGS
    (bichon_web_mbox_upload_limit_mb defaults to 1 GB,
     bichon_web_pst_upload_limit_mb defaults to 2 GB)
2026-06-28 09:43:50 +08:00
rustmailer
5034760517 fix: detect previewable attachments by file extension when MIME type is octet-stream #309 2026-06-27 16:08:42 +08:00
rustmailer
875eb2e309 fix:Truncate "To" column recipients in Search tab to prevent excessive row height #308 2026-06-27 13:06:41 +08:00
rustmailer
f0d0f202ba bump to 1.6.0 2026-06-26 19:15:46 +08:00
rustmailer
0d37825625 feat: fullscreen attachment gallery with image navigation 2026-06-26 19:13:14 +08:00
rustmailer
cfb8172607 fix: sliding token expiry and silent 401 redirect 2026-06-26 18:29:18 +08:00
rustmailer
8641bb4b56 fix: Proxy does not support formats from proxy providers #307 2026-06-26 18:20:19 +08:00
rustmailer
cdf27f2dd4 feat: add web upload for EML/MBOX files #260 2026-06-26 17:49:35 +08:00
rustmailer
db36272bae feat: add in-browser attachment preview for images, PDFs, and text files #303 2026-06-25 04:51:05 +08:00
rustmailer
cad447275f feat: search for an email address simultaneously in to, cc and bcc #304 2026-06-25 02:36:24 +08:00
rustmailer
8542ba0d28 feat: Display Name / Alias for IMAP Accounts #306 2026-06-25 02:00:20 +08:00
rustmailer
af089958e9 Update README.md 2026-06-24 23:11:52 +08:00
rustmailer
bba1ea5cc7 feat: add per-account FilterRule, ExtractionRules and ArchiveRules with regex validation 2026-06-24 21:46:32 +08:00
rustmailer
9e55026f12 feat: add SSO/OIDC support to user model and settings 2026-06-24 17:32:14 +08:00
rustmailer
b0f229618c refactor(blob): add NFS-safe file I/O layer 2026-06-24 00:03:56 +08:00
rustmailer
ff59d47a0d fix(blob): fsync meta before rename and hold write_mutex during GC 2026-06-23 23:57:34 +08:00
rustmailer
b6957c8ceb test(blob): add concurrent access and crash recovery integration tests 2026-06-23 23:29:48 +08:00
rustmailer
cd98c050b6 fix(blob): invalidate FilePool after GC to prevent stale reads 2026-06-23 23:28:33 +08:00
rustmailer
11e40750fe feat(blob): add FilePool read cache and fix BucketCache TOCTOU 2026-06-23 23:23:03 +08:00
rustmailer
b0374517e8 fix(blob): address review issues - visibility, unused param 2026-06-23 23:20:24 +08:00
rustmailer
4e12ae5457 refactor(blob): replace global RwLock with per-account Arc<AccountHandle> 2026-06-23 23:13:39 +08:00
rustmailer
495c91b7ae fix(blob): address code review issues - CRC guard, tests, clone 2026-06-23 23:10:15 +08:00
rustmailer
e442db4a2d feat(blob): replace JSON metadata with bincode + CRC32 binary format 2026-06-23 23:01:32 +08:00
rustmailer
cc54063669 chore(blob): add bincode dependency and new meta error variants 2026-06-23 22:55:13 +08:00
rustmailer
04745458bc feat(blob): add embedded KV storage engine for email archiving 2026-06-23 21:38:31 +08:00
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
rustmailer
86869ac848 Update release.yml 2026-05-26 20:11:49 +08:00
rustmailer
38453accc6 Update release.yml 2026-05-26 20:07:40 +08:00
rustmailer
a60b2c7dc4 Update release.yml 2026-05-26 20:02:55 +08:00
rustmailer
8e46c7a162 fix: use valid IMAP UID SEARCH instead of BEFORE in UID FETCH for incremental sync 2026-05-26 19:42:26 +08:00
rustmailer
1a615e1c45 bump to v1.4.0 2026-05-26 15:27:34 +08:00
rustmailer
83dd9cdd6b perf: optimize IMAP account fetch flow 2026-05-26 15:23:34 +08:00
rustmailer
f30cd66e00 feat: remove folder limit 2026-05-26 15:22:14 +08:00
rustmailer
4bd714a670 chore: remove custom global allocator 2026-05-26 15:20:59 +08:00
rustmailer
c0a63a1e3c feat: enhance autoconfig detection 2026-05-26 15:19:39 +08:00
rustmailer
04a022e850 add features endpoint 2026-05-24 20:31:58 +08:00
rustmailer
f9c2fc77ff feat(core): wire up attachment text extraction in IMAP sync pipeline 2026-05-24 19:52:28 +08:00
rustmailer
ec3e842bbb chore: add trace logging for duplicate email diagnosis #214 2026-05-24 17:27:01 +08:00
rustmailer
7311529908 bump to v1.3.0 2026-05-24 16:50:15 +08:00
rustmailer
0792bb546d perf: reduce tokio worker thread blocking to improve responsiveness on low-core machines
- Switch memdb durability from Full to Batch(100) with 10s flush worker
  - Offload BlobManager fjall writes to spawn_blocking
  - Wrap Tantivy commit operations in block_in_place
  - Flush memdb WAL on graceful shutdown
2026-05-24 14:34:16 +08:00
rustmailer
0be2670600 update 2026-05-24 02:37:22 +08:00
rustmailer
575f851cfb update 2026-05-24 02:27:52 +08:00
rustmailer
61430b72b0 feat(core): add ext module with EventBus and AttachmentTextExtractor traits 2026-05-24 02:04:28 +08:00
rustmailer
15c0cfc1d9 Update README.md 2026-05-23 15:57:39 +08:00
rustmailer
ea8c493374 Update .gitignore 2026-05-23 15:41:39 +08:00
rustmailer
005b1c2116 feat: added Cron scheduling for email downloads #211 2026-05-23 15:40:46 +08:00
rustmailer
d8b78b8010 update 2026-05-23 12:08:49 +08:00
rustmailer
36f3f19cdc fix(core): use email schema field for attachment hash lookup in cleanup_unused_content 2026-05-23 11:36:47 +08:00
rustmailer
9f3097df32 Merge pull request #259 from mmaudet/feat/search-by-server-timestamp
feat: filter and sort search-messages by a server-side timestamp
2026-05-23 11:04:54 +08:00
rustmailer
c075b9ef12 Merge pull request #258 from mmaudet/fix/self-heal-missing-content
fix: self-heal a missing content blob in download-message
2026-05-23 11:03:04 +08:00
rustmailer
21a7f7e9d5 Merge pull request #257 from mmaudet/fix/gc-blob-still-referenced
fix: prevent the dedup GC from deleting a still-referenced content blob
2026-05-23 10:58:34 +08:00
rustmailer
26c14fcaaf refactor(migrate): use searchable_segment_ids() instead of reader() for merge #261 2026-05-23 10:26:09 +08:00
Michel-Marie MAUDET
6873841ba4 fix(search): expose new SortBy variants via #[oai(rename)]
InternalDate and IngestAt were renamed for the wire with #[serde(rename)] only. SortBy derives poem_openapi::Enum, which does not honour serde attributes, so the REST deserializer exposed them under their Rust identifiers instead of the intended INTERNAL_DATE / INGEST_AT — inconsistent with the existing DATE/SIZE values and rejecting the documented names with HTTP 400. Add #[oai(rename = ...)] alongside the serde rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:14:01 +02:00
rustmailer
0dd81f599d update 2026-05-22 22:21:46 +08:00
Michel-Marie MAUDET
b3afc52a82 feat(search): filter and sort messages by server-side timestamps
POST /api/v1/search-messages previously filtered and sorted only on the
sender-controlled Date: header. Add support for two server-controlled
timestamps that are already indexed as FAST i64 fields:

- internal_date (IMAP INTERNALDATE)
- ingest_at (Bichon's archival time)

EmailSearchFilter gains internal_date_since/before and ingest_since/before
range bounds, mirroring the existing `since`/`before` Date: handling.
SortBy gains InternalDate and IngestAt variants (wire values INTERNAL_DATE
and INGEST_AT), mirroring the existing DATE/SIZE sort handling.

The envelope and attachment Tantivy schemas already declare these fields
as INDEXED | STORED | FAST, so no re-index or migration is required.
Attachments carry no IMAP INTERNALDATE, so the attachment search maps the
InternalDate sort to the attachment's own date field as a defined fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:55:23 +02:00
Michel-Marie MAUDET
171a40d70f fix: self-heal missing content blob in download-message
The download-message REST endpoint returned 404 [30000] "Original email
content not found" whenever an indexed message's raw content blob was
missing from the blob store, leaving the message permanently
unrecoverable even though it still existed on the IMAP server.

Make the endpoint self-healing: when the content blob is absent, fetch
that one message on demand from the IMAP server via
UID FETCH <uid> (BODY.PEEK[]), repopulate the detached blob, and return
the content. The 404 is now only produced when the on-demand fetch
itself fails (mailbox gone, UID gone, connection failure, or the fetched
bytes no longer match the archived content_hash).

- Add ImapExecutor::fetch_single_message_body: examines the mailbox
  read-only and fetches one message by UID, reusing the existing
  BODY.PEEK[] fetch command.
- Add reattach_eml_content_self_healing / recover_message_blob in the
  envelope extractor: fast-path delegates to reattach_eml_content when
  the blob exists; otherwise recovers it via IMAP and re-stores the
  stripped EML + attachments through the existing blob queue.
- Make store::blob::get_reader async and route it through the
  self-healing path; update the single caller (download_message).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:46:40 +02:00
Michel-Marie MAUDET
895ea543a9 fix(core): commit/reload barrier before dedup GC reference count
cleanup_unused_content decides whether to delete a deduplicated blob by
running a Tantivy Count of envelopes referencing each content_hash. The
searcher it used reflected only the committed index state at the time it
was built, so an envelope that shared a content hash but was still
sitting uncommitted in the IndexWriter buffer (for example added by the
background ingest task before the delete operation acquired the writer
lock) was invisible to the count. The count read 0 and a
still-referenced blob was deleted, permanently 404ing that envelope's
download-message.

Pass the locked IndexWriter into cleanup_unused_content and fatal_commit
it immediately before creating the searcher. create_searcher already
reloads the reader, so the Count is now evaluated against a fully
committed, freshly-reloaded index state. The barrier is local to the GC
path and self-contained, independent of what the caller committed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:40:43 +02:00
rustmailer
6eca351994 update i18n 2026-05-21 23:52:26 +08:00
rustmailer
1f477eca65 bump to v1.2.0 2026-05-21 23:33:26 +08:00
rustmailer
a3cdc094e8 feat: Strip remote data from emails when viewed #54 2026-05-21 23:32:42 +08:00
rustmailer
95147a7824 bump to v1.1.3 2026-05-21 18:31:00 +08:00
rustmailer
b22811f78c fix: Transparent menu on iPhone #253 2026-05-21 18:29:03 +08:00
rustmailer
fd61d013a2 fix: Imported emails and UTF-8 folders missing #182 2026-05-21 17:57:43 +08:00
rustmailer
3a950e7591 fix: account name don't change when Update Account #248 2026-05-21 10:38:04 +08:00
rustmailer
f17820bfa8 fix: add missing attachment index cleanup logic 2026-05-21 08:45:30 +08:00
rustmailer
178b25d27d fix:After deleting an email, its attachment remains visible/active in the application #245 2026-05-20 17:40:12 +08:00
rustmailer
d160ca75f5 fix: migration link doesn't exist #244 2026-05-20 17:19:31 +08:00
rustmailer
04136a4ae2 bump to v1.1.2 2026-05-19 23:58:52 +08:00
rustmailer
1d6f5d9a22 Merge pull request #243 from tremor021/smallfix
Fix small typo in store.rs
2026-05-19 23:56:11 +08:00
rustmailer
105a6d9b15 Update dedup.rs 2026-05-19 23:50:46 +08:00
rustmailer
df440c8441 Update README.md 2026-05-19 23:33:44 +08:00
Slaviša Arežina
4116a59b79 Merge branch 'main' into smallfix 2026-05-19 17:28:15 +02:00
rustmailer
609eee1b84 show storage and index usage to everyone 2026-05-19 23:25:36 +08:00
tremor021
79b9f07888 fix small typo in store.rs 2026-05-19 17:18:10 +02:00
rustmailer
ba28369202 update 2026-05-19 13:58:20 +08:00
rustmailer
ff64b66f79 fix: Migration to v1.0 panics with index out of bounds: the len is 0 but the index is 0 #234 2026-05-19 12:12:37 +08:00
rustmailer
a4f8e674c3 Update Cargo.lock 2026-05-18 20:46:31 +08:00
rustmailer
dde6b990da bump to v1.1.0 2026-05-18 20:46:19 +08:00
rustmailer
6b1f843bd5 Merge pull request #237 from rustmailer/fix/cli-mbox-memory
fix: bichon-cli OOMs on import #233
2026-05-18 20:27:03 +08:00
196 changed files with 22668 additions and 2455 deletions

5
.gitignore vendored
View File

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

358
Cargo.lock generated
View File

@@ -60,6 +60,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstream"
version = "1.0.0"
@@ -293,15 +299,14 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bichon-admin"
version = "1.0.2"
version = "1.6.1"
dependencies = [
"bichon-core",
"bichon-memdb",
"console",
"dialoguer",
"indicatif",
"itertools",
"memdb",
"mimalloc",
"itertools 0.15.0",
"native_db",
"native_model",
"serde",
@@ -310,22 +315,35 @@ dependencies = [
"tokio",
]
[[package]]
name = "bichon-blob"
version = "0.1.0"
dependencies = [
"bincode",
"crc32fast",
"criterion",
"lz4_flex",
"rand 0.10.1",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tracing",
"zstd",
]
[[package]]
name = "bichon-cli"
version = "1.0.2"
version = "1.6.1"
dependencies = [
"base64 0.22.1",
"bichon-core",
"chrono",
"clap",
"codepage-strings",
"compressed-rtf",
"console",
"dialoguer",
"hex",
"indicatif",
"mail-parser",
"mail-send",
"memmap2",
"outlook-pst",
"reqwest",
@@ -338,14 +356,18 @@ dependencies = [
[[package]]
name = "bichon-core"
version = "1.0.2"
version = "1.6.1"
dependencies = [
"async-imap",
"base64 0.22.1",
"bichon-memdb",
"blake3",
"bytes 1.11.1",
"chrono",
"clap",
"codepage-strings",
"compressed-rtf",
"cron",
"dashmap",
"deunicode",
"email_address",
@@ -353,17 +375,19 @@ dependencies = [
"fjall",
"futures",
"governor",
"hex",
"hickory-resolver",
"html2text",
"itertools",
"itertools 0.15.0",
"itoa",
"lru 0.18.0",
"mail-parser",
"mail-send",
"memdb",
"memmap2",
"murmur3",
"num_cpus",
"oauth2",
"outlook-pst",
"poem-openapi",
"quick-xml 0.40.0",
"rand 0.10.1",
@@ -394,17 +418,29 @@ dependencies = [
"whichlang",
]
[[package]]
name = "bichon-memdb"
version = "0.1.0"
dependencies = [
"rand 0.9.2",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "bichon-server"
version = "1.0.2"
version = "1.6.1"
dependencies = [
"bichon-core",
"bichon-smtp",
"chrono",
"email_address",
"futures",
"governor",
"http",
"mimalloc",
"poem",
"poem-derive",
"poem-openapi",
@@ -421,7 +457,7 @@ dependencies = [
[[package]]
name = "bichon-smtp"
version = "1.0.2"
version = "1.6.1"
dependencies = [
"base64 0.22.1",
"bichon-core",
@@ -614,6 +650,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.60"
@@ -657,9 +699,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",
@@ -669,6 +711,33 @@ dependencies = [
"windows-link",
]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.6.1"
@@ -869,12 +938,56 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "criterion"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bf7af66b0989381bd0be551bd7cc91912a655a58c6918420c9527b1fd8b4679"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"itertools 0.13.0",
"num-traits",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools 0.10.5",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "cron"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740"
dependencies = [
"chrono",
"once_cell",
"winnow 0.6.26",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@@ -1006,9 +1119,9 @@ dependencies = [
[[package]]
name = "dashmap"
version = "6.1.0"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
@@ -1050,7 +1163,6 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
"serde_core",
]
@@ -1285,9 +1397,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",
@@ -1592,6 +1704,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -1779,9 +1902,9 @@ checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
[[package]]
name = "http"
version = "1.4.0"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes 1.11.1",
"itoa",
@@ -2118,6 +2241,24 @@ dependencies = [
"phf 0.11.3",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2127,6 +2268,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2252,15 +2402,6 @@ version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "libmimalloc-sys"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6"
dependencies = [
"cc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2314,9 +2455,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",
@@ -2337,9 +2478,9 @@ dependencies = [
[[package]]
name = "lz4_flex"
version = "0.13.0"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a"
checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
dependencies = [
"twox-hash",
]
@@ -2374,9 +2515,9 @@ dependencies = [
[[package]]
name = "mail-parser"
version = "0.11.3"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2420e9ce11c2b0583ca97ddff7ab2398c8a613154e9b72e3bafdbf767f1d7"
checksum = "f2c0e7e0704500930be5b6c629f30d23fd1dde4d1800e138e04b3fa302e64d51"
dependencies = [
"encoding_rs",
"hashify",
@@ -2444,18 +2585,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memdb"
version = "0.1.0"
dependencies = [
"rand 0.9.2",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "memmap2"
version = "0.9.10"
@@ -2465,15 +2594,6 @@ dependencies = [
"libc",
]
[[package]]
name = "mimalloc"
version = "0.1.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -2707,9 +2827,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
@@ -2866,6 +2986,12 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "openssl"
version = "0.10.78"
@@ -3098,6 +3224,34 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "poem"
version = "3.1.12"
@@ -3167,7 +3321,7 @@ dependencies = [
"email_address",
"futures-util",
"indexmap",
"itertools",
"itertools 0.14.0",
"mime",
"num-traits",
"poem",
@@ -3559,9 +3713,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.12.3"
version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
@@ -3582,9 +3736,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.10"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
@@ -3740,9 +3894,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.40"
version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [
"aws-lc-rs",
"log",
@@ -3931,9 +4085,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -4123,18 +4277,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",
@@ -4286,9 +4440,9 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.39.1"
version = "0.39.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6"
checksum = "2c8bd2130a9b60bee2581bf82cfe89ee836424d1f37dcfa4ce21509611684673"
dependencies = [
"libc",
"memchr",
@@ -4328,7 +4482,7 @@ dependencies = [
"futures-channel",
"futures-util",
"htmlescape",
"itertools",
"itertools 0.14.0",
"levenshtein_automata",
"log",
"lru 0.16.4",
@@ -4379,7 +4533,7 @@ checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc"
dependencies = [
"downcast-rs",
"fastdivide",
"itertools",
"itertools 0.14.0",
"serde",
"tantivy-bitpacker",
"tantivy-common",
@@ -4431,7 +4585,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a2cfc3ac5164cbadc28965ffb145a8f47582a60ae5897859ad8d4316596c606"
dependencies = [
"futures-util",
"itertools",
"itertools 0.14.0",
"tantivy-bitpacker",
"tantivy-common",
"tantivy-fst",
@@ -4531,12 +4685,11 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.47"
version = "0.3.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
@@ -4548,15 +4701,15 @@ dependencies = [
[[package]]
name = "time-core"
version = "0.1.8"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.27"
version = "0.2.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935"
dependencies = [
"num-conv",
"time-core",
@@ -4582,6 +4735,16 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
@@ -4647,9 +4810,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",
@@ -5047,9 +5210,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.1"
version = "1.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -5269,9 +5432,9 @@ dependencies = [
[[package]]
name = "webpki-roots"
version = "1.0.7"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
@@ -5611,6 +5774,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.6.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.7.15"

View File

@@ -3,32 +3,34 @@
members = [
"crates/memdb",
"crates/core",
"crates/blob",
"crates/server",
"crates/cli",
"crates/admin",
"crates/smtp",
]
resolver = "2"
[workspace.package]
version = "1.0.2"
version = "1.6.1"
edition = "2021"
[workspace.dependencies]
chrono = "0.4.44"
chrono = "0.4.45"
clap = { version = "4.6.1", features = ["derive", "env"] }
mimalloc = "0.1.50"
memdb = { path = "crates/memdb" }
itertools = "0.14.0"
bichon-memdb = { path = "crates/memdb" }
bichon-blob = { path = "crates/blob" }
itertools = "0.15.0"
ring = { version = "0.17.14", features = ["std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
serde_json = "1.0.150"
tokio = { version = "1.52.3", features = ["full"] }
tracing = "0.1.44"
tracing-appender = "0.2.3"
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,13 +39,13 @@ reqwest = { version = "0.12.24", default-features = false, features = [
"blocking",
"socks",
] }
tokio-socks = "0.5.2"
http = "1.4.0"
regex = "1.12.3"
tokio-socks = "0.5.3"
http = "1.4.2"
regex = "1.12.4"
email_address = "0.2.9"
futures = "0.3.32"
utf7-imap = "0.3.2"
mail-parser = { version = '0.11.3', features = ["serde"] }
mail-parser = { version = '0.11.4', features = ["serde"] }
# mail-send = "0.5.2"
tokio-rustls = { version = "0.26.4", default-features = false, features = [
"ring",
@@ -52,12 +54,12 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [
timeago = "0.6.0"
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
url = { version = "2.5.8", features = ["serde"] }
sysinfo = "0.39.1"
sysinfo = "0.39.4"
num_cpus = "1.17.0"
rand = "0.10.1"
encoding_rs = "0.8.35"
webpki-roots = "1.0.7"
rustls = { version = "0.23.40", default-features = false, features = ["ring"] }
webpki-roots = "1.0.8"
rustls = { version = "0.23.41", default-features = false, features = ["ring"] }
rustls-pki-types = "1.14.1"
tokio-io-timeout = "1.2.1"
semver = "1.0.28"
@@ -65,7 +67,7 @@ governor = "0.10.4"
lru = "0.18.0"
mime_guess = "2.0.5"
hex = "0.4.3"
time = { version = "0.3.47", features = [
time = { version = "0.3.51", features = [
"formatting",
"parsing",
"local-offset",
@@ -73,7 +75,7 @@ time = { version = "0.3.47", features = [
rust-embed = "8.11.0"
murmur3 = "0.5.2"
urlencoding = "2.1.3"
dashmap = "6.1.0"
dashmap = "6.2.1"
gethostname = "1.1.0"
itoa = "1.0.18"
html2text = "0.17.1"
@@ -84,8 +86,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.4", 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"

131
README.md
View File

@@ -106,6 +106,10 @@
- **Admin Tooling**: Password reset for locked-out admins. Non-destructive v0.3.7 to v1.0 data migration.
- **API Token Management**: Create, list, and revoke long-lived API tokens for programmatic access.
- **SOCKS5 Proxy Management**: Configure and manage proxy profiles for routing IMAP traffic per account.
- **Scheduled Download**: Configure per-account download schedules using cron expressions. Run syncs at specific times or intervals — for example, nightly-only or business-hours-only archiving.
- **Remote Content Blocking**: External images and tracking pixels embedded in emails are blocked by default. Users can selectively allow remote content to load on a per-message basis from the WebUI.
- **Async Index Deduplication**: Duplicate detection in the search index is performed asynchronously, reducing write latency during high-throughput ingestion.
## Quick Start
@@ -271,6 +275,10 @@ All settings accept both CLI flags (`--bichon-http-port`) and environment variab
> [!TIP]
> Place `BICHON_INDEX_DIR` on fast SSD storage for responsive search, and `BICHON_DATA_DIR` on high-capacity HDD for cost-effective blob storage.
> [!IMPORTANT]
> Bichon does NOT support writing data directly to a network file system (NFS, CIFS/SMB, etc.). All directories — `BICHON_ROOT_DIR`, `BICHON_DATA_DIR`, and `BICHON_INDEX_DIR` — must reside on a **local file system**; otherwise, data corruption may occur.
### Performance Tuning
| Variable | Default | Description |
@@ -441,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).
@@ -477,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
@@ -524,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]
@@ -576,8 +657,8 @@ No. Bichon is an **archiver**, not an email client. The optional SMTP server **r
### What hardware does Bichon need?
- **Minimal:** 1 CPU core, 512 MB RAM
- **Recommended (100+ accounts, 200+ GB):** 4+ cores, 2+ GB RAM
- **Recommended:** 4+ CPU cores, 2+ GB RAM (sufficient for 10+ accounts and 200+ GB of archived data)
- Filesystem: use a mainstream Linux filesystem such as **ext4** or **XFS**; avoid network / virtual filesystems (NFS, VirtIO-FS) for all data directories
- Indices benefit from SSD storage; blob storage can use HDD
### How do I reset the admin password?
@@ -634,6 +715,31 @@ cargo test
Feel free to open an [Issue](https://github.com/rustmailer/bichon/issues) or join the [Discord](https://discord.gg/Bq4M2cDmF4) to discuss ideas.
#### Guidelines
1. **AI-assisted, not AI-authored.** Use AI to help analyze, debug, or draft code when unsure — but understand and review every change yourself before submitting. Don't submit unreviewed AI-generated content.
2. **Keep PRs scoped to one issue.** Don't bundle unrelated changes (CI config, dependency bumps, fixes to other modules) into the same PR. Split them into separate PRs.
3. **Frontend/backend changes go together.** If a change affects an API, data structure, or behavior with a frontend consumer, update the frontend in the same PR (or a clearly linked companion PR).
4. **Unit tests are required.** New or fixed logic must include tests that reproduce the original issue and verify the fix. PRs without tests won't be merged.
5. **Maintain backward compatibility.** Changes to data formats, protocols, configs, or APIs must state whether they're backward compatible. If not, include a migration plan.
6. **State the blast radius.** PR descriptions must specify which modules/APIs/data are affected and any downstream impact.
### Commit Messages
Format: `<type>(<scope>): <subject>`
- **type**: `fix`, `feat`, `refactor`, `ci`, `test`, `docs`, `chore`
- **scope**: affected module/component (e.g. `rustmailer#286`, `dedup_cache`)
- **subject**: imperative, present tense, no period
Rules:
- One logical change per commit — don't mix a fix with CI tweaks or unrelated module changes.
- Reference the issue number when applicable (e.g. `fix(#286): ...`).
- Body explains *why*, not just *what* — include root cause and how it was verified for non-trivial fixes.
- Rebase before submitting — squash WIP/fixup commits into a clean, logical sequence.
- No vague messages like `update`, `fix bug`, `wip`.
## Tech Stack
| Layer | Technology |
@@ -648,7 +754,6 @@ Feel free to open an [Issue](https://github.com/rustmailer/bichon/issues) or joi
| **Frontend** | React 18, TypeScript, Vite 6, ShadCN UI, TanStack Router/Query/Table |
| **Charts** | Recharts |
| **i18n** | i18next (18 languages) |
| **Allocator** | mimalloc |
| **Container** | Ubuntu 24.04, Docker |
## License

View File

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

View File

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

View File

@@ -18,7 +18,6 @@
use console::style;
use dialoguer::{theme::ColorfulTheme, Select};
use mimalloc::MiMalloc;
use crate::{migrate::handle_migration, reset::handle_reset_password};
@@ -26,8 +25,6 @@ pub mod meta;
pub mod migrate;
pub mod reset;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
fn main() {
run_interactive();
@@ -43,7 +40,7 @@ async fn run_interactive() {
let main_options = vec![
"Reset Admin Password",
"Migrate Legacy v0.3.7 Storage to v1.0.x",
"Migrate Legacy v0.3.7 Storage to v1.x",
"Exit",
];

View File

@@ -18,9 +18,9 @@ use bichon_core::{
token::TokenType,
users::{acl::AccessControl, role::RoleType},
};
use bichon_memdb::{Durability, MemDb};
use console::style;
use itertools::Itertools;
use memdb::{Durability, MemDb};
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
@@ -246,11 +246,11 @@ impl From<AccountV3> for AccountModel {
capabilities: value.capabilities,
date_since: value.date_since,
date_before: value.date_before,
folder_limit: value.folder_limit,
download_folders: value.sync_folders,
account_type: value.account_type,
download_interval_min: value.sync_interval_min,
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,
@@ -261,6 +261,10 @@ impl From<AccountV3> for AccountModel {
imap_quota_window: None,
imap_quota_bytes: None,
auto_download_new_mailboxes: None,
download_schedule: None,
deleting: false,
archive_rules: None,
extraction_rules: None,
}
}
}
@@ -572,6 +576,8 @@ impl From<BichonUserV2> for bichon_core::users::BichonUserV2 {
acl: value.acl,
theme: value.theme,
language: value.language,
sso_id: None,
sso_provider: None,
}
}
}
@@ -660,6 +666,7 @@ impl From<MailBox> for bichon_core::cache::imap::mailbox::MailBox {
unseen: value.unseen,
uid_next: value.uid_next,
uid_validity: value.uid_validity,
highest_uid: None,
}
}
}

View File

@@ -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};
@@ -11,7 +11,7 @@ use indicatif::{ProgressBar, ProgressStyle};
pub fn handle_migration(theme: &ColorfulTheme) {
println!(
"\n{}",
style("MIGRATION: Bichon v0.3.7 Storage Architecture → v1.0.x")
style("MIGRATION: Bichon v0.3.7 Storage Architecture → v1.x")
.bold()
.yellow()
);
@@ -20,7 +20,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
"{}",
style(
"This tool migrates data from the legacy v0.3.7 Tantivy-based storage \
architecture to the new v1.0.x \
architecture to the new v1.x \
separated index and Fjall-backed storage format."
)
.dim()
@@ -32,7 +32,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
"Legacy v0.3.7 architecture:\n\
• envelope metadata stored in Tantivy\n\
• message data stored in Tantivy\n\n\
New v1.0.x architecture:\n\
New v1.x architecture:\n\
• mail indexes stored in Tantivy\n\
• attachment indexes stored in Tantivy\n\
• raw message data stored in Fjall\n\
@@ -163,7 +163,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
println!("----------------------------------------");
println!(
"\n{} Checking legacy v0.x storage layout...",
"\n{} Checking legacy v0.3.7 storage layout...",
style("").yellow()
);
@@ -172,7 +172,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
println!(
"{} {}",
style("").green(),
style("Legacy v0.3.7 Tantivy-based storage detected. Migration to v1.0 is required.")
style("Legacy v0.3.7 Tantivy-based storage detected. Migration to v1.x is required.")
.yellow()
);
}
@@ -186,7 +186,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
println!(
"{}",
style(
"The selected directories may already be using the v1.0 storage architecture."
"The selected directories may already be using the v1.x storage architecture."
)
.dim()
);
@@ -288,12 +288,6 @@ pub fn handle_migration(theme: &ColorfulTheme) {
style(batch_size).cyan().bold()
);
println!(
"{} Using batch size: {}\n",
style("").green(),
style(batch_size).cyan().bold()
);
let legacy = LegacyDirs::new(index_path.clone(), data_path.clone());
let total_segments = match count_eml_segments(&legacy) {
Ok(n) => n,
@@ -332,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;
@@ -343,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:") {
@@ -413,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

1105
crates/blob/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
crates/blob/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "bichon-blob"
version = "0.1.0"
edition = "2021"
description = "Embedded KV storage engine for email"
[dependencies]
crc32fast = "1.4"
zstd = "0.13"
lz4_flex = "0.13.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bincode = "1"
tracing = "0.1"
thiserror = "2"
[dev-dependencies]
tempfile = "3"
rand = "0.10.1"
criterion = { version = "0.6", features = ["html_reports"] }
[[bench]]
name = "benchmark"
harness = false

View File

@@ -0,0 +1,316 @@
use criterion::{criterion_group, criterion_main, BatchSize, Criterion, Throughput};
use std::time::Duration;
use tempfile::TempDir;
use bichon_blob::{Codec, Config, Engine};
fn make_key(seed: u64) -> [u8; 32] {
let mut key = [0u8; 32];
key[0..8].copy_from_slice(&seed.to_le_bytes());
key
}
fn make_value(size: usize) -> Vec<u8> {
let mut v = Vec::with_capacity(size);
// Fill with somewhat realistic text-like data so compression works
let pattern = b"The quick brown fox jumps over the lazy dog. ";
while v.len() < size {
let rem = size - v.len();
let n = rem.min(pattern.len());
v.extend_from_slice(&pattern[..n]);
}
v
}
pub fn bench_write_small(c: &mut Criterion) {
let mut group = c.benchmark_group("write");
group.throughput(Throughput::Elements(1));
group.measurement_time(Duration::from_secs(10));
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("bench").unwrap();
let value = make_value(1024); // 1 KB
let mut counter = 0u64;
group.bench_function("1KB", |b| {
b.iter_batched(
|| {
counter += 1;
(make_key(counter), value.clone())
},
|(key, val)| {
engine
.write("bench", key, &val, Codec::Zstd)
.unwrap()
},
BatchSize::SmallInput,
)
});
group.finish();
}
pub fn bench_write_medium(c: &mut Criterion) {
let mut group = c.benchmark_group("write");
group.throughput(Throughput::Bytes(64 * 1024));
group.measurement_time(Duration::from_secs(10));
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("bench").unwrap();
let value = make_value(64 * 1024); // 64 KB
let mut counter = 0u64;
group.bench_function("64KB", |b| {
b.iter_batched(
|| {
counter += 1;
(make_key(counter), value.clone())
},
|(key, val)| {
engine
.write("bench", key, &val, Codec::Zstd)
.unwrap()
},
BatchSize::SmallInput,
)
});
group.finish();
}
pub fn bench_write_large(c: &mut Criterion) {
let mut group = c.benchmark_group("write");
group.throughput(Throughput::Bytes(1024 * 1024));
group.measurement_time(Duration::from_secs(15));
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("bench").unwrap();
let value = make_value(1024 * 1024); // 1 MB
let mut counter = 0u64;
group.bench_function("1MB", |b| {
b.iter_batched(
|| {
counter += 1;
(make_key(counter), value.clone())
},
|(key, val)| {
engine
.write("bench", key, &val, Codec::Zstd)
.unwrap()
},
BatchSize::SmallInput,
)
});
group.finish();
}
pub fn bench_read_cache_hit(c: &mut Criterion) {
let mut group = c.benchmark_group("read");
group.throughput(Throughput::Elements(1));
group.measurement_time(Duration::from_secs(10));
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("bench").unwrap();
// Pre-populate: 10 keys, all in same bucket → cache hit after first read
let value = make_value(4096);
for i in 0..10u64 {
engine
.write("bench", make_key(i), &value, Codec::Zstd)
.unwrap();
}
let mut counter = 0u64;
group.bench_function("cache_hit", |b| {
b.iter(|| {
let key = make_key(counter % 10);
counter += 1;
std::hint::black_box(engine.read("bench", &key).unwrap());
})
});
group.finish();
}
pub fn bench_read_cache_miss(c: &mut Criterion) {
let mut group = c.benchmark_group("read");
group.throughput(Throughput::Elements(1));
group.measurement_time(Duration::from_secs(10));
let dir = TempDir::new().unwrap();
let mut config = Config::default();
config.lru_bucket_count = 8; // Small cache to force misses
let engine = Engine::open(dir.path(), config).unwrap();
engine.create_account("bench").unwrap();
let value = make_value(4096);
// Write 1000 keys spread across all 16 buckets — small LRU will thrash
for i in 0..1000u64 {
engine
.write("bench", make_key(i), &value, Codec::Zstd)
.unwrap();
}
let mut counter = 0u64;
group.bench_function("cache_miss", |b| {
b.iter(|| {
let key = make_key(counter % 1000);
counter += 1;
std::hint::black_box(engine.read("bench", &key).unwrap());
})
});
group.finish();
}
pub fn bench_read_large_value(c: &mut Criterion) {
let mut group = c.benchmark_group("read");
group.throughput(Throughput::Bytes(1024 * 1024));
group.measurement_time(Duration::from_secs(10));
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("bench").unwrap();
let value = make_value(1024 * 1024); // 1 MB
for i in 0..5u64 {
engine
.write("bench", make_key(i), &value, Codec::Zstd)
.unwrap();
}
let mut counter = 0u64;
group.bench_function("1MB_cache_hit", |b| {
b.iter(|| {
let key = make_key(counter % 5);
counter += 1;
std::hint::black_box(engine.read("bench", &key).unwrap());
})
});
group.finish();
}
pub fn bench_delete(c: &mut Criterion) {
let mut group = c.benchmark_group("delete");
group.throughput(Throughput::Elements(1));
group.measurement_time(Duration::from_secs(10));
group.bench_function("delete", |b| {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("bench").unwrap();
let value = make_value(4096);
let mut counter = 0u64;
b.iter_batched(
|| {
counter += 1;
let key = make_key(counter);
engine
.write("bench", key, &value, Codec::Zstd)
.unwrap();
key
},
|key| {
engine.delete("bench", &key).unwrap();
},
BatchSize::SmallInput,
)
});
group.finish();
}
pub fn bench_mixed_workload(c: &mut Criterion) {
let mut group = c.benchmark_group("mixed");
group.throughput(Throughput::Elements(1));
group.measurement_time(Duration::from_secs(15));
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("bench").unwrap();
// Pre-populate with 500 entries
let value = make_value(8192);
for i in 0..500u64 {
engine
.write("bench", make_key(i), &value, Codec::Zstd)
.unwrap();
}
let mut counter: u64 = 500;
group.bench_function("80w_15r_5d", |b| {
b.iter(|| {
counter += 1;
let op = counter % 100;
match op {
0..=79 => {
// 80% writes
let key = make_key(counter);
let val = make_value(4096);
engine.write("bench", key, &val, Codec::Zstd).unwrap();
}
80..=94 => {
// 15% reads
std::hint::black_box(engine.read("bench", &make_key(counter % 500)).unwrap());
}
_ => {
// 5% deletes
if counter % 2 == 0 {
let key = make_key(counter % 500);
let _ = engine.delete("bench", &key);
}
}
}
})
});
group.finish();
}
pub fn bench_gc(c: &mut Criterion) {
let mut group = c.benchmark_group("gc");
group.measurement_time(Duration::from_secs(30));
group.sample_size(10);
group.bench_function("gc_30pct_deleted", |b| {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("bench").unwrap();
// Fill a segment with ~1000 entries, then delete 30%
let value = make_value(200_000); // 200KB each → ~1000 entries to fill 256MB
let n = 1200u64;
for i in 0..n {
engine
.write("bench", make_key(i), &value, Codec::None)
.unwrap();
}
// Delete ~30%
for i in (0..n).step_by(3) {
engine.delete("bench", &make_key(i)).unwrap();
}
b.iter(|| {
engine.gc("bench").unwrap();
})
});
group.finish();
}
criterion_group!(
benches,
bench_write_small,
bench_write_medium,
bench_write_large,
bench_read_cache_hit,
bench_read_cache_miss,
bench_read_large_value,
bench_delete,
bench_mixed_workload,
bench_gc,
);
criterion_main!(benches);

284
crates/blob/src/account.rs Normal file
View File

@@ -0,0 +1,284 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use crate::bucket::{self, BucketFile, IndexRecord};
use crate::error::{Error, Result};
use crate::file_pool::FilePool;
use crate::meta::{AccountMeta, SegmentStats};
use crate::segment::{self, SegmentReader, SegmentWriter};
use crate::types::Codec;
// ── AccountHandle ──────────────────────────────────────────────────────────
pub struct AccountHandle {
id: String,
dir: PathBuf,
inner: RwLock<AccountInner>,
pub(crate) write_mutex: Mutex<()>,
file_pool: FilePool,
}
impl AccountHandle {
pub fn id(&self) -> &str {
&self.id
}
pub fn dir(&self) -> &Path {
&self.dir
}
/// Open an existing account.
pub fn open(store_root: &Path, account_id: &str) -> Result<Arc<Self>> {
let dir = store_root.join("accounts").join(account_id);
if !dir.exists() {
return Err(Error::AccountNotFound(account_id.to_string()));
}
let inner = AccountInner::open(&dir)?;
Ok(Arc::new(Self {
id: account_id.to_string(),
dir,
inner: RwLock::new(inner),
write_mutex: Mutex::new(()),
file_pool: FilePool::new(8),
}))
}
/// Create a new account.
pub fn create(store_root: &Path, account_id: &str) -> Result<Arc<Self>> {
let dir = store_root.join("accounts").join(account_id);
if dir.exists() {
return Err(Error::AccountAlreadyExists(account_id.to_string()));
}
let inner = AccountInner::create(&dir, account_id)?;
Ok(Arc::new(Self {
id: account_id.to_string(),
dir,
inner: RwLock::new(inner),
write_mutex: Mutex::new(()),
file_pool: FilePool::new(8),
}))
}
/// Lock the inner state for reading.
pub fn read(&self) -> std::sync::RwLockReadGuard<'_, AccountInner> {
self.inner.read().unwrap()
}
/// Lock the inner state for writing.
pub fn write(&self) -> std::sync::RwLockWriteGuard<'_, AccountInner> {
self.inner.write().unwrap()
}
/// Get a cached file handle for a segment.
pub fn get_segment_file(&self, seg_id: u32, path: &Path) -> Result<Arc<Mutex<std::fs::File>>> {
self.file_pool.get(seg_id, path)
}
/// Invalidate cached file handles for a segment (after GC).
pub fn invalidate_file_cache(&self, seg_id: u32) {
self.file_pool.invalidate(seg_id);
}
}
// ── AccountInner ───────────────────────────────────────────────────────────
pub struct AccountInner {
dir: PathBuf,
meta: AccountMeta,
active_writer: SegmentWriter,
readers: HashMap<u32, SegmentReader>,
}
impl AccountInner {
fn open(dir: &Path) -> Result<Self> {
let meta = AccountMeta::load(dir)?;
let seg_path = dir
.join("segments")
.join(segment::segment_filename(meta.active_segment_id));
let active_writer = if seg_path.exists() {
SegmentWriter::open_append(seg_path, meta.active_segment_id)?
} else {
fs::create_dir_all(dir.join("segments"))?;
SegmentWriter::create(seg_path, meta.active_segment_id)?
};
let mut readers = HashMap::new();
for (&seg_id, stats) in &meta.segments {
if stats.sealed {
let seg_path = dir
.join("segments")
.join(segment::segment_filename(seg_id));
if seg_path.exists() {
readers.insert(seg_id, SegmentReader::open(seg_path, seg_id)?);
}
}
}
Ok(Self {
dir: dir.to_path_buf(),
meta,
active_writer,
readers,
})
}
fn create(dir: &Path, account_id: &str) -> Result<Self> {
fs::create_dir_all(dir.join("segments"))?;
BucketFile::ensure_dir(dir)?;
let meta = AccountMeta::new(account_id.to_string(), 1);
let seg_path = dir
.join("segments")
.join(segment::segment_filename(1));
let active_writer = SegmentWriter::create(seg_path, 1)?;
meta.save(dir)?;
Ok(Self {
dir: dir.to_path_buf(),
meta,
active_writer,
readers: HashMap::new(),
})
}
pub fn meta(&self) -> &AccountMeta {
&self.meta
}
/// Mark the segment as indexed up to the given offset and persist meta.
pub fn mark_indexed(&mut self, segment_id: u32, indexed_up_to_offset: u64) -> Result<()> {
if let Some(stats) = self.meta.segments.get_mut(&segment_id) {
if indexed_up_to_offset > stats.indexed_up_to_offset {
stats.indexed_up_to_offset = indexed_up_to_offset;
}
}
self.meta.save(&self.dir)
}
/// Append an entry without fsync.
pub fn append_entry(
&mut self,
key: [u8; 32],
data: &[u8],
flags: u8,
codec: Codec,
) -> Result<(u32, u64, u32)> {
if self.active_writer.is_full() {
self.seal_active()?;
}
use crate::segment::Entry;
let entry = if flags == 1 {
Entry::tombstone(key)
} else {
Entry::new(key, data, flags, codec)
};
let data_size = entry.data.len() as u32;
let segment_id = self.active_writer.id();
let offset = self.active_writer.append(&entry)?;
let stats = self
.meta
.segments
.entry(segment_id)
.or_insert_with(|| SegmentStats::new(segment_id));
stats.total_bytes += data_size as u64;
if flags == 1 {
stats.deleted_bytes += entry.raw_size as u64;
}
stats.recompute_ratio();
Ok((segment_id, offset, data_size))
}
/// Fsync the active segment and persist meta.
pub fn flush_active(&mut self) -> Result<()> {
self.active_writer.fsync()?;
self.meta.save(&self.dir)
}
/// Write an entry with fsync.
pub fn write_entry(
&mut self,
key: [u8; 32],
data: &[u8],
flags: u8,
codec: Codec,
) -> Result<(u32, u64, u32)> {
let result = self.append_entry(key, data, flags, codec)?;
self.flush_active()?;
Ok(result)
}
fn seal_active(&mut self) -> Result<()> {
let old_id = self.active_writer.id();
let old_stats = self
.meta
.segments
.entry(old_id)
.or_insert_with(|| SegmentStats::new(old_id));
old_stats.sealed = true;
let seg_path = self
.dir
.join("segments")
.join(segment::segment_filename(old_id));
self.readers
.insert(old_id, SegmentReader::open(seg_path, old_id)?);
let new_id = old_id + 1;
self.meta.active_segment_id = new_id;
let new_path = self
.dir
.join("segments")
.join(segment::segment_filename(new_id));
self.active_writer = SegmentWriter::create(new_path, new_id)?;
self.meta.save(&self.dir)?;
Ok(())
}
/// Get the on-disk path for a segment.
pub fn segment_path(&self, segment_id: u32) -> Result<PathBuf> {
let filename = segment::segment_filename(segment_id);
let path = self.dir.join("segments").join(&filename);
if path.exists() {
Ok(path)
} else {
Err(Error::SegmentNotFound(segment_id))
}
}
/// Append index record to the appropriate bucket file.
pub fn append_index(&self, record: &IndexRecord) -> Result<()> {
let bucket_id = bucket::bucket_id(&record.key);
let bf = BucketFile::open(&self.dir, bucket_id);
bf.append(record)
}
/// Return list of sealed segment IDs.
pub fn sealed_segments(&self) -> Vec<u32> {
self.meta
.segments
.iter()
.filter(|(_, s)| s.sealed)
.map(|(id, _)| *id)
.collect()
}
/// All segment IDs (including active).
pub fn all_segment_ids(&self) -> Vec<u32> {
let mut ids: Vec<u32> = self.meta.segments.keys().copied().collect();
if !ids.contains(&self.meta.active_segment_id) {
ids.push(self.meta.active_segment_id);
}
ids.sort_unstable();
ids
}
}

329
crates/blob/src/bucket.rs Normal file
View File

@@ -0,0 +1,329 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::types::{BUCKET_COUNT, INDEX_RECORD_SIZE};
/// On-disk format: 52 bytes per record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexRecord {
pub key: [u8; 32],
pub segment_id: u32,
pub offset: u64,
pub data_size: u32,
pub flags: u8,
}
impl IndexRecord {
pub fn new(key: [u8; 32], segment_id: u32, offset: u64, data_size: u32, flags: u8) -> Self {
Self {
key,
segment_id,
offset,
data_size,
flags,
}
}
pub fn is_tombstone(&self) -> bool {
self.flags == 1
}
pub fn encode(&self) -> [u8; INDEX_RECORD_SIZE] {
let mut buf = [0u8; INDEX_RECORD_SIZE];
buf[0..32].copy_from_slice(&self.key);
buf[32..36].copy_from_slice(&self.segment_id.to_le_bytes());
buf[36..44].copy_from_slice(&self.offset.to_le_bytes());
buf[44..48].copy_from_slice(&self.data_size.to_le_bytes());
buf[48] = self.flags;
// bytes 49..52 are padding (keep zero)
buf
}
pub fn decode(buf: &[u8; INDEX_RECORD_SIZE]) -> Self {
let mut key = [0u8; 32];
key.copy_from_slice(&buf[0..32]);
let segment_id = u32::from_le_bytes(buf[32..36].try_into().unwrap());
let offset = u64::from_le_bytes(buf[36..44].try_into().unwrap());
let data_size = u32::from_le_bytes(buf[44..48].try_into().unwrap());
let flags = buf[48];
Self {
key,
segment_id,
offset,
data_size,
flags,
}
}
}
/// Represents a loaded and deduplicated bucket in memory.
pub struct BucketIndex {
pub bucket_id: u16,
/// Records sorted by key, deduplicated (one record per key, latest wins).
pub records: Vec<IndexRecord>,
}
impl BucketIndex {
/// Build from raw records: sort by key, dedup keeping the one with max offset.
pub fn from_records(mut records: Vec<IndexRecord>, bucket_id: u16) -> Self {
records.sort_by_key(|a| a.key);
// Dedup: keep last (max offset) for each key
let mut deduped = Vec::with_capacity(records.len());
let mut i = 0;
while i < records.len() {
let mut best = i;
let mut j = i + 1;
while j < records.len() && records[j].key == records[i].key {
if records[j].offset > records[best].offset {
best = j;
}
j += 1;
}
deduped.push(records[best].clone());
i = j;
}
Self {
bucket_id,
records: deduped,
}
}
/// Binary search for a key. Returns the record if found.
pub fn find(&self, key: &[u8; 32]) -> Option<&IndexRecord> {
match self.records.binary_search_by(|r| r.key.cmp(key)) {
Ok(idx) => Some(&self.records[idx]),
Err(_) => None,
}
}
/// Append a new record and maintain sorted order.
pub fn insert(&mut self, record: IndexRecord) {
match self.records.binary_search_by(|r| r.key.cmp(&record.key)) {
Ok(idx) => {
// Replace if newer (larger offset)
if record.offset > self.records[idx].offset {
self.records[idx] = record;
}
}
Err(idx) => {
self.records.insert(idx, record);
}
}
}
pub fn len(&self) -> usize {
self.records.len()
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
}
/// Manages a bucket index file on disk.
pub struct BucketFile {
path: PathBuf,
bucket_id: u16,
}
impl BucketFile {
pub fn path_for(account_dir: &Path, bucket_id: u16) -> PathBuf {
account_dir.join("buckets").join(format!("{:02x}.idx", bucket_id))
}
pub fn open(account_dir: &Path, bucket_id: u16) -> Self {
Self {
path: Self::path_for(account_dir, bucket_id),
bucket_id,
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn bucket_id(&self) -> u16 {
self.bucket_id
}
/// Ensure the buckets directory exists.
pub fn ensure_dir(account_dir: &Path) -> Result<()> {
let dir = account_dir.join("buckets");
std::fs::create_dir_all(&dir)?;
Ok(())
}
/// Append a single record to the bucket file.
pub fn append(&self, record: &IndexRecord) -> Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
file.write_all(&record.encode())?;
Ok(())
}
/// Append multiple records at once.
pub fn append_batch(&self, records: &[IndexRecord]) -> Result<()> {
if records.is_empty() {
return Ok(());
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
for r in records {
file.write_all(&r.encode())?;
}
Ok(())
}
/// Load all records from the bucket file.
/// If the file size is not a multiple of INDEX_RECORD_SIZE (partial write),
/// the trailing bytes are silently ignored.
pub fn load_all(&self) -> Result<Vec<IndexRecord>> {
if !self.path.exists() {
return Ok(Vec::new());
}
let data = std::fs::read(&self.path)?;
let remainder = data.len() % INDEX_RECORD_SIZE;
let count = data.len() / INDEX_RECORD_SIZE;
let mut records = Vec::with_capacity(count);
for i in 0..count {
let start = i * INDEX_RECORD_SIZE;
let end = start + INDEX_RECORD_SIZE;
let buf: &[u8; INDEX_RECORD_SIZE] = data[start..end]
.try_into()
.map_err(|_| crate::error::Error::BucketIndexCorrupt {
path: self.path.clone(),
reason: format!("unexpected file size {}, not a multiple of {}", data.len(), INDEX_RECORD_SIZE),
})?;
records.push(IndexRecord::decode(buf));
}
if remainder > 0 {
tracing::warn!(
"Bucket file {:?} has {} trailing bytes (expected multiple of {}), ignoring",
self.path, remainder, INDEX_RECORD_SIZE
);
}
Ok(records)
}
/// Load all records, sort, and deduplicate into a BucketIndex.
pub fn load_index(&self) -> Result<BucketIndex> {
let records = self.load_all()?;
Ok(BucketIndex::from_records(records, self.bucket_id))
}
/// Rewrite the bucket file with a sorted, deduplicated set of records.
/// Uses atomic temp+rename to be safe on NFS.
pub fn rewrite(&self, records: &[IndexRecord]) -> Result<()> {
let mut buf = Vec::with_capacity(records.len() * INDEX_RECORD_SIZE);
for r in records {
buf.extend_from_slice(&r.encode());
}
crate::fs::create_atomic(&self.path, &buf)
}
/// Delete the bucket file.
pub fn delete(&self) -> Result<()> {
if self.path.exists() {
std::fs::remove_file(&self.path)?;
}
Ok(())
}
}
/// Compute bucket_id from a key's first 2 bytes.
pub fn bucket_id(key: &[u8; 32]) -> u16 {
u16::from_be_bytes([key[0], key[1]]) % BUCKET_COUNT
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_index_record_encode_decode() {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&[1, 2, 3, 4]);
let rec = IndexRecord::new(key, 5, 12345, 500, 0);
let encoded = rec.encode();
let decoded = IndexRecord::decode(&encoded);
assert_eq!(rec, decoded);
}
#[test]
fn test_bucket_id_deterministic() {
let mut key = [0u8; 32];
key[0] = 0x00;
key[1] = 0x0F;
assert_eq!(bucket_id(&key), 15);
key[0] = 0x00;
key[1] = 0x10;
assert_eq!(bucket_id(&key), 0);
}
#[test]
fn test_bucket_append_and_load() {
let dir = TempDir::new().unwrap();
let bucket = BucketFile::open(dir.path(), 0);
BucketFile::ensure_dir(dir.path()).unwrap();
let r1 = IndexRecord::new([1u8; 32], 1, 100, 50, 0);
let r2 = IndexRecord::new([2u8; 32], 1, 200, 60, 0);
bucket.append(&r1).unwrap();
bucket.append(&r2).unwrap();
let loaded = bucket.load_all().unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].key, [1u8; 32]);
assert_eq!(loaded[1].key, [2u8; 32]);
}
#[test]
fn test_bucket_index_dedup() {
let recs = vec![
IndexRecord::new([1u8; 32], 1, 100, 50, 0),
IndexRecord::new([1u8; 32], 2, 200, 50, 0), // newer offset wins
IndexRecord::new([2u8; 32], 1, 300, 60, 0),
];
let idx = BucketIndex::from_records(recs, 0);
assert_eq!(idx.len(), 2);
let found = idx.find(&[1u8; 32]).unwrap();
assert_eq!(found.segment_id, 2);
assert_eq!(found.offset, 200);
}
#[test]
fn test_bucket_index_find_missing() {
let recs = vec![IndexRecord::new([1u8; 32], 1, 100, 50, 0)];
let idx = BucketIndex::from_records(recs, 0);
assert!(idx.find(&[99u8; 32]).is_none());
}
#[test]
fn test_bucket_rewrite() {
let dir = TempDir::new().unwrap();
let bucket = BucketFile::open(dir.path(), 0);
BucketFile::ensure_dir(dir.path()).unwrap();
let r1 = IndexRecord::new([3u8; 32], 1, 300, 70, 0);
let r2 = IndexRecord::new([1u8; 32], 1, 100, 50, 0);
bucket.append(&r1).unwrap();
bucket.append(&r2).unwrap();
// Rewrite sorted
let sorted = vec![r2.clone(), r1.clone()];
bucket.rewrite(&sorted).unwrap();
let loaded = bucket.load_all().unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].key, [1u8; 32]);
assert_eq!(loaded[1].key, [3u8; 32]);
}
}

219
crates/blob/src/cache.rs Normal file
View File

@@ -0,0 +1,219 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use crate::bucket::{BucketFile, BucketIndex, IndexRecord};
use crate::error::Result;
type CacheKey = (String, u16);
/// Thread-safe LRU bucket cache with single-lock interior.
/// Eliminates the TOCTOU race in the old two-Mutex design.
pub struct BucketCache {
inner: Mutex<CacheInner>,
}
struct CacheInner {
max_entries: usize,
entries: Vec<CacheEntry>,
index: HashMap<CacheKey, usize>,
}
struct CacheEntry {
key: CacheKey,
index: BucketIndex,
}
impl BucketCache {
pub fn new(max_entries: usize) -> Self {
Self {
inner: Mutex::new(CacheInner {
max_entries: max_entries.max(1),
entries: Vec::new(),
index: HashMap::new(),
}),
}
}
/// Get or load a bucket index. Eliminates TOCTOU via double-checked locking.
pub fn get_or_load(
&self,
account: &str,
bucket_id: u16,
account_dir: &Path,
) -> Result<Vec<IndexRecord>> {
let key: CacheKey = (account.to_string(), bucket_id);
// Check cache
{
let inner = self.inner.lock().unwrap();
if let Some(&pos) = inner.index.get(&key) {
return Ok(inner.entries[pos].index.records.clone());
}
}
// Load from disk
let bucket_file = BucketFile::open(account_dir, bucket_id);
let bucket_index = bucket_file.load_index()?;
let records = bucket_index.records.clone();
// Insert with double-check (another thread might have beaten us)
{
let mut inner = self.inner.lock().unwrap();
if let Some(&pos) = inner.index.get(&key) {
return Ok(inner.entries[pos].index.records.clone());
}
// Evict if full
if inner.entries.len() >= inner.max_entries {
if let Some(evicted) = inner.entries.pop() {
inner.index.remove(&evicted.key);
}
}
// Insert at front
inner.entries.insert(0, CacheEntry {
key: key.clone(),
index: bucket_index,
});
// Rebuild index
inner.index.clear();
for i in 0..inner.entries.len() {
let key = inner.entries[i].key.clone();
inner.index.insert(key, i);
}
}
Ok(records)
}
/// Insert or update a single record in a cached bucket.
pub fn update_record(&self, account: &str, bucket_id: u16, record: IndexRecord) {
let key: CacheKey = (account.to_string(), bucket_id);
let mut inner = self.inner.lock().unwrap();
if let Some(&pos) = inner.index.get(&key) {
inner.entries[pos].index.insert(record);
// Move to front
let entry = inner.entries.remove(pos);
inner.entries.insert(0, entry);
// Rebuild index
inner.index.clear();
for i in 0..inner.entries.len() {
let entry_key = inner.entries[i].key.clone();
inner.index.insert(entry_key, i);
}
}
}
/// Invalidate a cached bucket (after GC rewrites bucket files).
pub fn invalidate(&self, account: &str, bucket_id: u16) {
let key: CacheKey = (account.to_string(), bucket_id);
let mut inner = self.inner.lock().unwrap();
if let Some(&pos) = inner.index.get(&key) {
inner.entries.remove(pos);
inner.index.clear();
for i in 0..inner.entries.len() {
let entry_key = inner.entries[i].key.clone();
inner.index.insert(entry_key, i);
}
}
}
pub fn len(&self) -> usize {
self.inner.lock().unwrap().entries.len()
}
pub fn is_empty(&self) -> bool {
self.inner.lock().unwrap().entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bucket::IndexRecord;
use tempfile::TempDir;
#[test]
fn test_cache_miss_loads_from_disk() {
let dir = TempDir::new().unwrap();
crate::bucket::BucketFile::ensure_dir(dir.path()).unwrap();
let bf = BucketFile::open(dir.path(), 0);
bf.append(&IndexRecord::new([1u8; 32], 1, 100, 50, 0))
.unwrap();
let cache = BucketCache::new(10);
let records = cache
.get_or_load("test", 0, dir.path())
.unwrap();
assert_eq!(records.len(), 1);
}
#[test]
fn test_cache_hit() {
let dir = TempDir::new().unwrap();
crate::bucket::BucketFile::ensure_dir(dir.path()).unwrap();
let bf = BucketFile::open(dir.path(), 0);
bf.append(&IndexRecord::new([2u8; 32], 1, 200, 60, 0))
.unwrap();
let cache = BucketCache::new(10);
let _ = cache.get_or_load("test", 0, dir.path()).unwrap();
let records = cache
.get_or_load("test", 0, dir.path())
.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_cache_eviction() {
let dir = TempDir::new().unwrap();
crate::bucket::BucketFile::ensure_dir(dir.path()).unwrap();
let cache = BucketCache::new(2);
for b in 0..4 {
let bf = BucketFile::open(dir.path(), b);
bf.append(&IndexRecord::new([b as u8; 32], 1, 100, 50, 0))
.unwrap();
let _ = cache.get_or_load("test", b, dir.path()).unwrap();
}
assert!(cache.len() <= 2);
}
#[test]
fn test_concurrent_get_or_load_no_deadlock() {
use std::sync::Arc;
use std::thread;
let dir = TempDir::new().unwrap();
crate::bucket::BucketFile::ensure_dir(dir.path()).unwrap();
let bf = BucketFile::open(dir.path(), 0);
for i in 0..10u8 {
bf.append(&IndexRecord::new([i; 32], 1, i as u64 * 100, 50, 0))
.unwrap();
}
let cache = Arc::new(BucketCache::new(10));
let dir_path = dir.path().to_path_buf();
let mut handles = vec![];
for _ in 0..4 {
let cache = cache.clone();
let dir_path = dir_path.clone();
handles.push(thread::spawn(move || {
for _ in 0..100 {
let records = cache
.get_or_load("test", 0, &dir_path)
.unwrap();
assert_eq!(records.len(), 10);
}
}));
}
for h in handles {
h.join().unwrap();
}
}
}

View File

@@ -0,0 +1,65 @@
use crc32fast::Hasher;
pub fn crc32(data: &[u8]) -> u32 {
let mut h = Hasher::new();
h.update(data);
h.finalize()
}
pub struct CrcWriter {
hasher: Hasher,
}
impl Default for CrcWriter {
fn default() -> Self {
Self::new()
}
}
impl CrcWriter {
pub fn new() -> Self {
Self {
hasher: Hasher::new(),
}
}
pub fn update(&mut self, data: &[u8]) {
self.hasher.update(data);
}
pub fn finalize(self) -> u32 {
self.hasher.finalize()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_crc32_deterministic() {
let a = crc32(b"hello");
let b = crc32(b"hello");
assert_eq!(a, b);
}
#[test]
fn test_crc32_different() {
let a = crc32(b"hello");
let b = crc32(b"world");
assert!(a != b);
}
#[test]
fn test_crc_writer_matches_crc32() {
let mut w = CrcWriter::new();
w.update(b"hello");
w.update(b" world");
assert_eq!(w.finalize(), crc32(b"hello world"));
}
#[test]
fn test_crc32_empty() {
assert_eq!(crc32(b""), 0);
}
}

View File

@@ -0,0 +1,97 @@
use crate::types::Codec;
pub fn compress(data: &[u8], codec: Codec, threshold: usize, level: i32) -> (Vec<u8>, Codec) {
if data.len() < threshold {
return (data.to_vec(), Codec::None);
}
let (compressed, actual_codec) = match codec {
Codec::Zstd => {
match zstd::encode_all(data, level) {
Ok(out) => (out, Codec::Zstd),
Err(e) => {
tracing::warn!("zstd compression failed, storing uncompressed: {}", e);
(data.to_vec(), Codec::None)
}
}
}
Codec::Lz4 => {
let out = lz4_flex::compress(data);
(out, Codec::Lz4)
}
Codec::None => (data.to_vec(), Codec::None),
};
// If compression made it larger, store uncompressed
if compressed.len() >= data.len() {
(data.to_vec(), Codec::None)
} else {
(compressed, actual_codec)
}
}
pub fn decompress(data: &[u8], codec: Codec, raw_size: usize) -> crate::error::Result<Vec<u8>> {
match codec {
Codec::None => Ok(data.to_vec()),
Codec::Zstd => {
zstd::decode_all(data)
.map_err(|e| crate::error::Error::Compression(format!("zstd decompress: {}", e)))
}
Codec::Lz4 => {
lz4_flex::decompress(data, raw_size)
.map_err(|e| crate::error::Error::Compression(format!("lz4 decompress: {}", e)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_small_data_not_compressed() {
let data = b"hi";
let (out, codec) = compress(data, Codec::Zstd, 4096, 0);
assert_eq!(out, b"hi");
assert_eq!(codec, Codec::None);
}
#[test]
fn test_large_data_compressed_zstd() {
let data = vec![b'A'; 5000];
let (out, codec) = compress(&data, Codec::Zstd, 4096, 0);
assert_eq!(codec, Codec::Zstd);
assert!(out.len() < data.len());
}
#[test]
fn test_roundtrip_zstd() {
let data = vec![b'B'; 10000];
let (compressed, codec) = compress(&data, Codec::Zstd, 4096, 0);
let decompressed = decompress(&compressed, codec, data.len()).unwrap();
assert_eq!(decompressed, data);
}
#[test]
fn test_roundtrip_lz4() {
let data = vec![b'C'; 10000];
let (compressed, codec) = compress(&data, Codec::Lz4, 4096, 0);
let decompressed = decompress(&compressed, codec, data.len()).unwrap();
assert_eq!(decompressed, data);
}
#[test]
fn test_roundtrip_none() {
let data = vec![b'D'; 100];
let (compressed, codec) = compress(&data, Codec::None, 4096, 0);
assert_eq!(codec, Codec::None);
let decompressed = decompress(&compressed, codec, data.len()).unwrap();
assert_eq!(decompressed, data);
}
#[test]
fn test_threshold_zero_always_compresses() {
let data = vec![b'E'; 100];
let (out, codec) = compress(&data, Codec::Zstd, 0, 0);
assert_eq!(codec, Codec::Zstd);
assert!(out.len() < data.len());
}
}

398
crates/blob/src/engine.rs Normal file
View File

@@ -0,0 +1,398 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use crate::account::AccountHandle;
use crate::bucket::{self, IndexRecord};
use crate::cache::BucketCache;
use crate::compress;
use crate::error::{Error, Result};
use crate::gc::{self, GcStats};
use crate::meta::GlobalMeta;
use crate::segment::SegmentReader;
use crate::types::{Codec, Config, ENTRY_HEADER_SIZE};
pub struct Engine {
root: PathBuf,
config: Config,
cache: BucketCache,
accounts: RwLock<HashMap<String, Arc<AccountHandle>>>,
}
#[derive(Debug, Clone)]
pub struct AccountStats {
pub account_id: String,
pub total_keys: u64,
pub total_bytes: u64,
pub deleted_bytes: u64,
pub segment_count: usize,
}
impl Engine {
pub fn open(path: &Path, config: Config) -> Result<Self> {
config.validate()?;
fs::create_dir_all(path)?;
fs::create_dir_all(path.join("accounts"))?;
let mut global = GlobalMeta::load(path)?;
global.save(path)?;
let cache = BucketCache::new(config.lru_bucket_count);
let accounts_dir = path.join("accounts");
let mut accounts = HashMap::new();
if accounts_dir.exists() {
for entry in fs::read_dir(&accounts_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
let account_name = entry.file_name().to_string_lossy().into_owned();
let _ = crate::recovery::cleanup_temp_files(&entry.path());
match crate::recovery::recover_account(&entry.path()) {
Ok(_meta) => {
match AccountHandle::open(path, &account_name) {
Ok(handle) => {
accounts.insert(account_name, handle);
}
Err(e) => {
tracing::warn!(
"Failed to open account {}: {}",
account_name,
e
);
}
}
}
Err(e) => {
tracing::warn!(
"Failed to recover account {}: {}",
account_name,
e
);
}
}
}
}
}
global.accounts = accounts.keys().cloned().collect();
global.save(path)?;
Ok(Self {
root: path.to_path_buf(),
config,
cache,
accounts: RwLock::new(accounts),
})
}
// ── Account management ──────────────────────────────────────────────
pub fn create_account(&self, account_id: &str) -> Result<()> {
let mut accounts = self.accounts.write().unwrap();
if accounts.contains_key(account_id) {
return Err(Error::AccountAlreadyExists(account_id.to_string()));
}
let handle = AccountHandle::create(&self.root, account_id)?;
accounts.insert(account_id.to_string(), handle);
let mut global = GlobalMeta::load(&self.root)?;
global.accounts = accounts.keys().cloned().collect();
global.save(&self.root)?;
Ok(())
}
pub fn delete_account(&self, account_id: &str) -> Result<()> {
let mut accounts = self.accounts.write().unwrap();
let handle = accounts
.remove(account_id)
.ok_or_else(|| Error::AccountNotFound(account_id.to_string()))?;
let account_dir = handle.dir().to_path_buf();
drop(handle);
fs::remove_dir_all(&account_dir)?;
let mut global = GlobalMeta::load(&self.root)?;
global.accounts = accounts.keys().cloned().collect();
global.save(&self.root)?;
Ok(())
}
pub fn list_accounts(&self) -> Vec<String> {
let accounts = self.accounts.read().unwrap();
accounts.keys().cloned().collect()
}
// ── Read / Write / Delete ───────────────────────────────────────────
pub fn write(
&self,
account_id: &str,
key: [u8; 32],
value: &[u8],
codec: Codec,
) -> Result<()> {
if value.len() > crate::types::MAX_VALUE_SIZE {
return Err(Error::ValueTooLarge { size: value.len() });
}
let handle = {
let accounts = self.accounts.read().unwrap();
accounts
.get(account_id)
.ok_or_else(|| Error::AccountNotFound(account_id.to_string()))?
.clone()
};
let _write_lock = handle.write_mutex.lock().unwrap();
let mut inner = handle.write();
let (data, actual_codec) =
compress::compress(value, codec, self.config.compress_threshold, self.config.compression_level);
let (segment_id, offset, data_size) =
inner.write_entry(key, &data, 0, actual_codec)?;
let record = IndexRecord::new(key, segment_id, offset, data_size, 0);
inner.append_index(&record)?;
let entry_end = offset + ENTRY_HEADER_SIZE as u64 + data_size as u64;
inner.mark_indexed(segment_id, entry_end)?;
let bucket_id = bucket::bucket_id(&key);
self.cache.update_record(account_id, bucket_id, record);
Ok(())
}
pub fn read(&self, account_id: &str, key: &[u8; 32]) -> Result<Option<Vec<u8>>> {
let bucket_id = bucket::bucket_id(key);
let handle = {
let accounts = self.accounts.read().unwrap();
accounts
.get(account_id)
.ok_or_else(|| Error::AccountNotFound(account_id.to_string()))?
.clone()
};
let (record, seg_path): (IndexRecord, PathBuf) = {
let inner = handle.read();
let records = self
.cache
.get_or_load(account_id, bucket_id, handle.dir())?;
match records.binary_search_by(|r| r.key.cmp(key)) {
Ok(idx) => {
let r = records[idx].clone();
if r.is_tombstone() {
return Ok(None);
}
let seg_path = inner.segment_path(r.segment_id)?;
(r, seg_path)
}
Err(_) => return Ok(None),
}
};
if !seg_path.exists() {
return Err(Error::SegmentNotFound(record.segment_id));
}
let reader = SegmentReader::open(seg_path.clone(), record.segment_id)?;
let file = handle.get_segment_file(record.segment_id, &seg_path)?;
let (entry, _) = reader.read_entry_at_file(record.offset, &file)?;
let value = compress::decompress(&entry.data, entry.codec, entry.raw_size as usize)?;
Ok(Some(value))
}
pub fn delete(&self, account_id: &str, key: &[u8; 32]) -> Result<()> {
let handle = {
let accounts = self.accounts.read().unwrap();
accounts
.get(account_id)
.ok_or_else(|| Error::AccountNotFound(account_id.to_string()))?
.clone()
};
let _write_lock = handle.write_mutex.lock().unwrap();
let mut inner = handle.write();
let (segment_id, offset, data_size) =
inner.write_entry(*key, &[], 1, Codec::None)?;
let record = IndexRecord::new(*key, segment_id, offset, data_size, 1);
inner.append_index(&record)?;
let entry_end = offset + ENTRY_HEADER_SIZE as u64 + data_size as u64;
inner.mark_indexed(segment_id, entry_end)?;
let bucket_id = bucket::bucket_id(key);
self.cache.update_record(account_id, bucket_id, record);
Ok(())
}
// ── Batch write ─────────────────────────────────────────────────────
pub fn write_batch(&self, account_id: &str, entries: &[([u8; 32], Vec<u8>, Codec)]) -> Result<()> {
if entries.is_empty() {
return Ok(());
}
let handle = {
let accounts = self.accounts.read().unwrap();
accounts
.get(account_id)
.ok_or_else(|| Error::AccountNotFound(account_id.to_string()))?
.clone()
};
let _write_lock = handle.write_mutex.lock().unwrap();
let mut inner = handle.write();
let mut pending: Vec<(IndexRecord, u64)> = Vec::with_capacity(entries.len());
for (key, value, codec) in entries {
if value.len() > crate::types::MAX_VALUE_SIZE {
return Err(Error::ValueTooLarge { size: value.len() });
}
let (data, actual_codec) =
compress::compress(value, *codec, self.config.compress_threshold, self.config.compression_level);
let (segment_id, offset, data_size) =
inner.append_entry(*key, &data, 0, actual_codec)?;
let entry_end = offset + ENTRY_HEADER_SIZE as u64 + data_size as u64;
let record = IndexRecord::new(*key, segment_id, offset, data_size, 0);
pending.push((record, entry_end));
}
inner.flush_active()?;
for (record, entry_end) in &pending {
inner.append_index(record)?;
inner.mark_indexed(record.segment_id, *entry_end)?;
let bucket_id = bucket::bucket_id(&record.key);
self.cache.update_record(account_id, bucket_id, record.clone());
}
Ok(())
}
// ── GC ──────────────────────────────────────────────────────────────
pub fn gc(&self, account_id: &str) -> Result<Option<GcStats>> {
let handle = {
let accounts = self.accounts.read().unwrap();
accounts
.get(account_id)
.ok_or_else(|| Error::AccountNotFound(account_id.to_string()))?
.clone()
};
// Hold write_mutex to prevent concurrent writes from racing
// with GC's bucket rebuild phase.
let _write_lock = handle.write_mutex.lock().unwrap();
let result = gc::gc_account(handle.dir(), self.config.gc_deleted_ratio)?;
// Invalidate FilePool for GC'd segments (they were rewritten via rename)
if let Some(ref stats) = result {
handle.invalidate_file_cache(stats.segment_id);
}
for bid in 0..crate::types::BUCKET_COUNT {
self.cache.invalidate(account_id, bid);
}
Ok(result)
}
pub fn compact_buckets(&self, account_id: &str) -> Result<()> {
let handle = {
let accounts = self.accounts.read().unwrap();
accounts
.get(account_id)
.ok_or_else(|| Error::AccountNotFound(account_id.to_string()))?
.clone()
};
// Hold write_mutex — compact rewrites all bucket files.
let _write_lock = handle.write_mutex.lock().unwrap();
gc::compact_buckets(handle.dir())?;
for bid in 0..crate::types::BUCKET_COUNT {
self.cache.invalidate(account_id, bid);
}
Ok(())
}
// ── Stats / Shutdown ────────────────────────────────────────────────
pub fn stats(&self, account_id: &str) -> Result<AccountStats> {
let handle = {
let accounts = self.accounts.read().unwrap();
accounts
.get(account_id)
.ok_or_else(|| Error::AccountNotFound(account_id.to_string()))?
.clone()
};
let inner = handle.read();
let meta = inner.meta();
let mut total_bytes = 0u64;
let mut deleted_bytes = 0u64;
for seg in meta.segments.values() {
total_bytes += seg.total_bytes;
deleted_bytes += seg.deleted_bytes;
}
let mut total_keys = 0u64;
for bid in 0..crate::types::BUCKET_COUNT {
if let Ok(records) =
self.cache
.get_or_load(account_id, bid, handle.dir())
{
total_keys += records.iter().filter(|r| !r.is_tombstone()).count() as u64;
}
}
Ok(AccountStats {
account_id: account_id.to_string(),
total_keys,
total_bytes,
deleted_bytes,
segment_count: meta.segments.len(),
})
}
pub fn shutdown(&self) -> Result<()> {
let accounts = self.accounts.read().unwrap();
for (_, handle) in accounts.iter() {
let mut inner = handle.write();
inner.flush_active()?;
}
let global = GlobalMeta::load(&self.root)?;
global.save(&self.root)?;
tracing::info!("bichon-blob shut down cleanly");
Ok(())
}
}
impl Drop for Engine {
fn drop(&mut self) {
if let Err(e) = self.shutdown() {
tracing::error!("bichon-blob shutdown error: {}", e);
}
}
}

59
crates/blob/src/error.rs Normal file
View File

@@ -0,0 +1,59 @@
use std::{io, path::PathBuf};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("CRC32 mismatch at {path}:{offset}")]
CrcMismatch { path: PathBuf, offset: u64 },
#[error("Corrupt entry at {path}:{offset}: {reason}")]
CorruptEntry {
path: PathBuf,
offset: u64,
reason: String,
},
#[error("Account not found: {0}")]
AccountNotFound(String),
#[error("Account already exists: {0}")]
AccountAlreadyExists(String),
#[error("Segment not found: {0}")]
SegmentNotFound(u32),
#[error("Value too large: {size} bytes (max 100 MB)")]
ValueTooLarge { size: usize },
#[error("Compression error: {0}")]
Compression(String),
#[error("Disk full: {0}")]
DiskFull(String),
#[error("Invalid config: {0}")]
InvalidConfig(String),
#[error("Bucket index corrupt at {path}: {reason}")]
BucketIndexCorrupt { path: PathBuf, reason: String },
#[error("Segment file truncated at {path}: expected {expected}, got {actual}")]
SegmentTruncated {
path: PathBuf,
expected: u64,
actual: u64,
},
#[error("Corrupt metadata file: {0}")]
CorruptMeta(String),
#[error("Unsupported metadata version {version} in {path}")]
UnsupportedMetaVersion { path: PathBuf, version: u32 },
}

View File

@@ -0,0 +1,54 @@
use std::collections::VecDeque;
use std::fs::File;
use std::path::Path;
use std::sync::{Arc, Mutex};
use crate::error::Result;
use crate::fs as fs_util;
/// Simple LRU pool of open file handles, keyed by segment_id.
/// Uses Arc<Mutex<File>> to allow safe concurrent reads from the same segment.
pub struct FilePool {
max_entries: usize,
entries: Mutex<VecDeque<(u32, Arc<Mutex<File>>)>>,
}
impl FilePool {
pub fn new(max_entries: usize) -> Self {
Self {
max_entries: max_entries.max(1),
entries: Mutex::new(VecDeque::new()),
}
}
/// Get an open File for the given segment. Reuses cached handle if available.
pub fn get(&self, seg_id: u32, path: &Path) -> Result<Arc<Mutex<File>>> {
let mut entries = self.entries.lock().unwrap();
// Check for existing entry
for (i, (id, _)) in entries.iter().enumerate() {
if *id == seg_id {
let (_, file) = entries.remove(i).unwrap();
entries.push_front((seg_id, file.clone()));
return Ok(file);
}
}
// Open new file
let file = Arc::new(Mutex::new(fs_util::open_read(path)?));
// Evict oldest if full
if entries.len() >= self.max_entries {
entries.pop_back();
}
entries.push_front((seg_id, file.clone()));
Ok(file)
}
/// Remove a cached file handle (e.g. after GC rewrites a segment).
pub fn invalidate(&self, seg_id: u32) {
let mut entries = self.entries.lock().unwrap();
entries.retain(|(id, _)| *id != seg_id);
}
}

142
crates/blob/src/fs.rs Normal file
View File

@@ -0,0 +1,142 @@
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::Path;
use std::time::Duration;
use crate::error::Result;
/// Max retries for transient filesystem errors (NFS ESTALE, CIFS sharing violations, etc.)
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY: Duration = Duration::from_millis(20);
/// Check if an I/O error is transient (retryable).
fn is_transient(err: &io::Error) -> bool {
use std::io::ErrorKind;
matches!(
err.kind(),
ErrorKind::TimedOut
| ErrorKind::Interrupted
| ErrorKind::WouldBlock
| ErrorKind::UnexpectedEof
) || err.raw_os_error() == Some(116) // ESTALE on Linux
}
/// Open an existing file for reading, with retry on transient errors (NFS ESTALE etc.).
pub fn open_read(path: &Path) -> Result<File> {
let mut last_err = None;
for attempt in 0..MAX_RETRIES {
match File::open(path) {
Ok(f) => return Ok(f),
Err(e) if is_transient(&e) => {
last_err = Some(e);
if attempt > 0 {
std::thread::sleep(RETRY_DELAY * attempt);
}
continue;
}
Err(e) => return Err(e.into()),
}
}
Err(crate::error::Error::Io(last_err.unwrap()))
}
/// Open an existing file for writing, with retry on transient errors.
pub fn open_write(path: &Path) -> Result<File> {
let mut last_err = None;
for attempt in 0..MAX_RETRIES {
match OpenOptions::new().write(true).open(path) {
Ok(f) => return Ok(f),
Err(e) if is_transient(&e) => {
last_err = Some(e);
if attempt > 0 {
std::thread::sleep(RETRY_DELAY * attempt);
}
continue;
}
Err(e) => return Err(e.into()),
}
}
Err(crate::error::Error::Io(last_err.unwrap()))
}
/// Create a new file atomically: write content to a temp file, fsync, then rename.
/// Avoids `create_new(true)` which is racy on NFS.
pub fn create_atomic(path: &Path, content: &[u8]) -> Result<()> {
let tmp = path.with_extension(
path.extension()
.map(|e| format!("{}.tmp", e.to_string_lossy()))
.unwrap_or_else(|| "tmp".to_string()),
);
{
let mut f = File::create(&tmp)?;
f.write_all(content)?;
f.sync_all()?;
}
fs::rename(&tmp, path)?;
Ok(())
}
/// Truncate an existing file to the given size, with retry.
pub fn truncate(path: &Path, size: u64) -> Result<()> {
let f = open_write(path)?;
f.set_len(size)?;
f.sync_all()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_open_read_existing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.txt");
std::fs::write(&path, b"hello").unwrap();
let mut f = open_read(&path).unwrap();
let mut s = String::new();
std::io::Read::read_to_string(&mut f, &mut s).unwrap();
assert_eq!(s, "hello");
}
#[test]
fn test_open_read_missing() {
let dir = TempDir::new().unwrap();
let result = open_read(&dir.path().join("nope.txt"));
assert!(result.is_err());
}
#[test]
fn test_create_atomic_success() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("data.bin");
create_atomic(&path, b"hello world").unwrap();
let content = std::fs::read(&path).unwrap();
assert_eq!(content, b"hello world");
// Temp file should not exist
assert!(!dir.path().join("data.bin.tmp").exists());
}
#[test]
fn test_create_atomic_overwrites() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("data.bin");
create_atomic(&path, b"first").unwrap();
create_atomic(&path, b"second").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"second");
}
#[test]
fn test_truncate() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("trunc.bin");
std::fs::write(&path, b"1234567890").unwrap();
truncate(&path, 5).unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().len(), 5);
}
}

267
crates/blob/src/gc.rs Normal file
View File

@@ -0,0 +1,267 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::bucket::{self, BucketFile, BucketIndex, IndexRecord};
use crate::error::Result;
#[cfg(test)]
use crate::meta::SegmentStats;
use crate::segment::{self, SegmentReader, SegmentWriter};
/// Result of a GC run.
#[derive(Debug)]
pub struct GcStats {
pub segment_id: u32,
pub bytes_before: u64,
pub bytes_after: u64,
pub entries_kept: usize,
pub entries_skipped: usize,
}
/// Run GC on an account: pick the sealed segment with highest deleted_ratio,
/// rewrite it without deleted/overwritten entries, then rebuild all bucket files.
pub fn gc_account(
account_dir: &Path,
deleted_ratio_threshold: f64,
) -> Result<Option<GcStats>> {
let meta = crate::meta::AccountMeta::load(account_dir)?;
// Find the best candidate
let candidate = meta
.segments
.values()
.filter(|s| s.sealed && s.deleted_ratio >= deleted_ratio_threshold)
.max_by(|a, b| a.deleted_ratio.partial_cmp(&b.deleted_ratio).unwrap());
let target = match candidate {
Some(s) => s.clone(),
None => return Ok(None),
};
let seg_path = account_dir
.join("segments")
.join(segment::segment_filename(target.segment_id));
let reader = SegmentReader::open(seg_path.clone(), target.segment_id)?;
// Build a global view: for each key, which entry (segment_id + offset) is the latest?
let mut latest_key: HashMap<[u8; 32], (u32, u64)> = HashMap::new();
for &seg_id in meta.segments.keys() {
let rpath = account_dir
.join("segments")
.join(segment::segment_filename(seg_id));
if !rpath.exists() {
continue;
}
let r = SegmentReader::open(rpath, seg_id)?;
let _ = r.scan_entries(0, |entry, offset| {
match latest_key.get(&entry.key) {
Some((existing_seg, existing_off)) => {
if seg_id > *existing_seg
|| (seg_id == *existing_seg && offset > *existing_off)
{
latest_key.insert(entry.key, (seg_id, offset));
}
}
None => {
latest_key.insert(entry.key, (seg_id, offset));
}
}
Ok(())
})?;
}
// Create temp segment with a unique name
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_name = format!("temp_{:016x}.seg", timestamp);
let temp_path = account_dir.join("segments").join(&temp_name);
let mut writer = SegmentWriter::create(temp_path.clone(), target.segment_id)?;
let mut bytes_after: u64 = 0;
let mut entries_kept: usize = 0;
let mut entries_skipped: usize = 0;
reader.scan_entries(0, |entry, offset| {
// Skip tombstones
if entry.is_tombstone() {
entries_skipped += 1;
return Ok(());
}
// Skip if this key has a newer entry in another segment
if let Some((latest_seg, latest_off)) = latest_key.get(&entry.key) {
if *latest_seg != target.segment_id || *latest_off != offset {
entries_skipped += 1;
return Ok(());
}
}
// Keep this entry
writer.append(entry)?;
bytes_after += entry.data.len() as u64;
entries_kept += 1;
Ok(())
})?;
writer.fsync()?;
// Atomic rename: replace old segment with new one
fs::rename(&temp_path, &seg_path)?;
// Rebuild all bucket files
rebuild_buckets(account_dir, &meta)?;
// Update meta
let mut meta = crate::meta::AccountMeta::load(account_dir)?;
if let Some(stats) = meta.segments.get_mut(&target.segment_id) {
stats.total_bytes = bytes_after;
stats.deleted_bytes = 0;
stats.recompute_ratio();
}
meta.save(account_dir)?;
Ok(Some(GcStats {
segment_id: target.segment_id,
bytes_before: target.total_bytes,
bytes_after,
entries_kept,
entries_skipped,
}))
}
/// Rebuild all 16 bucket files from scratch by scanning all segments.
fn rebuild_buckets(account_dir: &Path, meta: &crate::meta::AccountMeta) -> Result<()> {
let mut bucket_records: HashMap<u16, Vec<IndexRecord>> = HashMap::new();
for i in 0..crate::types::BUCKET_COUNT {
bucket_records.insert(i, Vec::new());
}
for &seg_id in meta.segments.keys() {
let seg_path = account_dir
.join("segments")
.join(segment::segment_filename(seg_id));
if !seg_path.exists() {
continue;
}
let reader = SegmentReader::open(seg_path, seg_id)?;
reader.scan_entries(0, |entry, offset| {
let bid = bucket::bucket_id(&entry.key);
let rec = IndexRecord::new(
entry.key,
seg_id,
offset,
entry.data.len() as u32,
entry.flags,
);
bucket_records.entry(bid).or_default().push(rec);
Ok(())
})?;
}
for (bid, records) in &bucket_records {
let index = BucketIndex::from_records(records.clone(), *bid);
let bf = BucketFile::open(account_dir, *bid);
bf.rewrite(&index.records)?;
}
Ok(())
}
/// Compact bucket files: load, dedup, rewrite.
pub fn compact_buckets(account_dir: &Path) -> Result<()> {
for bid in 0..crate::types::BUCKET_COUNT {
let bf = BucketFile::open(account_dir, bid);
if bf.path().exists() {
let index = bf.load_index()?;
bf.rewrite(&index.records)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::segment::Entry;
use crate::types::Codec;
use tempfile::TempDir;
fn setup_account(dir: &Path) {
fs::create_dir_all(dir.join("segments")).unwrap();
crate::bucket::BucketFile::ensure_dir(dir).unwrap();
let seg_path = dir
.join("segments")
.join(segment::segment_filename(1));
let mut writer = SegmentWriter::create(seg_path, 1).unwrap();
// Write 5 entries
for i in 0..5u8 {
let mut key = [0u8; 32];
key[0] = i;
let entry = Entry::new(key, &vec![i; 1000], 0, Codec::None);
writer.append(&entry).unwrap();
}
// Tombstone entry 2
let mut key2 = [0u8; 32];
key2[0] = 2;
let tomb = Entry::tombstone(key2);
writer.append(&tomb).unwrap();
writer.fsync().unwrap();
// Save meta
let mut meta = crate::meta::AccountMeta::new("test".into(), 2);
meta.segments.insert(
1,
SegmentStats {
segment_id: 1,
total_bytes: 6000,
deleted_bytes: 1000,
deleted_ratio: 1000.0 / 6000.0,
sealed: true,
indexed_up_to_offset: 0,
},
);
// Make segment 2 active so segment 1 is sealed
let seg2_path = dir
.join("segments")
.join(segment::segment_filename(2));
SegmentWriter::create(seg2_path, 2).unwrap();
meta.save(dir).unwrap();
}
#[test]
fn test_gc_removes_tombstones() {
let dir = TempDir::new().unwrap();
setup_account(dir.path());
let result = gc_account(dir.path(), 0.01).unwrap();
assert!(result.is_some());
// Verify segment 1 no longer has the tombstone'd entry
let seg_path = dir
.path()
.join("segments")
.join(segment::segment_filename(1));
let reader = SegmentReader::open(seg_path, 1).unwrap();
let mut count = 0;
reader.scan_entries(0, |entry, _offset| {
count += 1;
assert!(entry.key[0] != 2);
Ok(())
}).unwrap();
assert_eq!(count, 4); // 5 original - 1 tombstoned
}
#[test]
fn test_compact_buckets() {
let dir = TempDir::new().unwrap();
setup_account(dir.path());
compact_buckets(dir.path()).unwrap();
// Should not panic
}
}

19
crates/blob/src/lib.rs Normal file
View File

@@ -0,0 +1,19 @@
pub mod account;
pub mod bucket;
pub mod cache;
pub mod checksum;
pub mod compress;
pub mod engine;
pub mod error;
pub mod file_pool;
pub mod fs;
pub mod gc;
pub mod meta;
pub mod recovery;
pub mod segment;
pub mod types;
pub use account::AccountHandle;
pub use engine::{AccountStats, Engine};
pub use error::{Error, Result};
pub use types::{Codec, Config};

284
crates/blob/src/meta.rs Normal file
View File

@@ -0,0 +1,284 @@
use std::collections::BTreeMap;
use std::path::Path;
use crate::checksum;
use crate::error::Result;
use serde::{Deserialize, Serialize};
const META_VERSION: u32 = 1;
// ── Helpers ────────────────────────────────────────────────────────────────
fn write_bin<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let payload = bincode::serialize(value).map_err(|e| {
crate::error::Error::CorruptMeta(format!("{}: bincode encode: {}", path.display(), e))
})?;
let crc = checksum::crc32(&payload);
let mut buf = Vec::with_capacity(8 + payload.len());
buf.extend_from_slice(&crc.to_le_bytes());
buf.extend_from_slice(&META_VERSION.to_le_bytes());
buf.extend_from_slice(&payload);
crate::fs::create_atomic(path, &buf)?;
Ok(())
}
fn read_bin<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
let data = std::fs::read(path)?;
if data.len() < 8 {
return Err(crate::error::Error::CorruptMeta(path.display().to_string()));
}
let stored_crc = u32::from_le_bytes(data[0..4].try_into().unwrap());
let version = u32::from_le_bytes(data[4..8].try_into().unwrap());
if version != META_VERSION {
return Err(crate::error::Error::UnsupportedMetaVersion {
path: path.to_path_buf(),
version,
});
}
let computed = checksum::crc32(&data[8..]);
if stored_crc != computed {
return Err(crate::error::Error::CorruptMeta(path.display().to_string()));
}
bincode::deserialize(&data[8..]).map_err(|e| {
crate::error::Error::CorruptMeta(format!("{}: bincode decode: {}", path.display(), e))
})
}
// ── GlobalMeta ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalMeta {
pub version: u32,
pub accounts: Vec<String>,
}
impl Default for GlobalMeta {
fn default() -> Self {
Self {
version: META_VERSION,
accounts: Vec::new(),
}
}
}
impl GlobalMeta {
pub fn load(store_root: &Path) -> Result<Self> {
let bin_path = store_root.join("global_meta.bin");
if bin_path.exists() {
return read_bin(&bin_path);
}
// Migration from JSON
let json_path = store_root.join("global_meta.json");
if json_path.exists() {
let data = std::fs::read_to_string(&json_path)?;
let mut meta: Self = serde_json::from_str(&data)?;
meta.accounts.sort();
write_bin(&bin_path, &meta)?;
let _ = std::fs::remove_file(&json_path);
return Ok(meta);
}
Ok(Self::default())
}
pub fn save(&self, store_root: &Path) -> Result<()> {
let path = store_root.join("global_meta.bin");
let mut meta = self.clone();
meta.accounts.sort();
write_bin(&path, &meta)
}
}
// ── SegmentStats ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SegmentStats {
pub segment_id: u32,
pub total_bytes: u64,
pub deleted_bytes: u64,
pub deleted_ratio: f64,
pub sealed: bool,
/// Byte offset up to which entries have been indexed in bucket files.
/// Recovery starts scanning from here instead of 0.
pub indexed_up_to_offset: u64,
}
impl SegmentStats {
pub fn new(segment_id: u32) -> Self {
Self {
segment_id,
total_bytes: 0,
deleted_bytes: 0,
deleted_ratio: 0.0,
sealed: false,
indexed_up_to_offset: 0,
}
}
pub fn recompute_ratio(&mut self) {
if self.total_bytes > 0 {
self.deleted_ratio = self.deleted_bytes as f64 / self.total_bytes as f64;
} else {
self.deleted_ratio = 0.0;
}
}
}
// ── AccountMeta ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountMeta {
pub account_id: String,
pub active_segment_id: u32,
pub segments: BTreeMap<u32, SegmentStats>,
}
impl AccountMeta {
pub fn new(account_id: String, active_segment_id: u32) -> Self {
Self {
account_id,
active_segment_id,
segments: BTreeMap::new(),
}
}
pub fn load(account_dir: &Path) -> Result<Self> {
let bin_path = account_dir.join("meta.bin");
if bin_path.exists() {
return read_bin(&bin_path);
}
// Migration from JSON
let json_path = account_dir.join("meta.json");
if json_path.exists() {
let data = std::fs::read_to_string(&json_path)?;
let meta: Self = serde_json::from_str(&data)?;
write_bin(&bin_path, &meta)?;
let _ = std::fs::remove_file(&json_path);
return Ok(meta);
}
Err(crate::error::Error::AccountNotFound(
account_dir.to_string_lossy().into(),
))
}
pub fn save(&self, account_dir: &Path) -> Result<()> {
write_bin(&account_dir.join("meta.bin"), self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_global_meta_bin_roundtrip() {
let dir = TempDir::new().unwrap();
let mut meta = GlobalMeta::default();
meta.accounts.push("alice".into());
meta.save(dir.path()).unwrap();
let loaded = GlobalMeta::load(dir.path()).unwrap();
assert_eq!(loaded.accounts, vec!["alice"]);
assert!(!dir.path().join("global_meta.json").exists());
assert!(dir.path().join("global_meta.bin").exists());
}
#[test]
fn test_global_meta_default_when_missing() {
let dir = TempDir::new().unwrap();
let meta = GlobalMeta::load(dir.path()).unwrap();
assert!(meta.accounts.is_empty());
}
#[test]
fn test_json_migration() {
let dir = TempDir::new().unwrap();
// Write old JSON format
let json = r#"{"version":1,"accounts":["bob","alice"]}"#;
std::fs::write(dir.path().join("global_meta.json"), json).unwrap();
let meta = GlobalMeta::load(dir.path()).unwrap();
// Should be sorted
assert_eq!(meta.accounts, vec!["alice", "bob"]);
// JSON should be removed
assert!(!dir.path().join("global_meta.json").exists());
// BIN should exist
assert!(dir.path().join("global_meta.bin").exists());
}
#[test]
fn test_account_meta_bin_roundtrip() {
let dir = TempDir::new().unwrap();
let mut meta = AccountMeta::new("alice".into(), 1);
meta.segments.insert(
1,
SegmentStats {
segment_id: 1,
total_bytes: 1000,
deleted_bytes: 300,
deleted_ratio: 0.3,
sealed: false,
indexed_up_to_offset: 0,
},
);
meta.save(dir.path()).unwrap();
let loaded = AccountMeta::load(dir.path()).unwrap();
assert_eq!(loaded.active_segment_id, 1);
assert_eq!(loaded.segments[&1].total_bytes, 1000);
}
#[test]
fn test_corrupt_bin_detected() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("meta.bin"), vec![0xFFu8; 100]).unwrap();
let result = AccountMeta::load(dir.path());
assert!(result.is_err());
// 0xFFFFFFFF version triggers UnsupportedMetaVersion
assert!(matches!(result.unwrap_err(), crate::error::Error::UnsupportedMetaVersion { .. }));
}
#[test]
fn test_crc_corruption_detected() {
let dir = TempDir::new().unwrap();
// Write a well-formed header (version=1) but with wrong CRC bytes
let mut buf = Vec::new();
buf.extend_from_slice(&0xDEADBEEFu32.to_le_bytes()); // wrong CRC
buf.extend_from_slice(&1u32.to_le_bytes()); // version = 1 (OK)
buf.extend_from_slice(b"some payload bytes"); // payload
std::fs::write(dir.path().join("meta.bin"), &buf).unwrap();
let result = AccountMeta::load(dir.path());
assert!(matches!(result.unwrap_err(), crate::error::Error::CorruptMeta(_)));
}
#[test]
fn test_account_json_migration() {
let dir = TempDir::new().unwrap();
// Write old JSON format for AccountMeta
let json = r#"{"account_id":"alice","active_segment_id":5,"segments":{}}"#;
std::fs::write(dir.path().join("meta.json"), json).unwrap();
let meta = AccountMeta::load(dir.path()).unwrap();
assert_eq!(meta.account_id, "alice");
assert_eq!(meta.active_segment_id, 5);
// JSON should be removed
assert!(!dir.path().join("meta.json").exists());
// BIN should exist
assert!(dir.path().join("meta.bin").exists());
}
#[test]
fn test_bin_sorted_keys() {
let dir = TempDir::new().unwrap();
let mut meta = AccountMeta::new("test".into(), 1);
meta.segments.insert(3, SegmentStats::new(3));
meta.segments.insert(1, SegmentStats::new(1));
meta.segments.insert(2, SegmentStats::new(2));
meta.save(dir.path()).unwrap();
let loaded = AccountMeta::load(dir.path()).unwrap();
let keys: Vec<u32> = loaded.segments.keys().copied().collect();
assert_eq!(keys, vec![1, 2, 3]);
}
}

202
crates/blob/src/recovery.rs Normal file
View File

@@ -0,0 +1,202 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::bucket::{self, BucketFile, IndexRecord};
use crate::error::Result;
use crate::meta::{AccountMeta, SegmentStats};
use crate::segment::{self, SegmentReader};
/// Recover an account after a crash: scan segments, repair indices, update stats.
pub fn recover_account(account_dir: &Path) -> Result<AccountMeta> {
let meta_bin = account_dir.join("meta.bin");
let meta_json = account_dir.join("meta.json");
let meta_exists = meta_bin.exists() || meta_json.exists();
let mut meta = if meta_exists {
AccountMeta::load(account_dir).unwrap_or_else(|_| {
AccountMeta::new(
account_dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into(),
1,
)
})
} else {
return Ok(AccountMeta::new(
account_dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into(),
1,
));
};
// Discover all segment files on disk
let seg_dir = account_dir.join("segments");
if !seg_dir.exists() {
fs::create_dir_all(&seg_dir)?;
}
let mut disk_segments: Vec<u32> = Vec::new();
if seg_dir.exists() {
for entry in fs::read_dir(&seg_dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.ends_with(".seg") && !name_str.contains("temp_") {
if let Some(id_str) = name_str.strip_suffix(".seg") {
if let Ok(id) = id_str.parse::<u32>() {
disk_segments.push(id);
}
}
}
}
}
disk_segments.sort_unstable();
if disk_segments.is_empty() {
meta.active_segment_id = 1;
} else {
let max_id = *disk_segments.last().unwrap();
meta.active_segment_id = max_id;
}
// Ensure buckets directory exists
let buckets_dir = account_dir.join("buckets");
fs::create_dir_all(&buckets_dir)?;
// For each segment, scan only the unindexed tail and update stats incrementally
for &seg_id in &disk_segments {
let seg_path = seg_dir.join(segment::segment_filename(seg_id));
let file_size = fs::metadata(&seg_path)?.len();
// Preserve existing stats; start fresh if this is a newly discovered segment
let mut stats = meta.segments.remove(&seg_id).unwrap_or_else(|| SegmentStats::new(seg_id));
let is_sealed = seg_id != meta.active_segment_id;
stats.sealed = is_sealed;
// Scan start: from last indexed offset. Clamp defensively.
let scan_start = if stats.indexed_up_to_offset <= file_size {
stats.indexed_up_to_offset
} else {
0
};
// If fully indexed, skip scanning entirely
if scan_start >= file_size {
meta.segments.insert(seg_id, stats);
continue;
}
let reader = SegmentReader::open(seg_path.clone(), seg_id)?;
let mut new_records: HashMap<u16, Vec<IndexRecord>> = HashMap::new();
let truncation_point = reader.scan_entries(scan_start, |entry, offset| {
let bid = bucket::bucket_id(&entry.key);
let rec = IndexRecord::new(
entry.key,
seg_id,
offset,
entry.data.len() as u32,
entry.flags,
);
new_records.entry(bid).or_default().push(rec);
stats.total_bytes += entry.data.len() as u64;
if entry.is_tombstone() {
stats.deleted_bytes += entry.raw_size as u64;
}
Ok(())
})?;
// Merge new records into bucket files (only the newly discovered ones)
for (bid, records) in &new_records {
let bf = BucketFile::open(account_dir, *bid);
bf.append_batch(records)?;
}
// Truncate if tail corruption found
if truncation_point < file_size {
segment::truncate_segment(&seg_path, truncation_point)?;
}
stats.indexed_up_to_offset = truncation_point;
stats.recompute_ratio();
meta.segments.insert(seg_id, stats);
}
meta.save(account_dir)?;
Ok(meta)
}
/// Clean up leftover temp files from interrupted GC.
pub fn cleanup_temp_files(account_dir: &Path) -> Result<()> {
let seg_dir = account_dir.join("segments");
if seg_dir.exists() {
for entry in fs::read_dir(&seg_dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("temp_") {
let path = entry.path();
tracing::warn!("Removing leftover temp file: {:?}", path);
fs::remove_file(&path)?;
}
}
}
// Also cleanup temp bucket files
let buckets_dir = account_dir.join("buckets");
if buckets_dir.exists() {
for entry in fs::read_dir(&buckets_dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.ends_with(".tmp") {
let path = entry.path();
tracing::warn!("Removing leftover temp bucket file: {:?}", path);
fs::remove_file(&path)?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_recover_fresh_account() {
let dir = TempDir::new().unwrap();
let account_dir = dir.path().join("test");
fs::create_dir_all(&account_dir).unwrap();
let meta = recover_account(&account_dir).unwrap();
assert_eq!(meta.active_segment_id, 1);
assert!(meta.segments.is_empty());
}
#[test]
fn test_cleanup_temp_files() {
let dir = TempDir::new().unwrap();
let account_dir = dir.path().join("test");
fs::create_dir_all(account_dir.join("segments")).unwrap();
fs::create_dir_all(account_dir.join("buckets")).unwrap();
fs::write(
account_dir.join("segments").join("temp_ABC123.seg"),
b"garbage",
)
.unwrap();
fs::write(account_dir.join("buckets").join("00.idx.tmp"), b"garbage").unwrap();
cleanup_temp_files(&account_dir).unwrap();
assert!(!account_dir.join("segments").join("temp_ABC123.seg").exists());
}
}

539
crates/blob/src/segment.rs Normal file
View File

@@ -0,0 +1,539 @@
use std::fs::{self, File};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::checksum;
use crate::error::{Error, Result};
use crate::fs as fs_util;
use crate::types::{Codec, ENTRY_HEADER_SIZE, ENTRY_MAGIC, SEGMENT_MAX_SIZE};
/// In-memory representation of a stored entry.
#[derive(Debug, Clone)]
pub struct Entry {
pub flags: u8,
pub codec: Codec,
pub key: [u8; 32],
pub raw_size: u32,
pub data: Vec<u8>,
}
impl Entry {
/// Create a normal data entry.
pub fn new(key: [u8; 32], raw_data: &[u8], flags: u8, codec: Codec) -> Self {
Self {
flags,
codec,
key,
raw_size: raw_data.len() as u32,
data: raw_data.to_vec(),
}
}
/// Create a tombstone entry.
pub fn tombstone(key: [u8; 32]) -> Self {
Self {
flags: 1,
codec: Codec::None,
key,
raw_size: 0,
data: Vec::new(),
}
}
pub fn is_tombstone(&self) -> bool {
self.flags == 1
}
/// Total on-disk size: header + data
pub fn disk_size(&self) -> usize {
ENTRY_HEADER_SIZE + self.data.len()
}
}
/// Write entries sequentially to a segment file.
pub struct SegmentWriter {
file: File,
path: PathBuf,
id: u32,
bytes_written: u64,
}
impl SegmentWriter {
pub fn create(path: PathBuf, id: u32) -> Result<Self> {
// Use create+truncate instead of create_new to avoid NFS O_EXCL issues.
let file = File::create(&path)?;
Ok(Self {
file,
path,
id,
bytes_written: 0,
})
}
pub fn open_append(path: PathBuf, id: u32) -> Result<Self> {
let mut file = fs_util::open_write(&path)?;
file.seek(SeekFrom::End(0))?;
let bytes_written = file.stream_position()?;
Ok(Self {
file,
path,
id,
bytes_written,
})
}
pub fn id(&self) -> u32 {
self.id
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn bytes_written(&self) -> u64 {
self.bytes_written
}
pub fn is_full(&self) -> bool {
self.bytes_written >= SEGMENT_MAX_SIZE
}
/// Append an entry. Returns the offset where it was written.
pub fn append(&mut self, entry: &Entry) -> Result<u64> {
let offset = self.bytes_written;
self.write_entry(entry)
.map_err(|e| map_io_err(e, &self.path))?;
Ok(offset)
}
fn write_entry(&mut self, entry: &Entry) -> Result<()> {
let data_size = entry.data.len() as u32;
// Write magic
self.file.write_all(&ENTRY_MAGIC.to_le_bytes())?;
// CRC32 placeholder: write zeros, remember position
let crc_pos = self.file.stream_position()?;
self.file.write_all(&0u32.to_le_bytes())?;
// Write flags, codec, key, raw_size, data_size
self.file.write_all(&[entry.flags])?;
self.file.write_all(&[entry.codec as u8])?;
self.file.write_all(&entry.key)?;
self.file.write_all(&entry.raw_size.to_le_bytes())?;
self.file.write_all(&data_size.to_le_bytes())?;
// Write data
self.file.write_all(&entry.data)?;
// Calculate CRC32 over everything after the crc32 field
let crc = {
let mut hasher = checksum::CrcWriter::new();
hasher.update(&[entry.flags]);
hasher.update(&[entry.codec as u8]);
hasher.update(&entry.key);
hasher.update(&entry.raw_size.to_le_bytes());
hasher.update(&data_size.to_le_bytes());
hasher.update(&entry.data);
hasher.finalize()
};
// Seek back and write the real CRC32
self.file.seek(SeekFrom::Start(crc_pos))?;
self.file.write_all(&crc.to_le_bytes())?;
// Seek back to end
self.file.seek(SeekFrom::End(0))?;
self.bytes_written += entry.disk_size() as u64;
Ok(())
}
pub fn fsync(&self) -> Result<()> {
self.file.sync_all().map_err(|e| {
if e.kind() == std::io::ErrorKind::StorageFull {
Error::DiskFull(format!("{}: {}", self.path.display(), e))
} else {
Error::Io(e)
}
})?;
Ok(())
}
}
/// Read entries from a segment file.
pub struct SegmentReader {
path: PathBuf,
id: u32,
}
impl SegmentReader {
pub fn open(path: PathBuf, id: u32) -> Result<Self> {
Ok(Self { path, id })
}
pub fn id(&self) -> u32 {
self.id
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn file_size(&self) -> Result<u64> {
Ok(fs::metadata(&self.path)?.len())
}
/// Read a single entry at the given offset. Returns the entry and the offset of the next entry.
pub fn read_entry_at(&self, offset: u64) -> Result<(Entry, u64)> {
let mut file = fs_util::open_read(&self.path)?;
file.seek(SeekFrom::Start(offset))?;
// Read magic
let mut magic_buf = [0u8; 4];
file.read_exact(&mut magic_buf)?;
let magic = u32::from_le_bytes(magic_buf);
if magic != ENTRY_MAGIC {
return Err(Error::CorruptEntry {
path: self.path.clone(),
offset,
reason: format!("bad magic: 0x{:08X}", magic),
});
}
// Read CRC32
let mut crc_buf = [0u8; 4];
file.read_exact(&mut crc_buf)?;
let stored_crc = u32::from_le_bytes(crc_buf);
// Read flags, codec
let mut flags_buf = [0u8; 1];
file.read_exact(&mut flags_buf)?;
let flags = flags_buf[0];
let mut codec_buf = [0u8; 1];
file.read_exact(&mut codec_buf)?;
let codec = Codec::from_u8(codec_buf[0]).ok_or_else(|| Error::CorruptEntry {
path: self.path.clone(),
offset,
reason: format!("unknown codec: {}", codec_buf[0]),
})?;
// Read key, raw_size, data_size
let mut key = [0u8; 32];
file.read_exact(&mut key)?;
let mut raw_size_buf = [0u8; 4];
file.read_exact(&mut raw_size_buf)?;
let raw_size = u32::from_le_bytes(raw_size_buf);
let mut data_size_buf = [0u8; 4];
file.read_exact(&mut data_size_buf)?;
let data_size = u32::from_le_bytes(data_size_buf);
// Read data
let mut data = vec![0u8; data_size as usize];
file.read_exact(&mut data)?;
// Verify CRC32 (over everything after the crc32 field)
let computed_crc = {
let mut hasher = checksum::CrcWriter::new();
hasher.update(&[flags]);
hasher.update(&[codec as u8]);
hasher.update(&key);
hasher.update(&raw_size.to_le_bytes());
hasher.update(&data_size.to_le_bytes());
hasher.update(&data);
hasher.finalize()
};
if stored_crc != computed_crc {
return Err(Error::CrcMismatch {
path: self.path.clone(),
offset,
});
}
let next_offset = offset + ENTRY_HEADER_SIZE as u64 + data_size as u64;
Ok((
Entry {
flags,
codec,
key,
raw_size,
data,
},
next_offset,
))
}
/// Read a single entry at the given offset using a pre-opened File (via Mutex).
/// This avoids the per-read File::open cost for hot segments.
pub fn read_entry_at_file(&self, offset: u64, file: &Mutex<File>) -> Result<(Entry, u64)> {
let mut file = file.lock().unwrap();
file.seek(SeekFrom::Start(offset))?;
// Read magic
let mut magic_buf = [0u8; 4];
file.read_exact(&mut magic_buf)?;
let magic = u32::from_le_bytes(magic_buf);
if magic != ENTRY_MAGIC {
return Err(Error::CorruptEntry {
path: self.path.clone(),
offset,
reason: format!("bad magic: 0x{:08X}", magic),
});
}
// Read CRC32
let mut crc_buf = [0u8; 4];
file.read_exact(&mut crc_buf)?;
let stored_crc = u32::from_le_bytes(crc_buf);
// Read flags, codec
let mut flags_buf = [0u8; 1];
file.read_exact(&mut flags_buf)?;
let flags = flags_buf[0];
let mut codec_buf = [0u8; 1];
file.read_exact(&mut codec_buf)?;
let codec = Codec::from_u8(codec_buf[0]).ok_or_else(|| Error::CorruptEntry {
path: self.path.clone(),
offset,
reason: format!("unknown codec: {}", codec_buf[0]),
})?;
// Read key, raw_size, data_size
let mut key = [0u8; 32];
file.read_exact(&mut key)?;
let mut raw_size_buf = [0u8; 4];
file.read_exact(&mut raw_size_buf)?;
let raw_size = u32::from_le_bytes(raw_size_buf);
let mut data_size_buf = [0u8; 4];
file.read_exact(&mut data_size_buf)?;
let data_size = u32::from_le_bytes(data_size_buf);
// Read data
let mut data = vec![0u8; data_size as usize];
file.read_exact(&mut data)?;
// Verify CRC32
let computed_crc = {
let mut hasher = crate::checksum::CrcWriter::new();
hasher.update(&[flags]);
hasher.update(&[codec as u8]);
hasher.update(&key);
hasher.update(&raw_size.to_le_bytes());
hasher.update(&data_size.to_le_bytes());
hasher.update(&data);
hasher.finalize()
};
if stored_crc != computed_crc {
return Err(Error::CrcMismatch {
path: self.path.clone(),
offset,
});
}
let next_offset = offset + ENTRY_HEADER_SIZE as u64 + data_size as u64;
Ok((
Entry {
flags,
codec,
key,
raw_size,
data,
},
next_offset,
))
}
/// Read data portion of an entry (for pread-style reads when you already know offset + data_size).
pub fn read_data(&self, offset: u64, data_size: u32) -> Result<Vec<u8>> {
let mut file = fs_util::open_read(&self.path)?;
// Skip magic(4) + crc32(4) + flags(1) + codec(1) + key(32) + raw_size(4) + data_size(4) = 50 bytes
let data_start = offset + ENTRY_HEADER_SIZE as u64;
file.seek(SeekFrom::Start(data_start))?;
let mut buf = vec![0u8; data_size as usize];
file.read_exact(&mut buf)?;
Ok(buf)
}
/// Read the full entry header + data for verification (used by recovery and GC).
pub fn read_full_entry(&self, offset: u64, data_size: u32) -> Result<Vec<u8>> {
let mut file = fs_util::open_read(&self.path)?;
file.seek(SeekFrom::Start(offset))?;
let total = ENTRY_HEADER_SIZE + data_size as usize;
let mut buf = vec![0u8; total];
file.read_exact(&mut buf)?;
Ok(buf)
}
/// Iterate over all valid entries in the segment, calling f for each.
/// Stops when hitting a corrupt/incomplete entry at the tail.
pub fn scan_entries<F>(&self, start_offset: u64, mut f: F) -> Result<u64>
where
F: FnMut(&Entry, u64) -> Result<()>,
{
let file_size = self.file_size()?;
let mut offset = start_offset;
while offset + ENTRY_HEADER_SIZE as u64 <= file_size {
match self.read_entry_at(offset) {
Ok((entry, next)) => {
f(&entry, offset)?;
offset = next;
}
Err(Error::CrcMismatch { .. }) | Err(Error::CorruptEntry { .. }) => {
// If near end of file (within one max entry), truncate
if file_size - offset < ENTRY_HEADER_SIZE as u64 + 100 * 1024 * 1024 {
// Likely a partial write at tail, stop here
break;
} else {
return Err(Error::CorruptEntry {
path: self.path.clone(),
offset,
reason: "mid-file corruption detected".into(),
});
}
}
Err(e) => return Err(e),
}
}
Ok(offset) // return the truncation point
}
}
/// Truncate a segment file to the given size.
pub fn truncate_segment(path: &Path, size: u64) -> Result<()> {
fs_util::truncate(path, size)
}
/// Map an Error, converting Io(StorageFull) to DiskFull with path context.
fn map_io_err(e: Error, path: &Path) -> Error {
match e {
Error::Io(io) if io.kind() == std::io::ErrorKind::StorageFull => {
Error::DiskFull(format!("{}: {}", path.display(), io))
}
_ => e,
}
}
/// Segment file name from id: "00000001.seg"
pub fn segment_filename(id: u32) -> String {
format!("{:08}.seg", id)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn temp_segment_path(dir: &TempDir, id: u32) -> PathBuf {
dir.path().join(segment_filename(id))
}
#[test]
fn test_write_and_read_entry() {
let dir = TempDir::new().unwrap();
let path = temp_segment_path(&dir, 1);
let key = [0xAAu8; 32];
let data = b"hello world".to_vec();
let entry = Entry::new(key, &data, 0, Codec::None);
{
let mut writer = SegmentWriter::create(path.clone(), 1).unwrap();
writer.append(&entry).unwrap();
writer.fsync().unwrap();
}
let reader = SegmentReader::open(path, 1).unwrap();
let (read_entry, next) = reader.read_entry_at(0).unwrap();
assert_eq!(read_entry.key, key);
assert_eq!(read_entry.data, data);
assert_eq!(read_entry.flags, 0);
assert_eq!(read_entry.raw_size, 11);
assert!(next > 0);
}
#[test]
fn test_tombstone_entry() {
let dir = TempDir::new().unwrap();
let path = temp_segment_path(&dir, 1);
let key = [0xBBu8; 32];
let entry = Entry::tombstone(key);
{
let mut writer = SegmentWriter::create(path.clone(), 1).unwrap();
writer.append(&entry).unwrap();
writer.fsync().unwrap();
}
let reader = SegmentReader::open(path, 1).unwrap();
let (read_entry, _) = reader.read_entry_at(0).unwrap();
assert!(read_entry.is_tombstone());
assert_eq!(read_entry.data.len(), 0);
}
#[test]
fn test_multiple_entries() {
let dir = TempDir::new().unwrap();
let path = temp_segment_path(&dir, 1);
let entries: Vec<_> = (0..10)
.map(|i| {
let mut key = [0u8; 32];
key[0] = i;
Entry::new(key, &vec![i; 100], 0, Codec::None)
})
.collect();
{
let mut writer = SegmentWriter::create(path.clone(), 1).unwrap();
for e in &entries {
writer.append(e).unwrap();
}
writer.fsync().unwrap();
}
let reader = SegmentReader::open(path, 1).unwrap();
let mut offset = 0u64;
for (i, expected) in entries.iter().enumerate() {
let (entry, next) = reader.read_entry_at(offset).unwrap();
assert_eq!(entry.key[0], i as u8);
assert_eq!(entry.data, expected.data);
offset = next;
}
}
#[test]
fn test_bad_magic_detected() {
let dir = TempDir::new().unwrap();
let path = temp_segment_path(&dir, 1);
// Write garbage
std::fs::write(&path, vec![0xFFu8; 100]).unwrap();
let reader = SegmentReader::open(path, 1).unwrap();
let result = reader.read_entry_at(0);
assert!(result.is_err());
}
#[test]
fn test_is_full() {
let dir = TempDir::new().unwrap();
let path = temp_segment_path(&dir, 1);
let writer = SegmentWriter::create(path, 1).unwrap();
assert!(!writer.is_full());
}
}

88
crates/blob/src/types.rs Normal file
View File

@@ -0,0 +1,88 @@
use serde::{Deserialize, Serialize};
/// Magic number for entry identification
pub const ENTRY_MAGIC: u32 = 0xB3DB_0001;
/// Fixed header size: magic(4) + crc32(4) + flags(1) + codec(1) + key(32) + raw_size(4) + data_size(4)
pub const ENTRY_HEADER_SIZE: usize = 50;
/// Index record size: key(32) + segment_id(4) + offset(8) + data_size(4) + flags(1) + _pad(3)
pub const INDEX_RECORD_SIZE: usize = 52;
/// Maximum segment size (256 MB)
pub const SEGMENT_MAX_SIZE: u64 = 256 * 1024 * 1024;
/// Number of hash buckets per account
pub const BUCKET_COUNT: u16 = 16;
/// Maximum value size (100 MB)
pub const MAX_VALUE_SIZE: usize = 100 * 1024 * 1024;
/// Default compression threshold (4 KB)
pub const DEFAULT_COMPRESS_THRESHOLD: usize = 4096;
/// Default LRU bucket cache size
pub const DEFAULT_LRU_BUCKET_COUNT: usize = 256;
/// Default GC deleted ratio threshold
pub const DEFAULT_GC_DELETED_RATIO: f64 = 0.30;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Codec {
None = 0,
Zstd = 1,
Lz4 = 2,
}
impl Codec {
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Codec::None),
1 => Some(Codec::Zstd),
2 => Some(Codec::Lz4),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub compress_threshold: usize,
pub default_codec: Codec,
pub compression_level: i32,
pub lru_bucket_count: usize,
pub gc_deleted_ratio: f64,
}
impl Default for Config {
fn default() -> Self {
Self {
compress_threshold: DEFAULT_COMPRESS_THRESHOLD,
default_codec: Codec::Zstd,
compression_level: 0,
lru_bucket_count: DEFAULT_LRU_BUCKET_COUNT,
gc_deleted_ratio: DEFAULT_GC_DELETED_RATIO,
}
}
}
impl Config {
pub fn validate(&self) -> crate::error::Result<()> {
if self.lru_bucket_count == 0 {
return Err(crate::error::Error::InvalidConfig(
"lru_bucket_count must be > 0".into(),
));
}
if self.gc_deleted_ratio <= 0.0 || self.gc_deleted_ratio >= 1.0 {
return Err(crate::error::Error::InvalidConfig(
"gc_deleted_ratio must be in (0.0, 1.0)".into(),
));
}
if self.compression_level < 0 {
return Err(crate::error::Error::InvalidConfig(
"compression_level must be >= 0".into(),
));
}
Ok(())
}
}

View File

@@ -0,0 +1,498 @@
/// Crash-consistency and ACID property tests for bichon-blob.
///
/// Since we can't kill the process mid-write in an inline test, we simulate crashes
/// by dropping the Engine without calling any cleanup (close/drop is the "crash"),
/// then re-opening and verifying recovery produced consistent state.
///
/// For true power-loss simulation, each test writes data, drops the engine abruptly,
/// then reopens and verifies: no corruption, no lost committed data, no partial writes.
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use bichon_blob::{Codec, Config, Engine};
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_key(seed: u64) -> [u8; 32] {
let mut key = [0u8; 32];
key[0..8].copy_from_slice(&seed.to_le_bytes());
key
}
fn make_value(size: usize) -> Vec<u8> {
let pattern = b"The quick brown fox jumps over the lazy dog. ";
let mut v = Vec::with_capacity(size);
while v.len() < size {
let rem = size - v.len();
let n = rem.min(pattern.len());
v.extend_from_slice(&pattern[..n]);
}
v
}
// ---------------------------------------------------------------------------
// 1. Durability: committed data survives crash
// ---------------------------------------------------------------------------
#[test]
fn test_durability_single_write_survives_crash() {
let dir = TempDir::new().unwrap();
let key = make_key(42);
let value = make_value(8192);
// Write
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
engine
.write("alice", key, &value, Codec::Zstd)
.unwrap();
} // <-- Engine dropped = simulated crash
// Recover
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
let result = engine.read("alice", &key).unwrap();
assert_eq!(result, Some(value));
}
}
#[test]
fn test_durability_many_writes_survive_crash() {
let dir = TempDir::new().unwrap();
let n = 500;
let value = make_value(2048);
let mut keys = Vec::new();
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
for i in 0..n {
let key = make_key(i as u64);
keys.push(key);
engine
.write("alice", key, &value, Codec::Zstd)
.unwrap();
}
} // crash
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
for (i, key) in keys.iter().enumerate() {
let result = engine.read("alice", key).unwrap();
assert_eq!(result, Some(value.clone()), "missing key at index {}", i);
}
}
}
#[test]
fn test_durability_delete_survives_crash() {
let dir = TempDir::new().unwrap();
let key = make_key(99);
let value = make_value(4096);
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
engine
.write("alice", key, &value, Codec::Zstd)
.unwrap();
} // crash after write
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.delete("alice", &key).unwrap();
} // crash after delete
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
let result = engine.read("alice", &key).unwrap();
assert_eq!(result, None, "delete should persist across crash");
}
}
// ---------------------------------------------------------------------------
// 2. Atomicity: no partial writes visible after crash
// ---------------------------------------------------------------------------
#[test]
fn test_atomicity_no_partial_entries_after_crash() {
let dir = TempDir::new().unwrap();
// Write enough entries to fill part of a segment, then crash
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
let value = make_value(50_000); // big enough to notice
for i in 0..200u64 {
engine
.write("alice", make_key(i), &value, Codec::None)
.unwrap();
}
} // crash
// Recovery should clean up any partial tail entries and all committed
// entries should be readable
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
let value = make_value(50_000);
for i in 0..200u64 {
let result = engine.read("alice", &make_key(i)).unwrap();
assert_eq!(
result,
Some(value.clone()),
"committed key {} should be intact",
i
);
}
}
}
#[test]
fn test_atomicity_crash_during_segment_roll() {
let dir = TempDir::new().unwrap();
let big_value = make_value(2 * 1024 * 1024); // 2 MB each entry
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
// Write enough to cross at least one segment boundary (256 MB)
for i in 0..140u64 {
engine
.write("alice", make_key(i), &big_value, Codec::None)
.unwrap();
}
} // crash mid-way or after multiple segments
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
// All committed writes (that returned Ok) must be readable
for i in 0..140u64 {
let result = engine.read("alice", &make_key(i)).unwrap();
assert!(
result.is_some(),
"key {} should exist after segment roll recovery",
i
);
}
}
}
// ---------------------------------------------------------------------------
// 3. Consistency: CRC detects corruption, no silent data loss
// ---------------------------------------------------------------------------
#[test]
fn test_consistency_crc_detects_corruption() {
let dir = TempDir::new().unwrap();
let key = make_key(77);
let value = make_value(8192);
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
engine
.write("alice", key, &value, Codec::Zstd)
.unwrap();
}
// Corrupt the segment file by flipping a byte
let seg_path = find_first_segment(dir.path(), "alice");
let mut data = fs::read(&seg_path).unwrap();
// Flip a byte in the data portion, not the header
let flip_pos = data.len() - 100;
data[flip_pos] ^= 0xFF;
fs::write(&seg_path, &data).unwrap();
// Reading should detect CRC mismatch
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
let result = engine.read("alice", &key);
// Either error or None is acceptable — never silently wrong data
match result {
Err(_) => {} // CRC mismatch detected — good
Ok(None) => {} // index may point to truncated/removed data
Ok(Some(v)) => {
if v == value {
panic!("CRC corruption was NOT detected — silent data corruption!");
}
// If value differs, index pointed elsewhere after recovery
}
}
}
}
#[test]
fn test_consistency_corrupt_magic_truncated_on_recovery() {
let dir = TempDir::new().unwrap();
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
for i in 0..10u64 {
engine
.write("alice", make_key(i), &make_value(4096), Codec::Zstd)
.unwrap();
}
}
// Append garbage to the segment file (simulating partial write from crash)
let seg_path = find_first_segment(dir.path(), "alice");
let mut data = fs::read(&seg_path).unwrap();
let orig_len = data.len();
// Append garbage that doesn't start with the magic number
data.extend_from_slice(&[0xFF; 200]);
fs::write(&seg_path, &data).unwrap();
// Recovery should truncate the garbage
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
// Verify committed data is still intact
for i in 0..10u64 {
let result = engine.read("alice", &make_key(i)).unwrap();
assert!(result.is_some(), "committed key {} should survive tail truncation", i);
}
}
// Verify file was actually truncated
let truncated_len = fs::metadata(&seg_path).unwrap().len();
assert!(truncated_len <= orig_len as u64, "garbage should have been truncated");
}
// ---------------------------------------------------------------------------
// 4. Isolation: concurrent reader sees consistent snapshot
// ---------------------------------------------------------------------------
#[test]
fn test_isolation_reader_sees_snapshot_not_partial_write() {
let dir = TempDir::new().unwrap();
let engine = Arc::new(Engine::open(dir.path(), Config::default()).unwrap());
engine.create_account("alice").unwrap();
// Pre-populate a known key
let original_value = make_value(4096);
let key = make_key(100);
engine
.write("alice", key, &original_value, Codec::Zstd)
.unwrap();
let running = Arc::new(AtomicBool::new(true));
let writer_done = Arc::new(AtomicBool::new(false));
// Spawn a writer that continuously overwrites the same key
let writer_engine = engine.clone();
let writer_running = running.clone();
let writer_done_flag = writer_done.clone();
let writer_key = key;
let writer = thread::spawn(move || {
for i in 0..1000u64 {
if !writer_running.load(Ordering::Relaxed) {
break;
}
let val = make_value(4096 + (i as usize % 100));
writer_engine
.write("alice", writer_key, &val, Codec::Zstd)
.unwrap();
thread::yield_now();
}
writer_done_flag.store(true, Ordering::SeqCst);
});
// Concurrent reader: reads should never panic or hang
let reader_engine = engine.clone();
let reader_running = running.clone();
let reader = thread::spawn(move || {
let mut reads = 0;
while reads < 500 {
if !reader_running.load(Ordering::Relaxed) && reads > 0 {
break;
}
let result = reader_engine.read("alice", &key);
match result {
Ok(Some(_)) | Ok(None) => {} // OK
Err(e) => {
// Accept transient errors but report them
eprintln!("reader saw error: {:?}", e);
}
}
reads += 1;
thread::yield_now();
}
});
reader.join().unwrap();
running.store(false, Ordering::SeqCst);
writer.join().unwrap();
// Final read should see the last committed value (not partial)
let final_result = engine.read("alice", &key).unwrap();
assert!(final_result.is_some(), "final read should find a value");
}
// ---------------------------------------------------------------------------
// 5. Crash during GC: old data intact, no corruption
// ---------------------------------------------------------------------------
#[test]
fn test_crash_during_gc_leaves_data_intact() {
let dir = TempDir::new().unwrap();
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
let value = make_value(500_000); // 500 KB each
// Write enough entries and delete some to create GC candidate
for i in 0..500u64 {
engine
.write("alice", make_key(i), &value, Codec::None)
.unwrap();
}
// Delete ~40%
for i in (0..500u64).step_by(5) {
engine.delete("alice", &make_key(i)).unwrap();
}
// Single GC run (may or may not trigger)
let _ = engine.gc("alice");
} // crash after GC
// All non-deleted entries must still be readable
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
let value = make_value(500_000);
for i in 0..500u64 {
let key = make_key(i);
let result = engine.read("alice", &key).unwrap();
if i % 5 == 0 {
// Deleted keys
assert_eq!(result, None, "key {} should be deleted", i);
} else {
assert_eq!(
result,
Some(value.clone()),
"key {} should survive GC+crash",
i
);
}
}
}
}
// ---------------------------------------------------------------------------
// 6. Multiple crash-reopen cycles (torture test)
// ---------------------------------------------------------------------------
#[test]
fn test_multiple_crash_reopen_cycles() {
use std::collections::HashSet;
let dir = TempDir::new().unwrap();
let value = make_value(4096);
let mut alive: HashSet<u64> = HashSet::new();
// Populate and crash
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
for i in 0..50u64 {
engine
.write("alice", make_key(i), &value, Codec::Zstd)
.unwrap();
alive.insert(i);
}
}
// Reopen, verify all exist, write more, crash
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
for &k in &alive {
assert!(engine.read("alice", &make_key(k)).unwrap().is_some());
}
for i in 100..150u64 {
engine
.write("alice", make_key(i), &value, Codec::Zstd)
.unwrap();
alive.insert(i);
}
}
// Reopen, verify all exist, delete some, crash
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
for &k in &alive {
assert!(engine.read("alice", &make_key(k)).unwrap().is_some());
}
for i in 0..10u64 {
engine.delete("alice", &make_key(i)).unwrap();
alive.remove(&i);
}
}
// Final reopen: survivors exist, deleted gone
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
for &k in &alive {
assert!(engine.read("alice", &make_key(k)).unwrap().is_some(),
"key {} should exist", k);
}
for i in 0..10u64 {
assert_eq!(engine.read("alice", &make_key(i)).unwrap(), None,
"key {} should be deleted", i);
}
}
}
// ---------------------------------------------------------------------------
// 7. Account-level isolation
// ---------------------------------------------------------------------------
#[test]
fn test_account_isolation_crash_one_account_does_not_affect_others() {
let dir = TempDir::new().unwrap();
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
engine.create_account("bob").unwrap();
engine
.write("alice", make_key(1), &make_value(4096), Codec::Zstd)
.unwrap();
engine
.write("bob", make_key(1), &make_value(8192), Codec::Zstd)
.unwrap();
}
// Delete alice's account dir partially to simulate corruption
// Then verify bob is intact
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
// Bob should be fine
let result = engine.read("bob", &make_key(1)).unwrap();
assert!(result.is_some(), "bob should be unaffected");
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn find_first_segment(store_root: &Path, account: &str) -> std::path::PathBuf {
let seg_dir = store_root.join("accounts").join(account).join("segments");
for entry in fs::read_dir(&seg_dir).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(".seg") && !name.contains("temp_") {
return entry.path();
}
}
panic!("no segment found in {:?}", seg_dir);
}

View File

@@ -0,0 +1,497 @@
use bichon_blob::{Codec, Config, Engine};
use tempfile::TempDir;
#[test]
fn test_create_and_list_accounts() {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
engine.create_account("bob").unwrap();
let accounts = engine.list_accounts();
assert!(accounts.contains(&"alice".to_string()));
assert!(accounts.contains(&"bob".to_string()));
}
#[test]
fn test_write_and_read() {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
let key = [0xAA; 32];
let value = b"Hello, this is a test email!".to_vec();
engine
.write("alice", key, &value, Codec::Zstd)
.unwrap();
let result = engine.read("alice", &key).unwrap();
assert_eq!(result, Some(value));
}
#[test]
fn test_read_missing_key() {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
let key = [0xFF; 32];
let result = engine.read("alice", &key).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_delete() {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
let key = [0xBB; 32];
let value = b"Some email content".to_vec();
engine
.write("alice", key, &value, Codec::Zstd)
.unwrap();
engine.delete("alice", &key).unwrap();
let result = engine.read("alice", &key).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_delete_account() {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
engine.delete_account("alice").unwrap();
let accounts = engine.list_accounts();
assert!(!accounts.contains(&"alice".to_string()));
}
#[test]
fn test_small_value_not_compressed() {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
let key = [0xCC; 32];
let value = b"hi"; // Smaller than 4KB threshold
engine
.write("alice", key, value, Codec::Zstd)
.unwrap();
let result = engine.read("alice", &key).unwrap();
assert_eq!(result, Some(value.to_vec()));
}
#[test]
fn test_large_value() {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
let key = [0xDD; 32];
let value = vec![b'X'; 100_000]; // 100KB
engine
.write("alice", key, &value, Codec::Zstd)
.unwrap();
let result = engine.read("alice", &key).unwrap();
assert_eq!(result, Some(value));
}
#[test]
fn test_multiple_keys() {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
let n = 100;
for i in 0..n {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&(i as u32).to_le_bytes());
let value = format!("email number {}", i).into_bytes();
engine
.write("alice", key, &value, Codec::Zstd)
.unwrap();
}
for i in 0..n {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&(i as u32).to_le_bytes());
let result = engine.read("alice", &key).unwrap();
assert_eq!(result, Some(format!("email number {}", i).into_bytes()));
}
}
#[test]
fn test_gc() {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
// Write many entries
let value = vec![b'Y'; 5000];
let n = 100;
for i in 0..n {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&(i as u32).to_le_bytes());
engine
.write("alice", key, &value, Codec::None)
.unwrap();
}
// Delete even-numbered keys
for i in (0..n).step_by(2) {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&(i as u32).to_le_bytes());
engine.delete("alice", &key).unwrap();
}
// Run GC
let _result = engine.gc("alice").unwrap();
// Verify remaining keys still readable
for i in (1..n).step_by(2) {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&(i as u32).to_le_bytes());
let result = engine.read("alice", &key).unwrap();
assert_eq!(result, Some(value.clone()));
}
// Deleted keys should not exist
for i in (0..n).step_by(2) {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&(i as u32).to_le_bytes());
let result = engine.read("alice", &key).unwrap();
assert_eq!(result, None);
}
}
#[test]
fn test_reopen_persistence() {
let dir = TempDir::new().unwrap();
let key = [0xEE; 32];
let value = b"persistent data".to_vec();
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
engine
.write("alice", key, &value, Codec::Zstd)
.unwrap();
}
// Reopen
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
let result = engine.read("alice", &key).unwrap();
assert_eq!(result, Some(value));
}
}
#[test]
fn test_stats() {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
engine
.write("alice", [1u8; 32], b"hello", Codec::None)
.unwrap();
let stats = engine.stats("alice").unwrap();
assert!(stats.total_bytes > 0);
}
#[test]
fn test_batch_write() {
let dir = TempDir::new().unwrap();
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
let n = 50;
let entries: Vec<_> = (0..n)
.map(|i: u64| {
let mut key = [0u8; 32];
key[0..8].copy_from_slice(&i.to_le_bytes());
let value = format!("batch email {}", i).into_bytes();
(key, value, Codec::Zstd)
})
.collect();
engine.write_batch("alice", &entries).unwrap();
for (key, value, _) in &entries {
let result = engine.read("alice", key).unwrap();
assert_eq!(result.as_ref(), Some(value));
}
}
#[test]
fn test_batch_write_persistence() {
let dir = TempDir::new().unwrap();
let entries: Vec<_> = (0..30u64)
.map(|i| {
let mut key = [0u8; 32];
key[0..8].copy_from_slice(&i.to_le_bytes());
(key, format!("persist {}", i).into_bytes(), Codec::Zstd)
})
.collect();
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
engine.create_account("alice").unwrap();
engine.write_batch("alice", &entries).unwrap();
}
{
let engine = Engine::open(dir.path(), Config::default()).unwrap();
for (key, value, _) in &entries {
let result = engine.read("alice", key).unwrap();
assert_eq!(result.as_ref(), Some(value));
}
}
}
#[test]
fn test_invalid_config_rejected() {
let dir = TempDir::new().unwrap();
let mut config = Config::default();
config.lru_bucket_count = 0;
assert!(Engine::open(dir.path(), config).is_err());
let mut config = Config::default();
config.gc_deleted_ratio = 1.5;
assert!(Engine::open(dir.path(), config).is_err());
}
#[test]
fn test_concurrent_reads() {
use std::sync::Arc;
use std::thread;
let dir = TempDir::new().unwrap();
let engine = Arc::new(Engine::open(dir.path(), Config::default()).unwrap());
engine.create_account("alice").unwrap();
// Write some data
for i in 0..50u32 {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&i.to_le_bytes());
engine.write("alice", key, &vec![i as u8; 1024], Codec::None).unwrap();
}
// Spawn 4 threads, each reading a different subset
let mut handles = vec![];
for t in 0..4 {
let engine = engine.clone();
handles.push(thread::spawn(move || {
for i in (t * 12)..((t + 1) * 12) {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&(i as u32).to_le_bytes());
let read = engine.read("alice", &key).unwrap();
assert!(read.is_some(), "key {} should exist", i);
}
}));
}
for h in handles {
h.join().unwrap();
}
}
#[test]
fn test_concurrent_writes_different_accounts() {
use std::sync::Arc;
use std::thread;
let dir = TempDir::new().unwrap();
let engine = Arc::new(Engine::open(dir.path(), Config::default()).unwrap());
for name in &["alice", "bob", "carol"] {
engine.create_account(name).unwrap();
}
let mut handles = vec![];
for (t, name) in ["alice", "bob", "carol"].iter().enumerate() {
let engine = engine.clone();
let account_name = name.to_string();
handles.push(thread::spawn(move || {
for i in 0..20 {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&((t * 100 + i) as u32).to_le_bytes());
let value = vec![(t * 100 + i) as u8; 512];
engine.write(&account_name, key, &value, Codec::None).unwrap();
}
}));
}
for h in handles {
h.join().unwrap();
}
// Verify all writes persisted
for (t, name) in ["alice", "bob", "carol"].iter().enumerate() {
for i in 0..20 {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&((t * 100 + i) as u32).to_le_bytes());
let read = engine.read(name, &key).unwrap();
assert!(read.is_some(), "account {} key {} should exist", name, i);
}
}
}
#[test]
fn test_crash_recovery() {
let dir = TempDir::new().unwrap();
let dir_path = dir.path().to_path_buf();
// Phase 1: write data, then drop without shutdown (simulates crash)
{
let engine = Engine::open(&dir_path, Config::default()).unwrap();
engine.create_account("alice").unwrap();
for i in 0..50u32 {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&i.to_le_bytes());
engine.write("alice", key, &vec![i as u8; 512], Codec::None).unwrap();
}
// Engine dropped here without calling shutdown()
}
// Phase 2: reopen — recovery should run, data should be intact
let engine = Engine::open(&dir_path, Config::default()).unwrap();
let stats = engine.stats("alice").unwrap();
assert!(stats.total_keys > 0, "recovery should preserve data");
// Verify reads work
for i in 0..50u32 {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&i.to_le_bytes());
let read = engine.read("alice", &key).unwrap();
assert!(read.is_some(), "key {} should survive crash recovery", i);
}
}
#[test]
fn test_meta_bin_durability() {
// Verify meta.bin has valid CRC and can be read after a write cycle.
let dir = TempDir::new().unwrap();
let dir_path = dir.path().to_path_buf();
{
let engine = Engine::open(&dir_path, Config::default()).unwrap();
engine.create_account("alice").unwrap();
let key = [0x42u8; 32];
engine.write("alice", key, b"durable", Codec::None).unwrap();
}
// Engine dropped → shutdown() called → meta saved via write_bin (with fsync)
// Verify meta.bin exists and has valid CRC
let meta_path = dir_path
.join("accounts")
.join("alice")
.join("meta.bin");
assert!(meta_path.exists(), "meta.bin should exist after clean shutdown");
let data = std::fs::read(&meta_path).unwrap();
assert!(data.len() >= 8, "meta.bin should have at least 8 bytes (crc + version)");
let stored_crc = u32::from_le_bytes(data[0..4].try_into().unwrap());
assert_ne!(stored_crc, 0, "stored CRC should be non-zero");
// Reopen and verify data is intact
let engine = Engine::open(&dir_path, Config::default()).unwrap();
let read = engine.read("alice", &[0x42u8; 32]).unwrap();
assert_eq!(read, Some(b"durable".to_vec()));
}
#[test]
fn test_gc_concurrent_with_writes() {
// GC should not lose entries that are written concurrently.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
let dir = TempDir::new().unwrap();
let engine = Arc::new(Engine::open(dir.path(), Config::default()).unwrap());
engine.create_account("alice").unwrap();
// Pre-fill: write enough to trigger eventual GC
let big_value = vec![b'X'; 8192];
for i in 0..500u32 {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&i.to_le_bytes());
engine.write("alice", key, &big_value, Codec::None).unwrap();
}
// Delete some to create GC candidates
for i in 0..250u32 {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&i.to_le_bytes());
engine.delete("alice", &key).unwrap();
}
let running = Arc::new(AtomicBool::new(true));
let engine_gc = engine.clone();
let running_gc = running.clone();
// Thread 1: run GC in a loop
let gc_handle = thread::spawn(move || {
while running_gc.load(Ordering::Relaxed) {
let _ = engine_gc.gc("alice");
thread::sleep(std::time::Duration::from_millis(10));
}
});
// Thread 2: keep writing new entries
let engine_write = engine.clone();
let running_write = running.clone();
let write_handle = thread::spawn(move || {
let mut counter = 10000u32;
while running_write.load(Ordering::Relaxed) {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&counter.to_le_bytes());
engine_write
.write("alice", key, &vec![counter as u8; 256], Codec::None)
.unwrap();
counter += 1;
}
counter
});
// Let them race for a bit
thread::sleep(std::time::Duration::from_millis(500));
running.store(false, Ordering::Relaxed);
gc_handle.join().unwrap();
let final_counter = write_handle.join().unwrap();
// All written entries must be readable
let mut missing = 0;
for i in 0..500u32 {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&i.to_le_bytes());
if engine.read("alice", &key).unwrap().is_none() {
// Entries 0..250 were deleted, they should be gone
if i >= 250 {
missing += 1;
}
}
}
assert_eq!(missing, 0, "pre-existing entries should survive concurrent GC");
// Entries written during the race should be readable
for i in 10000..final_counter {
let mut key = [0u8; 32];
key[0..4].copy_from_slice(&i.to_le_bytes());
let read = engine.read("alice", &key).unwrap();
assert!(read.is_some(), "concurrently written key {} should exist after GC", i);
}
}

View File

@@ -16,12 +16,8 @@ reqwest.workspace = true
toml = "0.9.8"
memmap2 = "0.9.10"
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
compressed-rtf = "1.0.1"
chrono.workspace = true
mail-send.workspace = true
base64.workspace = true
codepage-strings = "1.0.2"
hex = "0.4.3"
sysinfo.workspace = true
indicatif.workspace = true
serde_json.workspace = true

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

@@ -60,3 +60,75 @@ pub fn determine_folder(labels_raw: &str) -> String {
}
}
}
#[cfg(test)]
mod tests {
use mail_parser::{HeaderValue, MessageParser};
use super::*;
fn parse_x_gmail_labels(raw_message: &[u8]) -> Option<String> {
// MessageParser::new() has an empty header_map so the hardcoded match at
// parsers/header.rs:76 treats ALL unknown headers as raw (no RFC 2047
// decoding). We need three things to get decoding:
// 1. A non-empty header_map (so the else branch runs)
// 2. default_header_text() so the fallback fn is parse_unstructured
// 3. OR register X-Gmail-Labels explicitly via header_text()
let message = MessageParser::new()
.with_minimal_headers()
.default_header_text()
.parse(raw_message)?;
let value: &HeaderValue<'_> = message.header("X-Gmail-Labels")?;
value.as_text().map(|s| s.to_string())
}
/// Construct a raw MIME message with RFC 2047 encoded X-Gmail-Labels,
/// parse it, and verify the header is correctly decoded.
fn build_email(x_gmail_labels: &str) -> Vec<u8> {
format!(
"From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: Test\r\n\
X-Gmail-Labels: {}\r\n\
\r\n\
Body text here.\r\n",
x_gmail_labels
)
.into_bytes()
}
#[test]
fn rfc2047_encoded_labels_are_decoded() {
// Exactly the format the user reported: French Gmail labels
let raw = build_email("=?UTF-8?Q?Corbeille?=, =?UTF-8?Q?Messages_archiv=C3=A9s?=");
let labels = parse_x_gmail_labels(&raw).expect("failed to parse X-Gmail-Labels");
// mail-parser decodes RFC 2047 header values during initial parsing.
// The decoded text should NOT contain raw =?UTF-8?Q?... sequences.
assert!(!labels.contains("=?UTF-8"), "labels still encoded: {labels:?}");
assert!(labels.contains("Corbeille"), "missing 'Corbeille': {labels:?}");
assert!(
labels.contains("archivés"),
"missing decoded 'archivés': {labels:?}",
);
// Full pipeline: decoded labels → determine_folder
let folder = determine_folder(&labels);
assert_eq!(folder, "Corbeille");
}
#[test]
fn plain_ascii_labels_passthrough() {
let raw = build_email("Inbox, Important");
let labels = parse_x_gmail_labels(&raw).expect("failed to parse X-Gmail-Labels");
assert_eq!(labels, "Inbox, Important");
assert_eq!(determine_folder(&labels), "Important");
}
#[test]
fn missing_x_gmail_labels_header() {
let raw = b"From: sender@example.com\r\nTo: r@example.com\r\n\r\nBody.\r\n";
let message = MessageParser::new().parse(raw.as_slice()).unwrap();
assert!(message.header("X-Gmail-Labels").is_none());
}
}

View File

@@ -21,14 +21,13 @@ use std::path::PathBuf;
use crate::api::sender::send_batch_request;
use crate::mbox::gmail::determine_folder;
use crate::mbox::reader::MboxFile;
use bichon_core::import::reader::MboxFile;
use crate::BichonCliConfig;
use bichon_core::base64_encode_url_safe;
use bichon_core::envelope::meta::{parse_bichon_metadata, BichonMetadata};
use console::style;
use dialoguer::{theme::ColorfulTheme, Input};
use dialoguer::{Confirm, Select};
use mail_parser::parsers::MessageStream;
use mail_parser::MessageParser;
use reqwest::Client;
@@ -38,7 +37,6 @@ const MAX_EMAIL_BYTES: usize = 100 * 1024 * 1024;
const MAX_BUFFER_BYTES: usize = 200 * 1024 * 1024;
pub mod gmail;
pub mod reader;
pub async fn handle_mbox_single_file_import(
config: &BichonCliConfig,
@@ -161,7 +159,11 @@ pub async fn run_import(
continue;
}
let message = match MessageParser::new().parse(body) {
let message = match MessageParser::new()
.with_minimal_headers()
.default_header_text()
.parse(body)
{
Some(msg) => msg,
None => {
eprintln!(
@@ -181,15 +183,12 @@ pub async fn run_import(
}
let get_default_folder = || {
let gmail_labels = message.header_raw("X-Gmail-Labels").unwrap_or("INBOX");
let text_cow = MessageStream::new(gmail_labels.as_bytes())
.parse_unstructured()
.into_text();
let data: &str = match &text_cow {
Some(c) => c.as_ref(),
None => "INBOX",
};
determine_folder(data)
let labels = message
.header("X-Gmail-Labels")
.and_then(|h| h.as_text())
.map(|s| s.to_string())
.unwrap_or_else(|| "INBOX".to_string());
determine_folder(&labels)
};
let folder_name = if let Some(ref folder) = target_folder {

View File

@@ -16,21 +16,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use chrono::{DateTime, TimeZone, Utc};
use dialoguer::theme::ColorfulTheme;
use dialoguer::Input;
use mail_send::mail_builder::headers::text::Text;
use mail_send::mail_builder::MessageBuilder;
use outlook_pst::ltp::prop_context::PropertyValue;
use crate::api::sender::send_batch_request;
use crate::pst::encoding::decode_subject;
use crate::BichonCliConfig;
use bichon_core::base64_encode_url_safe;
use dialoguer::Confirm;
use outlook_pst::messaging::attachment::AttachmentProperties;
use bichon_core::import::pst::build_eml_base64;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Confirm, Input};
use outlook_pst::messaging::folder::Folder;
use outlook_pst::messaging::message::{Message, MessageProperties};
use outlook_pst::ndb::node_id::NodeId;
use reqwest::Client;
use std::future::Future;
@@ -38,28 +29,6 @@ use std::path::PathBuf;
use std::pin::Pin;
use std::rc::Rc;
mod encoding;
#[derive(Debug, Default)]
pub struct EmailMetadata {
pub message_id: Option<String>,
pub subject: Option<String>,
pub from: Option<String>,
pub to: Option<Vec<String>>,
pub cc: Option<Vec<String>>,
pub bcc: Option<Vec<String>>,
pub html: Option<String>,
pub text: Option<String>,
pub in_reply_to: Option<String>,
}
#[derive(Debug, Default)]
pub struct EmailAttachment {
pub name: Option<String>,
pub mime_type: Option<String>,
pub data: Option<Vec<u8>>,
}
pub async fn handle_pst_import(config: &BichonCliConfig, account_id: u64, theme: &ColorfulTheme) {
let path_str: String = Input::with_theme(theme)
.with_prompt("Enter the path to your SINGLE .pst file")
@@ -244,167 +213,6 @@ fn process_folder_recursively<'a>(
})
}
fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
let properties = message.properties();
let mut builder = MessageBuilder::new();
if let Some(sub) = extract_subject(properties) {
builder = builder.subject(sub);
}
if let Some(mid) = extract_string_property(properties, 0x1035) {
builder = builder.message_id(mid);
}
if let Some(irt) = extract_string_property(properties, 0x1042) {
builder = builder.in_reply_to(irt);
}
if let Some(refs) = extract_string_property(properties, 0x1039) {
builder = builder.header("References", Text::new(refs));
}
if let Some(cid_val) = properties.get(0x3013) {
if let PropertyValue::Binary(bin) = cid_val {
builder = builder.header(
"X-Bichon-Conversation-ID",
Text::new(hex::encode(bin.buffer())),
);
}
}
let from = extract_string_property(properties, 0x5D01)
.or_else(|| extract_string_property(properties, 0x5D02))
.or_else(|| extract_string_property(properties, 0x0C1F));
if let Some(f) = from {
builder = builder.from(f);
}
if let Some(filetime) = extract_i64_property(properties, &[0x0039, 0x0E06]) {
let dt = filetime_to_datetime(filetime).timestamp();
builder = builder.date(dt);
}
let (to, cc, bcc) = extract_recipients_list(&message);
if !to.is_empty() {
builder = builder.to(to.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if !cc.is_empty() {
builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if !bcc.is_empty() {
builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if let Some(html) = extract_html(properties) {
builder = builder.html_body(html);
}
if let Some(text) = extract_text(properties) {
builder = builder.text_body(text);
}
if let Some(attachment_table) = message.attachment_table() {
for row in attachment_table.rows_matrix() {
let node_id = NodeId::from(u32::from(row.id()));
if let Ok(attachment) = message.clone().read_attachment(node_id, None) {
let att_props = attachment.properties();
let name = extract_attachment_string_property(att_props, 0x3707);
let mime = extract_attachment_string_property(att_props, 0x370E)
.unwrap_or_else(|| "application/octet-stream".into());
let cid = extract_attachment_string_property(att_props, 0x3712);
let is_inline = att_props
.get(0x3714)
.and_then(|val| {
if let PropertyValue::Integer32(f) = val {
Some(f)
} else {
None
}
})
.map(|flag| (flag & 0x4) != 0)
.unwrap_or(false);
if let Some(PropertyValue::Binary(bin)) = att_props.get(0x3701) {
let data = bin.buffer().to_vec();
let file_name = name.unwrap_or_else(|| "unnamed_attachment".to_string());
if is_inline && cid.is_some() {
let content_id = cid.unwrap();
builder = builder.inline(mime, content_id, data);
} else {
builder = builder.attachment(mime, file_name, data);
}
}
}
}
}
match builder.write_to_vec() {
Ok(eml_vec) => Some(base64_encode_url_safe!(eml_vec)),
Err(e) => {
eprintln!("Failed to generate EML: {:?}", e);
None
}
}
}
fn filetime_to_datetime(filetime: i64) -> DateTime<Utc> {
let unix_secs = (filetime / 10_000_000) - 11_644_473_600;
let nsecs = (filetime % 10_000_000) * 100;
Utc.timestamp_opt(unix_secs, nsecs as u32).unwrap()
}
fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<String>, Vec<String>) {
let mut to = Vec::new();
let mut cc = Vec::new();
let mut bcc = Vec::new();
let recipient_table = message.recipient_table();
if let Some(recipient_table) = recipient_table {
let context = recipient_table.context();
for row in recipient_table.rows_matrix() {
if let Ok(cols) = row.columns(context) {
let mut r_type = 0;
let mut email = String::new();
for (col, val) in context.columns().iter().zip(cols) {
let prop_val = val
.as_ref()
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
match col.prop_id() {
0x0C15 => {
if let Some(PropertyValue::Integer32(t)) = prop_val {
r_type = t;
}
}
0x39FE | 0x3003 => {
if let Some(s) = prop_val.and_then(|v| extract_string(&v)) {
email = s;
}
}
_ => {}
}
}
if !email.is_empty() {
match r_type {
1 => to.push(email),
2 => cc.push(email),
3 => bcc.push(email),
_ => {}
}
}
}
}
} else {
let receiver = extract_string_property(message.properties(), 0x0076);
if let Some(receiver) = receiver {
to.push(receiver);
}
}
(to, cc, bcc)
}
async fn send_to_bichon(
client: &Client,
config: &BichonCliConfig,
@@ -414,69 +222,3 @@ async fn send_to_bichon(
) {
send_batch_request(client, config, account_id, folder_path, emls).await;
}
fn extract_subject(props: &MessageProperties) -> Option<String> {
props.get(0x0037).and_then(|val| decode_subject(val))
}
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
properties
.get(prop_id)
.and_then(|value| extract_string(value))
}
fn extract_attachment_string_property(
properties: &AttachmentProperties,
prop_id: u16,
) -> Option<String> {
properties
.get(prop_id)
.and_then(|value| extract_string(value))
}
fn extract_string(value: &PropertyValue) -> Option<String> {
match value {
PropertyValue::String8(value) => Some(value.to_string()),
PropertyValue::Unicode(value) => Some(value.to_string()),
_ => None,
}
}
fn extract_text(properties: &MessageProperties) -> Option<String> {
properties.get(0x1000).and_then(extract_string).or_else(|| {
properties.get(0x1009).and_then(|value| match value {
PropertyValue::Binary(value) => encoding::decode_rtf_compressed(value.buffer()),
_ => None,
})
})
}
fn extract_html(properties: &MessageProperties) -> Option<String> {
properties.get(0x1013).and_then(|value| match value {
PropertyValue::Binary(value) => {
let code_page = properties
.get(0x3FDE)
.and_then(|v| {
if let PropertyValue::Integer32(cpid) = v {
Some(*cpid as u16)
} else {
None
}
})
.unwrap_or(65001);
encoding::decode_html_body(value.buffer(), code_page)
}
PropertyValue::String8(value) => Some(value.to_string()),
PropertyValue::Unicode(value) => Some(value.to_string()),
_ => None,
})
}
fn extract_i64_property(properties: &MessageProperties, prop_ids: &[u16]) -> Option<i64> {
for &prop_id in prop_ids {
if let Some(PropertyValue::Time(value)) = properties.get(prop_id) {
return Some(*value);
}
}
None
}

View File

@@ -19,7 +19,7 @@ poem-openapi = { version = "5.1.16", features = [
], optional = true }
chrono.workspace = true
clap.workspace = true
memdb.workspace = true
bichon-memdb.workspace = true
itertools.workspace = true
ring.workspace = true
serde.workspace = true
@@ -69,5 +69,11 @@ tokio-util.workspace = true
whichlang = "0.1.1"
deunicode = "1.6.2"
scopeguard = "1.2.0"
cron = "0.15"
quick-xml = { version = "0.40.0", features = ["serialize"] }
hickory-resolver = "0.26.0-alpha.1"
memmap2 = "0.9.10"
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
compressed-rtf = "1.0.1"
codepage-strings = "1.0.2"
hex.workspace = true

View File

@@ -64,6 +64,216 @@ pub enum QuotaWindow {
Monthly,
}
/// Include/exclude filter rule.
///
/// - `include` non-empty: only values matching these patterns pass.
/// - `exclude` non-empty: values matching these patterns are rejected.
/// - Both empty: all values pass.
/// - Both set: include checked first, then exclude.
///
/// Extension patterns use case-insensitive exact match; all others use regex.
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct FilterRule {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
impl FilterRule {
pub fn is_empty(&self) -> bool {
self.include.is_empty() && self.exclude.is_empty()
}
fn matches_exact(&self, value: &str) -> bool {
if !self.include.is_empty() && !self.include.iter().any(|e| e.eq_ignore_ascii_case(value)) {
return false;
}
if !self.exclude.is_empty() && self.exclude.iter().any(|e| e.eq_ignore_ascii_case(value)) {
return false;
}
true
}
fn matches_regex(&self, value: &str) -> bool {
if !self.include.is_empty() && !matches_any_regex(&self.include, value) {
return false;
}
if !self.exclude.is_empty() && matches_any_regex(&self.exclude, value) {
return false;
}
true
}
fn validate_regex(&self, field: &str) -> Result<(), String> {
validate_patterns(&self.include, &format!("{field}.include"))?;
validate_patterns(&self.exclude, &format!("{field}.exclude"))?;
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct ExtractionRules {
/// Type 0: Master switch.
#[serde(default)]
pub enabled: bool,
/// Type 1: File extensions (exact match, e.g. `{"include": ["pdf","docx"]}`).
#[serde(default)]
pub extensions: FilterRule,
/// Type 2: Folder patterns (regex, e.g. `{"include": ["^INBOX/Invoices"]}`).
#[serde(default)]
pub folders: FilterRule,
/// Type 3: Attachment filename patterns (regex).
#[serde(default)]
pub attachment_names: FilterRule,
/// Type 4: Sender patterns (regex).
#[serde(default)]
pub senders: FilterRule,
}
impl ExtractionRules {
/// Returns `true` if the attachment should be extracted under these rules.
pub fn should_extract(
&self,
ext: &str,
folder: Option<&str>,
attachment_name: Option<&str>,
sender: Option<&str>,
) -> bool {
if !self.enabled {
return false;
}
if !self.extensions.matches_exact(ext) {
return false;
}
if !self.folders.is_empty() {
if let Some(folder) = folder {
if !self.folders.matches_regex(folder) {
return false;
}
}
}
if !self.attachment_names.is_empty() {
if let Some(name) = attachment_name {
if !self.attachment_names.matches_regex(name) {
return false;
}
}
}
if !self.senders.is_empty() {
if let Some(sender) = sender {
if !self.senders.matches_regex(sender) {
return false;
}
}
}
true
}
pub fn validate(&self) -> Result<(), String> {
self.folders.validate_regex("folders")?;
self.attachment_names.validate_regex("attachment_names")?;
self.senders.validate_regex("senders")?;
Ok(())
}
}
/// Archive filtering rules — skip unwanted emails before storage.
///
/// Rule types:
/// 0 — Master switch
/// 1 — Sender filter (regex)
/// 2 — Subject filter (regex)
/// 3 — Skip emails larger than this (bytes)
/// 4 — Skip emails with spam headers (X-Spam-Flag, X-Spam)
///
/// `None` = archive everything (backward compatible).
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct ArchiveRules {
/// Type 0: Master switch. `false` = archive everything.
#[serde(default)]
pub enabled: bool,
/// Type 1: Sender filter (regex, include/exclude).
#[serde(default)]
pub senders: FilterRule,
/// Type 2: Subject filter (regex, include/exclude).
#[serde(default)]
pub subjects: FilterRule,
/// Type 3: Skip emails larger than this (bytes). `None` = no size limit.
#[serde(default)]
pub skip_larger_than: Option<u64>,
/// Type 4: Spam header names to check (e.g. `["X-Spam-Flag", "X-Spam"]`).
/// When the value is `yes` or `true` (case-insensitive), the email is skipped.
/// Empty = don't check. Common headers: `X-Spam-Flag` (SpamAssassin),
/// `X-Spam` (rspamd), `X-MS-Exchange-Organization-SCL` (Exchange).
#[serde(default)]
pub spam_headers: Vec<String>,
}
impl ArchiveRules {
/// Returns `true` if the email should be archived under these rules.
pub fn should_archive(
&self,
sender: Option<&str>,
subject: Option<&str>,
size: u32,
is_spam: bool,
) -> bool {
if !self.enabled {
return true;
}
if !self.senders.is_empty() {
if let Some(sender) = sender {
if !self.senders.matches_regex(sender) {
return false;
}
}
}
if !self.subjects.is_empty() {
if let Some(subject) = subject {
if !self.subjects.matches_regex(subject) {
return false;
}
}
}
if let Some(limit) = self.skip_larger_than {
if size as u64 > limit {
return false;
}
}
if !self.spam_headers.is_empty() && is_spam {
return false;
}
true
}
/// Validate all regex patterns are well-formed.
pub fn validate(&self) -> Result<(), String> {
self.senders.validate_regex("senders")?;
self.subjects.validate_regex("subjects")?;
Ok(())
}
}
fn matches_any_regex(patterns: &[String], value: &str) -> bool {
patterns.iter().any(|p| {
regex::Regex::new(p)
.map(|re| re.is_match(value))
.unwrap_or(false)
})
}
fn validate_patterns(patterns: &[String], field_name: &str) -> Result<(), String> {
for p in patterns {
regex::Regex::new(p)
.map_err(|e| format!("{} pattern '{}' is invalid regex: {}", field_name, p, e))?;
}
Ok(())
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct Account {
@@ -80,11 +290,12 @@ pub struct Account {
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub download_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub download_interval_min: Option<i64>,
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 +306,17 @@ pub struct Account {
pub imap_quota_bytes: Option<u64>,
pub imap_quota_window: Option<QuotaWindow>,
pub auto_download_new_mailboxes: Option<bool>,
pub download_schedule: Option<String>,
#[serde(default)]
pub deleting: bool,
/// Email-level filtering rules (Pro feature).
/// `None` = archive everything (backward compatible).
#[serde(default)]
pub archive_rules: Option<ArchiveRules>,
/// Attachment text extraction rules (Pro feature).
/// `None` = extract everything (backward compatible).
#[serde(default)]
pub extraction_rules: Option<ExtractionRules>,
}
impl MemDbModel for Account {
@@ -124,15 +346,19 @@ impl Account {
created_at: utc_now!(),
updated_at: utc_now!(),
use_proxy: request.use_proxy,
folder_limit: request.folder_limit,
use_dangerous: request.use_dangerous,
pgp_key: request.pgp_key,
created_by: user_id,
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,
archive_rules: request.archive_rules,
extraction_rules: request.extraction_rules,
})
}
@@ -220,14 +446,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 +494,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)?;
@@ -324,6 +582,7 @@ impl Account {
.map(|account: AccountModel| MinimalAccount {
id: account.id,
email: account.email,
name: account.account_name,
})
.collect::<Vec<MinimalAccount>>();
Ok(result)
@@ -365,14 +624,8 @@ impl Account {
}
}
if let Some(folder_limit) = request.folder_limit {
new.folder_limit = Some(folder_limit);
}
if let Some(clear_folder_limit) = request.clear_folder_limit {
if clear_folder_limit {
new.folder_limit = None;
}
if let Some(account_name) = request.account_name {
new.account_name = Some(account_name);
}
if matches!(old.account_type, AccountType::IMAP) {
@@ -401,6 +654,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);
}
@@ -435,7 +692,305 @@ impl Account {
if let Some(auto_download_new_mailboxes) = request.auto_download_new_mailboxes {
new.auto_download_new_mailboxes = Some(auto_download_new_mailboxes);
}
if let Some(download_schedule) = request.download_schedule {
new.download_schedule = Some(download_schedule);
}
if request.clear_download_schedule == Some(true) {
new.download_schedule = None;
}
if request.extraction_rules.is_some() {
new.extraction_rules = request.extraction_rules;
}
if request.archive_rules.is_some() {
new.archive_rules = request.archive_rules;
}
new.updated_at = utc_now!();
Ok(new)
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── FilterRule ───────────────────────────────────────────────────
#[test]
fn filter_rule_include_only() {
let r = FilterRule {
include: vec![r"@ok\.com$".into()],
..Default::default()
};
assert!(r.matches_regex("bob@ok.com"));
assert!(!r.matches_regex("spam@bad.com"));
}
#[test]
fn filter_rule_exclude_only() {
let r = FilterRule {
exclude: vec![r"@spam\.com$".into()],
..Default::default()
};
assert!(r.matches_regex("bob@ok.com"));
assert!(!r.matches_regex("bot@spam.com"));
}
#[test]
fn filter_rule_include_then_exclude() {
let r = FilterRule {
include: vec![r"@company\.com$".into()],
exclude: vec![r"noreply@company\.com$".into()],
..Default::default()
};
assert!(r.matches_regex("bob@company.com"));
assert!(!r.matches_regex("noreply@company.com"));
assert!(!r.matches_regex("spam@other.com"));
}
#[test]
fn filter_rule_exact_match() {
let r = FilterRule {
include: vec!["pdf".into(), "docx".into()],
exclude: vec!["xlsx".into()],
..Default::default()
};
assert!(r.matches_exact("pdf"));
assert!(r.matches_exact("docx"));
assert!(r.matches_exact("DOCX")); // case-insensitive
assert!(!r.matches_exact("xlsx"));
assert!(!r.matches_exact("txt"));
}
// ── ExtractionRules ─────────────────────────────────────────────
#[test]
fn extraction_rules_master_switch() {
let rules = ExtractionRules {
enabled: false,
..Default::default()
};
assert!(!rules.should_extract("pdf", None, None, None));
}
#[test]
fn extraction_rules_extension_include() {
let rules = ExtractionRules {
enabled: true,
extensions: FilterRule {
include: vec!["pdf".into()],
..Default::default()
},
..Default::default()
};
assert!(rules.should_extract("pdf", None, None, None));
assert!(!rules.should_extract("docx", None, None, None));
}
#[test]
fn extraction_rules_extension_exclude() {
let rules = ExtractionRules {
enabled: true,
extensions: FilterRule {
exclude: vec!["xlsx".into(), "pptx".into()],
..Default::default()
},
..Default::default()
};
assert!(rules.should_extract("pdf", None, None, None));
assert!(!rules.should_extract("xlsx", None, None, None));
}
#[test]
fn extraction_rules_folder_regex() {
let rules = ExtractionRules {
enabled: true,
folders: FilterRule {
include: vec![r"^INBOX/Invoices".into(), r"Contracts$".into()],
..Default::default()
},
..Default::default()
};
assert!(rules.should_extract("pdf", Some("INBOX/Invoices"), None, None));
assert!(rules.should_extract("pdf", Some("Finance/Contracts"), None, None));
assert!(!rules.should_extract("pdf", Some("INBOX/Junk"), None, None));
}
#[test]
fn extraction_rules_attachment_name_regex() {
let rules = ExtractionRules {
enabled: true,
attachment_names: FilterRule {
include: vec![r"^invoice-.*\.pdf$".into()],
..Default::default()
},
..Default::default()
};
assert!(rules.should_extract("pdf", None, Some("invoice-2024.pdf"), None));
assert!(!rules.should_extract("pdf", None, Some("newsletter.pdf"), None));
}
#[test]
fn extraction_rules_sender_regex() {
let rules = ExtractionRules {
enabled: true,
senders: FilterRule {
exclude: vec![r"@noreply\.com$".into()],
..Default::default()
},
..Default::default()
};
// non-excluded sender passes
assert!(rules.should_extract("pdf", None, None, Some("bob@ok.com")));
// excluded sender blocked
assert!(!rules.should_extract("pdf", None, None, Some("bot@noreply.com")));
}
#[test]
fn extraction_rules_empty_filters_pass_everything() {
let rules = ExtractionRules {
enabled: true,
..Default::default()
};
assert!(rules.should_extract(
"anything",
Some("any/folder"),
Some("any.pdf"),
Some("any@x.com")
));
}
// ── ArchiveRules ────────────────────────────────────────────────
#[test]
fn archive_rules_disabled_archives_everything() {
let rules = ArchiveRules {
enabled: false,
..Default::default()
};
assert!(rules.should_archive(Some("spam@x.com"), Some("BUY NOW"), 999, false));
}
#[test]
fn archive_rules_sender_exclude() {
let rules = ArchiveRules {
enabled: true,
senders: FilterRule {
exclude: vec![r"@spam\.com$".into()],
..Default::default()
},
..Default::default()
};
assert!(!rules.should_archive(Some("bot@spam.com"), None, 100, false));
assert!(rules.should_archive(Some("friend@ok.com"), None, 100, false));
}
#[test]
fn archive_rules_subject_exclude() {
let rules = ArchiveRules {
enabled: true,
subjects: FilterRule {
exclude: vec![r"(?i)unsubscribe|buy now|limited offer".into()],
..Default::default()
},
..Default::default()
};
assert!(!rules.should_archive(None, Some("UNSUBSCRIBE NOW"), 100, false));
assert!(!rules.should_archive(None, Some("Limited Offer!!"), 100, false));
assert!(rules.should_archive(None, Some("Meeting tomorrow"), 100, false));
}
#[test]
fn archive_rules_sender_include() {
// Only archive emails from specific senders
let rules = ArchiveRules {
enabled: true,
senders: FilterRule {
include: vec![r"@partner\.com$".into()],
..Default::default()
},
..Default::default()
};
assert!(rules.should_archive(Some("bob@partner.com"), None, 100, false));
assert!(!rules.should_archive(Some("spam@random.com"), None, 100, false));
}
#[test]
fn archive_rules_skip_larger_than() {
let rules = ArchiveRules {
enabled: true,
skip_larger_than: Some(50_000_000),
..Default::default()
};
assert!(rules.should_archive(None, None, 1_000_000, false));
assert!(!rules.should_archive(None, None, 60_000_000, false));
}
#[test]
fn archive_rules_skip_spam_headers() {
let rules = ArchiveRules {
enabled: true,
spam_headers: vec!["X-Spam-Flag".into()],
..Default::default()
};
assert!(!rules.should_archive(None, None, 100, true));
assert!(rules.should_archive(None, None, 100, false));
}
// ── Validation ──────────────────────────────────────────────────
#[test]
fn validate_extraction_rules_valid() {
let rules = ExtractionRules {
folders: FilterRule {
include: vec![r"^INBOX/.*".into()],
..Default::default()
},
senders: FilterRule {
exclude: vec![r"@spam\.com$".into()],
..Default::default()
},
..Default::default()
};
assert!(rules.validate().is_ok());
}
#[test]
fn validate_extraction_rules_invalid_regex() {
let rules = ExtractionRules {
folders: FilterRule {
include: vec!["***bad[".into()],
..Default::default()
},
..Default::default()
};
assert!(rules.validate().is_err());
}
#[test]
fn validate_archive_rules_valid() {
let rules = ArchiveRules {
senders: FilterRule {
exclude: vec![r"@spam\.com$".into()],
..Default::default()
},
subjects: FilterRule {
include: vec![r"(?i)invoice".into()],
..Default::default()
},
..Default::default()
};
assert!(rules.validate().is_ok());
}
#[test]
fn validate_archive_rules_invalid_regex() {
let rules = ArchiveRules {
senders: FilterRule {
include: vec!["[unclosed".into()],
..Default::default()
},
..Default::default()
};
assert!(rules.validate().is_err());
}
}

View File

@@ -16,8 +16,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::str::FromStr;
use crate::account::entity::ImapConfig;
use crate::account::migration::{AccountModel, AccountType, QuotaWindow};
use crate::account::migration::{
AccountModel, AccountType, ArchiveRules, ExtractionRules, QuotaWindow,
};
use crate::account::since::{DateSince, RelativeDate};
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
@@ -39,8 +43,6 @@ pub struct AccountCreateRequest {
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub account_type: AccountType,
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "100"))))]
pub folder_limit: Option<u32>,
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "10"))))]
pub download_interval_min: Option<i64>,
#[cfg_attr(
@@ -48,12 +50,20 @@ 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>,
pub imap_quota_bytes: Option<u64>,
pub imap_quota_window: Option<QuotaWindow>,
pub auto_download_new_mailboxes: Option<bool>,
pub download_schedule: Option<String>,
/// Email archive filtering rules (Pro feature).
/// `None` = archive everything (backward compatible).
pub archive_rules: Option<ArchiveRules>,
/// Attachment text extraction rules (Pro feature).
/// `None` = extract everything (backward compatible).
pub extraction_rules: Option<ExtractionRules>,
}
impl AccountCreateRequest {
@@ -92,15 +102,31 @@ impl AccountCreateRequest {
))
}
}
if self.download_interval_min.is_none() {
if self.download_interval_min.is_none() && self.download_schedule.is_none() {
return Err(raise_error!(
"`sync_interval_min` is required for IMAP account type".into(),
"`sync_interval_min` or `download_schedule` is required for IMAP account type".into(),
ErrorCode::InvalidParameter
));
}
if let Some(ref schedule) = self.download_schedule {
validate_cron_expression(schedule)?;
}
}
AccountType::NoSync => {}
}
if let Some(ref rules) = self.extraction_rules {
rules.validate().map_err(|e| {
raise_error!(
format!("extraction_rules: {}", e),
ErrorCode::InvalidParameter
)
})?;
}
if let Some(ref rules) = self.archive_rules {
rules.validate().map_err(|e| {
raise_error!(format!("archive_rules: {}", e), ErrorCode::InvalidParameter)
})?;
}
Ok(AccountModel::new(user_id, self)?)
}
@@ -139,12 +165,6 @@ pub struct AccountUpdateRequest {
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub clear_date_range: Option<bool>,
/// Max emails to sync for this folder.
/// If not set, sync all emails.
/// otherwise sync up to `n` most recent emails (min 10).
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "100"))))]
pub folder_limit: Option<u32>,
pub clear_folder_limit: Option<bool>,
/// Configuration for selective folder (mailbox/label) synchronization
///
/// - For IMAP/SMTP accounts:
@@ -167,6 +187,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.
@@ -178,6 +199,14 @@ pub struct AccountUpdateRequest {
pub imap_quota_bytes: Option<u64>,
pub imap_quota_window: Option<QuotaWindow>,
pub auto_download_new_mailboxes: Option<bool>,
pub download_schedule: Option<String>,
pub clear_download_schedule: Option<bool>,
/// Email archive filtering rules (Pro feature).
/// `None` = no change. Use `Some(ArchiveRules { .. })` to set.
pub archive_rules: Option<ArchiveRules>,
/// Attachment text extraction rules (Pro feature).
/// `None` = no change. Use `Some(ExtractionRules { .. })` to set.
pub extraction_rules: Option<ExtractionRules>,
}
impl AccountUpdateRequest {
@@ -197,13 +226,6 @@ impl AccountUpdateRequest {
));
}
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
return Err(raise_error!(
"clear_folder_limit cannot be combined with folder_limit".into(),
ErrorCode::InvalidParameter
));
}
if self.clear_date_range == Some(true)
&& (self.date_since.is_some() || self.date_before.is_some())
{
@@ -230,17 +252,56 @@ impl AccountUpdateRequest {
));
}
}
if self.clear_download_schedule == Some(true) && self.download_schedule.is_some() {
return Err(raise_error!(
"clear_download_schedule cannot be combined with download_schedule".into(),
ErrorCode::InvalidParameter
));
}
if let Some(ref schedule) = self.download_schedule {
validate_cron_expression(schedule)?;
}
}
if let Some(ref rules) = self.extraction_rules {
rules.validate().map_err(|e| {
raise_error!(
format!("extraction_rules: {}", e),
ErrorCode::InvalidParameter
)
})?;
}
if let Some(ref rules) = self.archive_rules {
rules.validate().map_err(|e| {
raise_error!(format!("archive_rules: {}", e), ErrorCode::InvalidParameter)
})?;
}
Ok(())
}
}
fn validate_cron_expression(expr: &str) -> BichonResult<()> {
if expr.trim().is_empty() {
return Err(raise_error!(
"download_schedule must not be empty".into(),
ErrorCode::InvalidParameter
));
}
cron::Schedule::from_str(expr).map_err(|e| {
raise_error!(
format!("Invalid cron expression '{}': {}", expr, e),
ErrorCode::InvalidParameter
)
})?;
Ok(())
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct MinimalAccount {
pub id: u64,
pub email: String,
pub name: Option<String>,
}
pub fn filter_accessible_accounts<'a>(
@@ -253,3 +314,34 @@ pub fn filter_accessible_accounts<'a>(
.cloned()
.collect()
}
#[cfg(test)]
mod test {
use super::validate_cron_expression;
#[test]
fn valid_cron_expressions() {
assert!(validate_cron_expression("0 0 0 * * *").is_ok()); // daily at midnight
assert!(validate_cron_expression("0 */5 * * * *").is_ok()); // every 5 minutes
assert!(validate_cron_expression("0 0 12 * * 1-5").is_ok()); // weekdays at noon
assert!(validate_cron_expression("0 30 4 1 * *").is_ok()); // 1st of month at 04:30
assert!(validate_cron_expression("0 0 * * * *").is_ok()); // every hour
}
#[test]
fn invalid_cron_expression_too_few_fields() {
assert!(validate_cron_expression("0 0 * *").is_err());
assert!(validate_cron_expression("* * * * *").is_err()); // 5 fields, needs seconds
}
#[test]
fn invalid_cron_expression_empty() {
assert!(validate_cron_expression("").is_err());
assert!(validate_cron_expression(" ").is_err());
}
#[test]
fn invalid_cron_expression_garbage() {
assert!(validate_cron_expression("not a cron").is_err());
}
}

View File

@@ -364,4 +364,43 @@ mod test {
};
assert!(e.validate().is_err());
}
// ── Sliding window tests ──────────────────────────────────────
#[test]
fn relative_date_calculate_returns_valid_format() {
let r = RelativeDate {
unit: Unit::Years,
value: 1,
};
let date_str = r.calculate_date().unwrap();
// Expect format like "26-May-2025"
assert!(date_str.len() > 5);
assert!(date_str.contains('-'));
}
#[test]
fn relative_date_one_year_ago_is_before_now() {
let r = RelativeDate {
unit: Unit::Years,
value: 1,
};
let date_str = r.calculate_date().unwrap();
let parsed = chrono::NaiveDate::parse_from_str(&date_str, "%d-%b-%Y").unwrap();
let today = chrono::Local::now().date_naive();
assert!(parsed < today, "1 year ago ({parsed}) should be before today ({today})");
}
#[test]
fn relative_date_one_day_ago_is_yesterday() {
let r = RelativeDate {
unit: Unit::Days,
value: 1,
};
let date_str = r.calculate_date().unwrap();
let parsed = chrono::NaiveDate::parse_from_str(&date_str, "%d-%b-%Y").unwrap();
let today = chrono::Local::now().date_naive();
let yesterday = today - chrono::Duration::days(1);
assert_eq!(parsed, yesterday, "1 day ago should be yesterday");
}
}

View File

@@ -40,11 +40,11 @@ pub struct AccountResp {
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub download_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub download_interval_min: Option<i64>,
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 +57,8 @@ pub struct AccountResp {
pub imap_quota_bytes: Option<u64>,
pub imap_quota_window: Option<QuotaWindow>,
pub auto_download_new_mailboxes: Option<bool>,
pub download_schedule: Option<String>,
pub deleting: bool,
}
impl AccountResp {
@@ -72,11 +74,11 @@ impl AccountResp {
capabilities: account.capabilities,
date_since: account.date_since,
date_before: account.date_before,
folder_limit: account.folder_limit,
download_folders: account.download_folders,
account_type: account.account_type,
download_interval_min: account.download_interval_min,
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 +95,8 @@ impl AccountResp {
imap_quota_bytes: account.imap_quota_bytes,
imap_quota_window: account.imap_quota_window,
auto_download_new_mailboxes: account.auto_download_new_mailboxes,
download_schedule: account.download_schedule,
deleting: account.deleting,
}
}
}

View File

@@ -18,7 +18,7 @@
use std::path::Path;
use memdb::{Durability, MemDb};
use bichon_memdb::{Durability, MemDb};
use crate::{
database::MemDbModel,

View File

@@ -45,6 +45,10 @@ pub struct IncomingServer {
#[serde(rename = "socketType")]
pub socket_type: String,
pub username: String,
/// Authentication method from the XML, e.g. "OAuth2", "password-cleartext",
/// "password-encrypted", "GSSAPI", "NTLM". Absent in DNS SRV fallback.
#[serde(default)]
pub authentication: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
@@ -137,6 +141,7 @@ async fn lookup_srv(domain: &str) -> Option<MailConfig> {
port: imap_port,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![OutgoingServer {
protocol: "smtp".to_string(),
@@ -153,30 +158,37 @@ async fn lookup_srv(domain: &str) -> Option<MailConfig> {
// ---------------------------------------------------------------------------
/// Discover mail server configuration for a domain using the Thunderbird
/// autoconfig protocol (ISPDB) and DNS SRV fallback.
/// autoconfig protocol (ISPDB), DNS SRV, MX fallback, and finally guessing.
///
/// Probe order:
/// 1. `https://autoconfig.{domain}/mail/config-v1.1.xml`
/// 2. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
/// 3. DNS SRV records (`_imaps._tcp` / `_submission._tcp`)
/// 4. Thunderbird central ISPDB (`https://autoconfig.thunderbird.net/v1.1/{domain}`)
/// 2. `http://autoconfig.{domain}/mail/config-v1.1.xml`
/// 3. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
/// 4. `http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
/// 5. DNS SRV records (`_imaps._tcp` / `_submission._tcp`)
/// 6. Thunderbird central ISPDB (`https://autoconfig.thunderbird.net/v1.1/{domain}`)
/// 7. MX lookup → ISPDB for MX domain
/// 8. MX lookup → ISP autoconfig for MX domain
/// 9. GuessConfig — probe common hostnames + ports
pub async fn fetch(domain: &str) -> BichonResult<MailConfig> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// 1. Try autoconfig subdomain
if let Some(config) = fetch_xml(
&client,
&format!("https://autoconfig.{domain}/mail/config-v1.1.xml"),
)
.await
// ── ISP autoconfig (HTTPS, then HTTP) ──────────────────────────
if let Some(config) =
fetch_xml(&client, &format!("https://autoconfig.{domain}/mail/config-v1.1.xml")).await
{
return Ok(config);
}
if let Some(config) =
fetch_xml(&client, &format!("http://autoconfig.{domain}/mail/config-v1.1.xml")).await
{
return Ok(config);
}
// 2. Try well-known path
// ── Well-known path (HTTPS, then HTTP) ─────────────────────────
if let Some(config) = fetch_xml(
&client,
&format!("https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
@@ -185,28 +197,100 @@ pub async fn fetch(domain: &str) -> BichonResult<MailConfig> {
{
return Ok(config);
}
if let Some(config) = fetch_xml(
&client,
&format!("http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
)
.await
{
return Ok(config);
}
// 3. Try DNS SRV records
// ── DNS SRV records ────────────────────────────────────────────
if let Some(config) = lookup_srv(domain).await {
return Ok(config);
}
// 4. Fall back to Thunderbird central database
if let Some(config) = fetch_xml(
&client,
&format!("https://autoconfig.thunderbird.net/v1.1/{domain}"),
)
.await
// ── Thunderbird central ISPDB ──────────────────────────────────
if let Some(config) =
fetch_xml(&client, &format!("https://autoconfig.thunderbird.net/v1.1/{domain}")).await
{
return Ok(config);
}
// ── MX fallback ────────────────────────────────────────────────
if let Some(config) = fetch_for_mx(&client, domain).await {
return Ok(config);
}
// ── GuessConfig ────────────────────────────────────────────────
if let Some(config) = crate::autoconfig::guess::guess_config(domain).await {
return Ok(config);
}
Err(raise_error!(
format!("No autoconfig found for domain: {domain}"),
ErrorCode::InternalError
))
}
/// DNS MX lookup → retry ISPDB and ISP autoconfig for the MX domain.
///
/// Many self-hosted domains have their MX pointed at Google, Microsoft, etc.
/// The MX domain's ISPDB entry covers the original domain.
async fn fetch_for_mx(client: &Client, domain: &str) -> Option<MailConfig> {
let mx_domain = lookup_mx_domain(domain).await?;
if mx_domain == domain.to_ascii_lowercase() {
return None; // same domain, already tried above
}
// Try ISPDB for the MX domain
if let Some(config) =
fetch_xml(client, &format!("https://autoconfig.thunderbird.net/v1.1/{mx_domain}")).await
{
return Some(config);
}
// Try ISP autoconfig for the MX domain (HTTPS then HTTP)
if let Some(config) =
fetch_xml(client, &format!("https://autoconfig.{mx_domain}/mail/config-v1.1.xml")).await
{
return Some(config);
}
if let Some(config) =
fetch_xml(client, &format!("http://autoconfig.{mx_domain}/mail/config-v1.1.xml")).await
{
return Some(config);
}
None
}
/// DNS MX lookup → extract the second-level domain of the first MX hostname.
async fn lookup_mx_domain(domain: &str) -> Option<String> {
let resolver = TokioResolver::builder(TokioConnectionProvider::default())
.ok()?
.build();
let lookup = resolver.mx_lookup(domain).await.ok()?;
let record = lookup.iter().next()?;
let mx_host = record.to_string().trim_end_matches('.').to_string();
// Extract a reasonable base domain from the MX hostname.
// E.g., "aspmx.l.google.com" → "google.com"
// "company.mail.protection.outlook.com" → "outlook.com"
extract_base_domain(&mx_host)
}
/// Extract the top two labels from a hostname as a rough base domain.
fn extract_base_domain(host: &str) -> Option<String> {
let parts: Vec<&str> = host.split('.').collect();
if parts.len() >= 2 {
Some(parts[parts.len() - 2..].join("."))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1,105 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::account::entity::Encryption;
use crate::autoconfig::client::{IncomingServer, MailConfig};
use crate::imap::client::Client;
use tracing::{debug, info};
/// A single host:port:encryption combination to probe.
struct Guess {
hostname: String,
port: u16,
encryption: Encryption,
socket_type: &'static str,
}
/// Generate candidates in the same order Thunderbird uses:
/// 1. imap.{domain} — most common
/// 2. mail.{domain} — fallback
/// 3. {domain} — bare domain (rare)
fn make_guesses(domain: &str) -> Vec<Guess> {
let hosts = [
format!("imap.{domain}"),
format!("mail.{domain}"),
domain.to_string(),
];
let mut guesses = Vec::with_capacity(hosts.len() * 2);
for host in &hosts {
guesses.push(Guess {
hostname: host.clone(),
port: 993,
encryption: Encryption::Ssl,
socket_type: "SSL",
});
guesses.push(Guess {
hostname: host.clone(),
port: 143,
encryption: Encryption::StartTls,
socket_type: "STARTTLS",
});
}
guesses
}
/// Try to open a connection, read the IMAP banner, and close.
/// Returns `true` if the server responds with an IMAP greeting.
async fn probe(hostname: &str, port: u16, encryption: &Encryption) -> bool {
match Client::connection(hostname, encryption, port, None, true).await {
Ok(_) => {
debug!("GuessConfig probe succeeded: {hostname}:{port} ({encryption:?})");
true
}
Err(e) => {
debug!("GuessConfig probe failed for {hostname}:{port}: {e:?}");
false
}
}
}
/// Thunderbird-style guessing: try common hostnames and ports, probing
/// each with a real TCP connection.
///
/// Returns the first working `MailConfig`, or `None` if nothing works.
pub async fn guess_config(domain: &str) -> Option<MailConfig> {
let guesses = make_guesses(domain);
info!("GuessConfig: trying {} candidates for {domain}", guesses.len());
for g in &guesses {
if probe(&g.hostname, g.port, &g.encryption).await {
info!(
"GuessConfig: found working IMAP at {}:{} ({})",
g.hostname, g.port, g.socket_type
);
return Some(MailConfig {
incoming: vec![IncomingServer {
protocol: "imap".to_string(),
hostname: g.hostname.clone(),
port: g.port,
socket_type: g.socket_type.to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![],
});
}
}
None
}

View File

@@ -19,6 +19,7 @@
use crate::account::entity::Encryption;
use crate::autoconfig::client::{self, MailConfig};
use crate::autoconfig::entity::{MailServerConfig, ServerConfig};
use crate::autoconfig::oauth2_providers::lookup_oauth2;
use crate::autoconfig::CachedMailSettings;
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
@@ -54,9 +55,17 @@ pub(crate) fn mail_config_to_server_config(config: &MailConfig) -> Option<MailSe
}
};
// Detect OAuth2 support: the XML <authentication> field and a known
// hostname → issuer mapping determine whether the provider supports OAuth2.
let oauth2 = if imap.authentication.eq_ignore_ascii_case("OAuth2") {
lookup_oauth2(&imap.hostname)
} else {
None
};
Some(MailServerConfig {
imap: ServerConfig::new(imap.hostname.clone(), port, encryption),
oauth2: None,
oauth2,
})
}

View File

@@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize};
pub mod client;
pub mod entity;
pub mod guess;
pub mod load;
mod oauth2_providers;
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,151 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::autoconfig::entity::OAuth2Config;
/// Per-provider OAuth2 metadata, mirroring Thunderbird's `OAuth2Providers.sys.mjs`.
///
/// Each entry maps one or more IMAP hostname suffixes to a well-known OIDC issuer
/// and the IMAP-specific OAuth2 scopes.
struct Provider {
/// Suffixes matched case-insensitively against the end of the IMAP hostname.
host_suffixes: &'static [&'static str],
/// The OIDC issuer URL used by the provider.
issuer: &'static str,
/// OAuth2 scope(s) required for IMAP access.
scopes: &'static [&'static str],
}
const PROVIDERS: &[Provider] = &[
// Google
Provider {
host_suffixes: &["imap.gmail.com", ".gmail.com", ".googlemail.com"],
issuer: "https://accounts.google.com",
scopes: &["https://mail.google.com/"],
},
// Microsoft (Outlook / Office 365 / Hotmail / Live)
Provider {
host_suffixes: &[
"outlook.office365.com",
".outlook.com",
".hotmail.com",
".live.com",
".office365.com",
],
issuer: "https://login.microsoftonline.com/common/v2.0",
scopes: &[
"https://outlook.office365.com/IMAP.AccessAsUser.All",
"offline_access",
],
},
// Yahoo / AOL / ATT / Verizon
Provider {
host_suffixes: &[
"imap.mail.yahoo.com",
".yahoo.com",
".yahoodns.net",
".aol.com",
"imap.aol.com",
],
issuer: "https://login.yahoo.com",
scopes: &["mail-w"],
},
// Yandex
Provider {
host_suffixes: &["imap.yandex.ru", "imap.yandex.com", ".yandex.ru"],
issuer: "https://oauth.yandex.com",
scopes: &["imap:all"],
},
// Mail.ru
Provider {
host_suffixes: &["imap.mail.ru", ".mail.ru", ".bk.ru", ".list.ru", ".inbox.ru"],
issuer: "https://o2.mail.ru",
scopes: &["imap"],
},
// Fastmail
Provider {
host_suffixes: &["imap.fastmail.com", ".fastmail.com"],
issuer: "https://www.fastmail.com",
scopes: &[
"https://www.fastmail.com/dev/imap",
"offline_access",
],
},
// Comcast
Provider {
host_suffixes: &["imap.comcast.net", ".comcast.net"],
issuer: "https://oauth.xfinity.com",
scopes: &["https://email.comcast.net/"],
},
];
/// Try to find an OAuth2 provider that matches the given IMAP hostname.
///
/// Matching is case-insensitive and done by suffix: a hostname "imap.gmail.com"
/// matches the suffix ".gmail.com".
pub fn lookup_oauth2(hostname: &str) -> Option<OAuth2Config> {
let host = hostname.to_ascii_lowercase();
for provider in PROVIDERS {
if provider
.host_suffixes
.iter()
.any(|suffix| host.ends_with(&suffix.to_ascii_lowercase()))
{
return Some(OAuth2Config {
issuer: provider.issuer.to_string(),
scope: provider.scopes.iter().map(|s| s.to_string()).collect(),
auth_url: String::new(),
token_url: String::new(),
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_known_providers() {
let cases = [
("imap.gmail.com", Some("https://accounts.google.com")),
("imap.gmail.com", Some("https://accounts.google.com")),
("outlook.office365.com", Some("https://login.microsoftonline.com/common/v2.0")),
("imap.mail.yahoo.com", Some("https://login.yahoo.com")),
("imap.aol.com", Some("https://login.yahoo.com")),
("imap.yandex.ru", Some("https://oauth.yandex.com")),
("imap.mail.ru", Some("https://o2.mail.ru")),
("imap.fastmail.com", Some("https://www.fastmail.com")),
("imap.comcast.net", Some("https://oauth.xfinity.com")),
];
for (hostname, expected_issuer) in &cases {
let result = lookup_oauth2(hostname);
assert_eq!(
result.map(|c| c.issuer),
expected_issuer.map(|s| s.to_string()),
"failed for hostname: {hostname}"
);
}
}
#[test]
fn test_unknown_provider() {
assert!(lookup_oauth2("mail.my-company.example").is_none());
}
}

View File

@@ -195,6 +195,7 @@ fn make_imap_server(host: &str, port: u16, socket_type: &str) -> IncomingServer
port,
socket_type: socket_type.to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}
}
@@ -241,6 +242,7 @@ fn convert_no_imap_only_pop3() {
port: 995,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![],
};
@@ -266,6 +268,7 @@ fn convert_picks_imap_over_pop3() {
port: 995,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
},
make_imap_server("imap.example.com", 993, "SSL"),
],
@@ -284,6 +287,7 @@ fn convert_imaps_protocol_variant() {
port: 993,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![],
};
@@ -300,9 +304,65 @@ fn convert_case_insensitive_protocol() {
port: 143,
socket_type: "STARTTLS".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should recognize 'IMAP'");
assert_eq!(result.imap.host, "imap.example.com");
}
#[test]
fn convert_gmail_oauth2() {
let config = MailConfig {
incoming: vec![IncomingServer {
protocol: "imap".to_string(),
hostname: "imap.gmail.com".to_string(),
port: 993,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: "OAuth2".to_string(),
}],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should convert");
let oauth2 = result.oauth2.expect("Gmail should have OAuth2");
assert_eq!(oauth2.issuer, "https://accounts.google.com");
assert!(oauth2.scope.contains(&"https://mail.google.com/".to_string()));
}
#[test]
fn convert_outlook_oauth2() {
let config = MailConfig {
incoming: vec![IncomingServer {
protocol: "imap".to_string(),
hostname: "outlook.office365.com".to_string(),
port: 993,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: "OAuth2".to_string(),
}],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should convert");
let oauth2 = result.oauth2.expect("Outlook should have OAuth2");
assert!(oauth2.issuer.contains("microsoftonline"));
}
#[test]
fn convert_unknown_host_no_oauth2() {
// OAuth2 auth flag on an unknown hostname → no OAuth2 returned
let config = MailConfig {
incoming: vec![IncomingServer {
protocol: "imap".to_string(),
hostname: "mail.random-isp.example".to_string(),
port: 993,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: "OAuth2".to_string(),
}],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should convert");
assert!(result.oauth2.is_none(), "unknown hostname → no OAuth2 mapping");
}

View File

@@ -16,6 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::str::FromStr;
use chrono::{DateTime, Local, TimeZone, Utc};
use cron::Schedule;
use crate::{
utc_now,
{
@@ -48,11 +53,20 @@ pub async fn decide_next_download_task(
let should_start = match trigger_type {
TriggerType::Manual => true,
TriggerType::Scheduled => should_trigger_next_download(
state.last_trigger_at,
state.last_finished_at.unwrap_or(0),
account.download_interval_min.unwrap_or(60),
),
TriggerType::Scheduled => {
let now = utc_now!();
let cooldown_ok = now - state.last_finished_at.unwrap_or(0) > 60 * 1000;
if !cooldown_ok {
false
} else if let Some(ref schedule) = account.download_schedule {
should_trigger_scheduled(schedule, state.last_trigger_at)
} else {
should_trigger_next_download(
state.last_trigger_at,
account.download_interval_min.unwrap_or(60),
)
}
}
};
if should_start {
@@ -63,11 +77,80 @@ pub async fn decide_next_download_task(
}
}
fn should_trigger_next_download(
last_trigger_at: i64,
last_finished_at: i64,
sync_interval_min: i64,
) -> bool {
fn should_trigger_next_download(last_trigger_at: i64, sync_interval_min: i64) -> bool {
let now = utc_now!();
now - last_trigger_at > (sync_interval_min * 60 * 1000) && now - last_finished_at > 60 * 1000
now - last_trigger_at > (sync_interval_min * 60 * 1000)
}
fn should_trigger_scheduled(schedule_str: &str, last_trigger_at: i64) -> bool {
let schedule = match Schedule::from_str(schedule_str) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
"Invalid cron expression '{}', falling back to no trigger: {}",
schedule_str,
e
);
return false;
}
};
// last_trigger_at is a UTC millis timestamp; convert to server local time
let last_utc = match Utc.timestamp_millis_opt(last_trigger_at) {
chrono::LocalResult::Single(dt) => dt,
_ => {
tracing::warn!("Invalid last_trigger_at timestamp: {}", last_trigger_at);
return false;
}
};
let last_dt: DateTime<Local> = last_utc.with_timezone(&Local);
let now = Local::now();
schedule
.after(&last_dt)
.next()
.map_or(false, |next| next <= now)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn cron_every_minute_triggers_after_60s() {
// "0 * * * * *" = every minute at second 0. last_trigger 90s ago → should trigger
let now = Local::now();
let last_trigger = now.timestamp_millis() - 90_000;
assert!(should_trigger_scheduled("0 * * * * *", last_trigger));
}
#[test]
fn cron_daily_midnight_triggers_when_missed() {
// "0 0 0 * * *" = daily at midnight
// last_trigger was 25 hours ago → should trigger (we missed midnight)
let now = Local::now();
let last_trigger = now.timestamp_millis() - 25 * 60 * 60 * 1000;
assert!(should_trigger_scheduled("0 0 0 * * *", last_trigger));
}
#[test]
fn cron_daily_midnight_no_trigger_if_already_fired() {
// "0 0 0 * * *" = daily at midnight
// last_trigger was 1 minute ago → should NOT trigger
let now = Local::now();
let last_trigger = now.timestamp_millis() - 60_000;
assert!(!should_trigger_scheduled("0 0 0 * * *", last_trigger));
}
#[test]
fn invalid_cron_returns_false() {
assert!(!should_trigger_scheduled("invalid cron expression", 0));
}
#[test]
fn cron_every_hour_triggers() {
// "0 0 * * * *" = every hour at minute 0, second 0
// last_trigger was 61 minutes ago → should trigger
let now = Local::now();
let last_trigger = now.timestamp_millis() - 61 * 60 * 1000;
assert!(should_trigger_scheduled("0 0 * * * *", last_trigger));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,24 +17,20 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
raise_error,
{
account::{
migration::AccountModel,
state::{DownloadState, DownloadStatus, FolderStatus},
},
cache::{
imap::{
download::flow::{
fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection,
},
mailbox::MailBox,
},
SEMAPHORE,
},
error::{code::ErrorCode, BichonResult},
store::tantivy::envelope::ENVELOPE_MANAGER,
account::{
migration::AccountModel,
state::{DownloadState, DownloadStatus, FolderStatus},
},
cache::{
imap::{
download::flow::{fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection},
mailbox::MailBox,
},
SEMAPHORE,
},
error::{code::ErrorCode, BichonResult},
raise_error,
store::tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
};
use tokio_util::sync::CancellationToken;
@@ -91,9 +87,13 @@ pub async fn rebuild_cache(
continue;
}
};
match fetch_and_save_full_mailbox(&account, &mailbox, token.clone()).await {
Ok(_) => {}
Ok(new_highest_uid) => {
let mut updated = mailbox.clone();
updated.highest_uid = new_highest_uid;
MailBox::batch_upsert(&[updated])?;
}
Err(err) => {
has_error = true;
tracing::error!("Folder sync task failed: {:#?}", err);
@@ -173,7 +173,11 @@ pub async fn rebuild_cache_by_date(
match fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction, token.clone())
.await
{
Ok(_) => {}
Ok(new_highest_uid) => {
let mut updated = mailbox.clone();
updated.highest_uid = new_highest_uid;
MailBox::batch_upsert(&[updated])?;
}
Err(err) => {
has_error = true;
tracing::error!("Folder sync task failed: {:#?}", err);
@@ -200,11 +204,13 @@ pub async fn rebuild_mailbox_cache(
local_mailbox: &MailBox,
remote_mailbox: &MailBox,
token: CancellationToken,
) -> BichonResult<()> {
) -> BichonResult<Option<u32>> {
ENVELOPE_MANAGER
.delete_mailbox_envelopes(account.id, vec![local_mailbox.id])
.await?;
ATTACHMENT_MANAGER
.delete_mailbox_attachments(account.id, vec![local_mailbox.id])
.await?;
if remote_mailbox.exists == 0 {
info!(
"Account {}: Mailbox '{}' has no emails on the remote server. The mailbox is empty, no envelopes to fetch.",
@@ -219,11 +225,11 @@ pub async fn rebuild_mailbox_cache(
FolderStatus::Success,
None,
)?;
return Ok(());
return Ok(None);
}
fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
Ok(())
let result = fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
Ok(result)
}
pub async fn rebuild_mailbox_cache_by_date(
@@ -233,10 +239,13 @@ pub async fn rebuild_mailbox_cache_by_date(
remote: &MailBox,
direction: FetchDirection,
token: CancellationToken,
) -> BichonResult<()> {
) -> BichonResult<Option<u32>> {
ENVELOPE_MANAGER
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
.await?;
ATTACHMENT_MANAGER
.delete_mailbox_attachments(account.id, vec![local_mailbox_id])
.await?;
if remote.exists == 0 {
info!(
"Account {}: Mailbox '{}' has no emails on the remote server. The mailbox is empty, no envelopes to fetch.",
@@ -251,9 +260,9 @@ pub async fn rebuild_mailbox_cache_by_date(
FolderStatus::Success,
None,
)?;
return Ok(());
return Ok(None);
}
fetch_and_save_by_date(account, date, remote, direction, token).await?;
Ok(())
let result = fetch_and_save_by_date(account, date, remote, direction, token).await?;
Ok(result)
}

View File

@@ -56,6 +56,10 @@ pub struct MailBox {
/// The validity identifier for UIDs in this mailbox, used to ensure UID consistency across sessions.
/// If `None`, the IMAP server has not provided this information.
pub uid_validity: Option<u32>,
/// The highest UID that has been successfully downloaded and stored locally.
/// Used for incremental sync: next fetch starts from `highest_uid + 1`.
/// If `None`, a fallback query against the Tantivy index will be performed once.
pub highest_uid: Option<u32>,
}
impl MemDbModel for MailBox {

View File

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

@@ -83,16 +83,11 @@ impl DashboardStats {
stat.email_count = ENVELOPE_MANAGER.total_emails(&authorized_ids)?;
stat.attachment_count = ATTACHMENT_MANAGER.total_attachments(&authorized_ids)?;
if has_all_accounts {
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.storage_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.storage_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.index_usage_bytes = get_total_size(&&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
} else {
stat.storage_usage_bytes = 0;
stat.index_usage_bytes = 0;
}
stat.index_usage_bytes = get_total_size(&&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.system_version = bichon_version!().to_string();

View File

@@ -17,7 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::settings::dir::DATA_DIR_MANAGER;
use memdb::{Durability, MemDb};
use bichon_memdb::{Durability, MemDb};
use std::sync::LazyLock;
use std::time::Duration;
@@ -32,12 +32,16 @@ impl DatabaseManager {
let db_path = &DATA_DIR_MANAGER.memdb_dir;
std::fs::create_dir_all(db_path).expect("Failed to create memdb data directory");
let db = MemDb::open_with(db_path, Durability::Full)
let db = MemDb::open_with(db_path, Durability::Batch { max_ops: 100 })
.expect("Failed to open memdb database");
// Start periodic snapshot worker (every 5 minutes)
db.start_snapshot_worker(Duration::from_secs(300));
// Start periodic flush worker (every 10 seconds) so buffered writes
// are flushed regularly and not only at the batch threshold.
db.start_flush_worker(Duration::from_secs(10));
DatabaseManager { db }
}
@@ -45,4 +49,12 @@ impl DatabaseManager {
pub fn db(&self) -> &MemDb {
&self.db
}
/// Flush any buffered WAL entries to disk. Must be called before shutdown
/// to avoid losing writes that haven't hit the batch threshold yet.
pub fn flush(&self) {
if let Err(e) = self.db.flush() {
eprintln!("[memdb] flush error on shutdown: {e}");
}
}
}

View File

@@ -20,7 +20,7 @@ use crate::common::paginated::Paginated;
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::raise_error;
use memdb::{MemDb, Transaction};
use bichon_memdb::{MemDb, Transaction};
use serde::de::DeserializeOwned;
use serde::Serialize;

View File

@@ -16,14 +16,18 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::account::migration::AccountModel;
use crate::cache::imap::mailbox::MailBox;
use crate::common::AddrVec;
use crate::envelope::meta::parse_bichon_metadata;
use crate::envelope::utils::normalize_subject;
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
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;
@@ -48,9 +52,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
}
@@ -87,7 +99,13 @@ async fn extract_envelope_core(
account_id: u64,
mailbox_id: u64,
) -> 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(),
@@ -95,6 +113,34 @@ async fn extract_envelope_core(
)
})?;
if let Ok(account) = AccountModel::get(account_id) {
if let Some(ref rules) = account.archive_rules {
let sender = message.from().and_then(|addr| {
AddrVec::from(addr).0.into_iter().next().and_then(|a| a.address)
});
let subject = message.subject().map(|s| s.to_string());
let is_spam = !rules.spam_headers.is_empty()
&& rules.spam_headers.iter().any(|h| {
message
.header_raw(h.clone())
.map(|v| matches!(v.trim().to_lowercase().as_str(), "yes" | "true"))
.unwrap_or(false)
});
if !rules.should_archive(sender.as_deref(), subject.as_deref(), size, is_spam) {
tracing::debug!(
account_id,
uid,
sender = sender.as_deref().unwrap_or("?"),
subject = subject.as_deref().unwrap_or("?"),
"Email filtered out by archive rules"
);
return Ok(());
}
}
}
let preview_limit = 100;
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
text
@@ -156,7 +202,7 @@ async fn extract_envelope_core(
.and_then(|add| add.address)
.unwrap_or_else(|| "unknown".to_string());
let attachment_count = message.attachment_count();
let attachments = detach_and_store_attachments(body, &message, &email_content_hash).await;
let attachments = detach_and_store_attachments(body, &message, &email_content_hash, account_id, mailbox_id).await;
let envelope_id = Uuid::new_v4().to_string();
let now = utc_now!();
@@ -194,33 +240,37 @@ async fn extract_envelope_core(
let attachment_docs: Vec<TantivyDocument> = attachments
.iter()
.filter(|a| !a.inline || a.content_id.is_none())
.map(|a| AttachmentModel {
id: Uuid::new_v4().to_string(),
envelope_id: envelope_id.clone(),
account_id,
account_email: None,
mailbox_id,
mailbox_name: None,
subject: subject.clone(),
content_hash: a.content_hash.clone(),
from: from.clone(),
date,
ingest_at: now,
size: a.size as u64,
ext: a.get_extension(),
category: a.get_category().to_string(),
content_type: a.file_type.clone(),
shard_id: 0,
text: None,
has_text: false,
is_ocr: false,
page_count: None,
is_indexed: false,
is_message: a.is_message,
name: a.filename.clone(),
tags: None,
auto_tags: None,
}).map(|a|a.into_document())
.map(|a| {
let has_text = a.extracted_text.is_some();
AttachmentModel {
id: Uuid::new_v4().to_string(),
envelope_id: envelope_id.clone(),
account_id,
account_email: None,
mailbox_id,
mailbox_name: None,
subject: subject.clone(),
content_hash: a.content_hash.clone(),
from: from.clone(),
date,
ingest_at: now,
size: a.size as u64,
ext: a.get_extension(),
category: a.get_category().to_string(),
content_type: a.file_type.clone(),
shard_id: 0,
text: a.extracted_text.clone(),
has_text,
is_ocr: a.extracted_is_ocr,
page_count: a.extracted_page_count.map(|n| n as u64),
is_indexed: has_text,
is_message: a.is_message,
name: a.filename.clone(),
tags: None,
auto_tags: None,
}
})
.map(|a| a.into_document())
.collect();
let envelope = Envelope {
@@ -245,7 +295,8 @@ 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(),
account_name: None,
};
// 'attachments' contains both regular and inline attachments
let ea = EnvelopeWithAttachments {
@@ -253,7 +304,16 @@ async fn extract_envelope_core(
attachments: Some(attachments),
};
let doc = ea.to_document(&body_text, 0)?;
tracing::debug!(
"[account {}][mailbox {}] extract: uid={} msg_id={} content_hash={}",
account_id,
mailbox_id,
uid,
&ea.envelope.message_id,
&ea.envelope.content_hash,
);
ENVELOPE_MANAGER.queue(doc).await;
DEDUP_CACHE.insert(account_id, mailbox_id, &email_content_hash);
for doc in attachment_docs {
ATTACHMENT_MANAGER.queue(doc).await;
}
@@ -331,6 +391,7 @@ pub fn extract_envelope_from_nested_message(
regular_attachment_count: Default::default(),
tags: Default::default(),
account_email: Default::default(),
account_name: Default::default(),
mailbox_name: Default::default(),
content_hash: Default::default(),
};
@@ -369,7 +430,27 @@ pub async fn detach_and_store_attachments(
original_body: &[u8],
message: &Message<'_>,
eml_content_hash: &str,
account_id: u64,
mailbox_id: u64,
) -> Vec<AttachmentInfo> {
let rules = if account_id > 0 {
AccountModel::get(account_id)
.ok()
.and_then(|a| a.extraction_rules)
} else {
None
};
let mailbox_name = match rules.as_ref().map(|r| !r.folders.is_empty()) {
Some(true) => MailBox::get(mailbox_id).ok().map(|mb| mb.name),
_ => None,
};
let sender = message
.from()
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
.and_then(|add| add.address);
let mut stripped_eml = original_body.to_vec();
let mut attachment_infos = Vec::new();
// Step 1: Collect and sort attachment ranges in reverse to maintain offset integrity
@@ -386,42 +467,136 @@ pub async fn detach_and_store_attachments(
ranges.sort_by(|a, b| b.0.cmp(&a.0));
let mut attachments = Vec::with_capacity(ranges.len());
// Collect candidates for text extraction (non-inline, known document types).
struct TextCandidate {
content_hash: String,
file_type: String,
ext: String,
bytes: Vec<u8>,
}
let mut text_candidates: Vec<TextCandidate> = Vec::new();
for (raw_start, raw_end, att) in ranges {
// Step 2: Extract raw bytes and store them as standalone documents
let raw_bytes = &original_body[raw_start..raw_end];
// 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());
attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));//
if range_valid {
let raw_bytes = &original_body[raw_start..raw_end];
// The actual content stored in the blob is the raw undecoded data.
attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));
// Step 3: Replace raw attachment content with a hash-based placeholder
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
let p_bytes = placeholder.as_bytes();
stripped_eml.splice(raw_start..raw_end, p_bytes.iter().cloned());
// Replace raw attachment content with a hash-based placeholder
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
stripped_eml.splice(raw_start..raw_end, placeholder.as_bytes().iter().cloned());
} else {
// Invalid range: store a zero-length blob so the consistency
// check passes; reattachment will log a warning for the missing
// blob data but won't panic.
attachments.push((content_hash.clone(), Bytes::new()));
}
let inline = att
.content_disposition()
.map(|d| d.is_inline())
.unwrap_or_else(|| att.content_id().is_some());
let file_type = att
.content_type()
.map(|ct| {
format!(
"{}/{}",
ct.c_type.as_ref(),
ct.c_subtype.as_deref().unwrap_or("")
)
})
.unwrap_or_else(|| "application/octet-stream".to_string());
let has_cid = att.content_id().is_some();
let att_name = att.attachment_name().map(|n| n.to_string());
let ext = att_name
.as_deref()
.and_then(|n| {
std::path::Path::new(n)
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
})
.unwrap_or_default();
let should_extract = rules.as_ref().map_or(true, |r| {
r.should_extract(
&ext,
mailbox_name.as_deref(),
att_name.as_deref(),
sender.as_deref(),
)
});
if !inline || !has_cid {
let decoded_len = att.contents().len();
if should_extract
&& decoded_len <= crate::ext::text_extractor::MAX_EXTRACT_BYTES
&& crate::ext::text_extractor::should_try_extract(&file_type, &ext)
{
text_candidates.push(TextCandidate {
content_hash: content_hash.clone(),
file_type: file_type.clone(),
ext: ext.clone(),
bytes: att.contents().to_vec(),
});
}
}
let info = AttachmentInfo {
filename: att.attachment_name().map(|n| n.to_string()),
size: att.contents().len(),
inline: att
.content_disposition()
.map(|d| d.is_inline())
.unwrap_or(false),
file_type: att
.content_type()
.map(|ct| {
format!(
"{}/{}",
ct.c_type.as_ref(),
ct.c_subtype.as_deref().unwrap_or("")
)
})
.unwrap_or_else(|| "application/octet-stream".to_string()),
inline,
file_type,
content_id: att.content_id().map(|id| id.to_string()),
content_hash: content_hash.clone(),
is_message: att.is_message(),
extracted_text: None,
extracted_page_count: None,
extracted_is_ocr: false,
};
attachment_infos.push(info);
}
// Run text extraction in a single spawn_blocking batch.
if !text_candidates.is_empty() {
if let Ok(mut extracted_map) = tokio::task::spawn_blocking(move || {
let mut map: std::collections::HashMap<
String,
(String, Option<u32>, bool),
> = std::collections::HashMap::new();
for c in text_candidates {
if let Some(r) =
crate::ext::text_extractor::extract_text(&c.file_type, &c.ext, &c.bytes)
{
map.insert(c.content_hash, (r.text, r.page_count, r.is_ocr));
}
}
map
})
.await
{
for info in &mut attachment_infos {
if let Some((text, pages, is_ocr)) = extracted_map.remove(&info.content_hash) {
info.extracted_text = Some(text);
info.extracted_page_count = pages;
info.extracted_is_ocr = is_ocr;
}
}
}
}
// Step 4: Store the final stripped EML content
BLOB_MANAGER
.queue(DetachedEmail {
@@ -515,6 +690,114 @@ pub fn reattach_eml_content(
Ok((e.envelope, Bytes::from(restored_eml)))
}
/// Returns the raw EML for an indexed message, self-healing a missing content blob.
///
/// Behaves like [`reattach_eml_content`], but when the message's content blob is
/// absent from the blob store it fetches that single message on demand from the
/// IMAP server (`UID FETCH <uid> (BODY.PEEK[])`), persists it for future requests,
/// and returns it. If the on-demand fetch itself fails, the original "content not
/// found" error from [`reattach_eml_content`] is surfaced unchanged so the caller
/// still produces its 404.
pub async fn reattach_eml_content_self_healing(
account_id: u64,
envelope_id: String,
) -> BichonResult<(Envelope, Bytes)> {
let envelope = ENVELOPE_MANAGER
.get_envelope_by_id(account_id, &envelope_id)?
.ok_or_else(|| {
raise_error!(
format!(
"Envelope not found: account_id={} envelope_id={}",
account_id, &envelope_id
),
ErrorCode::ResourceNotFound
)
})?
.envelope;
// Fast path: the content blob is present, reuse the regular reattach logic.
if BLOB_MANAGER.get_email(&envelope.content_hash)?.is_some() {
return reattach_eml_content(account_id, envelope_id);
}
// The blob is missing. Try to recover it directly from the IMAP server.
match recover_message_blob(&envelope).await {
Ok(raw_body) => {
tracing::info!(
account_id,
envelope_id = %envelope_id,
uid = envelope.uid,
"Self-healed missing email content blob via on-demand IMAP fetch"
);
Ok((envelope, raw_body))
}
Err(e) => {
tracing::warn!(
account_id,
envelope_id = %envelope_id,
uid = envelope.uid,
error = %e,
"On-demand IMAP fetch for missing content blob failed; returning not-found"
);
Err(e)
}
}
}
/// Fetches one message from IMAP and re-stores its detached blob.
///
/// On success the freshly fetched raw RFC822 body is returned; it is also queued
/// (in detached form) into the blob store so subsequent requests hit the cache.
/// Fails if the message cannot be fetched, or if the fetched bytes do not match
/// the archived `content_hash` (the server-side message no longer matches what
/// Bichon archived, so it cannot be treated as a recovery of that blob).
async fn recover_message_blob(envelope: &Envelope) -> BichonResult<Bytes> {
let mailbox = MailBox::find_mailbox(envelope.account_id, envelope.mailbox_id)?
.ok_or_else(|| {
raise_error!(
format!(
"Mailbox not found: account_id={} mailbox_id={}",
envelope.account_id, envelope.mailbox_id
),
ErrorCode::ResourceNotFound
)
})?;
let mut session = ImapExecutor::create_connection(envelope.account_id).await?;
let result = ImapExecutor::fetch_single_message_body(
&mut session,
&mailbox.encoded_name(),
envelope.uid,
)
.await;
session.logout().await.ok();
let raw_body = result?;
let fetched_hash = compute_content_hash(&raw_body);
if fetched_hash != envelope.content_hash {
return Err(raise_error!(
format!(
"Fetched message does not match archived content: expected content_hash={} got={}",
envelope.content_hash, fetched_hash
),
ErrorCode::ImapUnexpectedResult
));
}
// Re-create the detached blob (stripped EML + attachments) so the missing
// blob is repopulated for future requests. The detached EML is queued under
// `fetched_hash`, which equals `envelope.content_hash`.
let message = MessageParser::new().parse(raw_body.as_slice()).ok_or_else(|| {
raise_error!(
"Failed to parse fetched email content".into(),
ErrorCode::InternalError
)
})?;
detach_and_store_attachments(&raw_body, &message, &fetched_hash, envelope.account_id, envelope.mailbox_id).await;
Ok(Bytes::from(raw_body))
}
#[cfg(test)]
mod test {
use html2text::config;
@@ -562,4 +845,58 @@ 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",
0,
0,
)
.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

@@ -0,0 +1,86 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Event bus extension point.
//
// Community edition: NoopEventBus — all events are discarded.
// Pro edition: AuditEventBus — events are persisted to audit database.
// Enterprise edition: adds SIEM webhook to the same trait impl.
//
// The open-source server emits events at key points (login, view, delete, search).
// It never reads from the event bus — events are fire-and-forget.
use std::net::IpAddr;
use std::sync::{LazyLock, RwLock};
#[derive(Debug, Clone)]
pub enum Event {
EmailViewed {
email_id: String,
user: String,
ip: IpAddr,
},
EmailDeleted {
email_id: String,
user: String,
},
UserLoggedIn {
user: String,
ip: IpAddr,
},
UserCreated {
created_by: String,
new_user: String,
},
SearchPerformed {
query: String,
user: String,
},
SettingsChanged {
key: String,
user: String,
},
AttachmentDownloaded {
email_id: String,
content_hash: String,
user: String,
},
}
pub trait EventBus: Send + Sync {
fn emit(&self, event: Event);
}
/// Default — all events are discarded.
struct NoopEventBus;
impl EventBus for NoopEventBus {
fn emit(&self, _event: Event) {}
}
static EVENT_BUS: LazyLock<RwLock<Box<dyn EventBus>>> =
LazyLock::new(|| RwLock::new(Box::new(NoopEventBus)));
/// Called by Pro/Enterprise at startup to replace the noop default.
pub fn set_event_bus(bus: Box<dyn EventBus>) {
*EVENT_BUS.write().unwrap() = bus;
}
/// Fire-and-forget. Called by the server at key points.
pub fn emit(event: Event) {
EVENT_BUS.read().unwrap().emit(event);
}

View File

@@ -0,0 +1,29 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Event bus extension point.
//
// Community edition: NoopEventBus — all events are discarded.
// Pro edition: AuditEventBus — events are persisted to audit database.
// Enterprise edition: adds SIEM webhook to the same trait impl.
//
// The open-source server emits events at key points (login, view, delete, search).
// It never reads from the event bus — events are fire-and-forget.
pub mod event_bus;
pub mod text_extractor;

View File

@@ -0,0 +1,85 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Attachment text extraction extension point.
//
// Community edition: NoopExtractor — no attachments are text-indexed.
// Pro edition: PdfExtractor — extracts text from PDF, Word, etc.
//
// Used in: crates/core/src/envelope/extractor.rs
use std::sync::{LazyLock, RwLock};
pub struct ExtractedText {
pub text: String,
pub page_count: Option<u32>,
pub is_ocr: bool,
}
pub trait AttachmentTextExtractor: Send + Sync {
/// Returns None if this extractor doesn't handle the file type.
/// Returns Some(ExtractedText) if text was successfully extracted.
fn extract(&self, content_type: &str, ext: &str, bytes: &[u8]) -> Option<ExtractedText>;
}
/// Default — all attachments are skipped.
struct NoopExtractor;
impl AttachmentTextExtractor for NoopExtractor {
fn extract(&self, _ct: &str, _ext: &str, _bytes: &[u8]) -> Option<ExtractedText> {
None
}
}
static EXTRACTOR: LazyLock<RwLock<Box<dyn AttachmentTextExtractor>>> =
LazyLock::new(|| RwLock::new(Box::new(NoopExtractor)));
/// Called by Pro/Enterprise at startup to replace the noop default.
pub fn set_extractor(extractor: Box<dyn AttachmentTextExtractor>) {
*EXTRACTOR.write().unwrap() = extractor;
}
/// Attachments larger than this are skipped (10 MiB). Avoids excessive memory
/// and CPU cost for huge files whose text is rarely useful for search.
pub const MAX_EXTRACT_BYTES: usize = 10 * 1024 * 1024;
/// Quick pre-filter: returns true for file types where text extraction may
/// produce useful results. Avoids cloning attachment bytes for images, videos,
/// archives, etc. when no registered extractor would handle them.
pub fn should_try_extract(content_type: &str, ext: &str) -> bool {
matches!(
ext,
"pdf"
| "doc"
| "docx"
| "xls"
| "xlsx"
| "ppt"
| "pptx"
| "txt"
| "rtf"
| "odt"
| "ods"
| "odp"
) || content_type.starts_with("text/")
}
/// Called by the attachment pipeline during IMAP sync.
/// The caller should wrap this in spawn_blocking for CPU-bound extraction.
pub fn extract_text(content_type: &str, ext: &str, bytes: &[u8]) -> Option<ExtractedText> {
EXTRACTOR.read().unwrap().extract(content_type, ext, bytes)
}

View File

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

@@ -17,8 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::account::migration::AccountModel;
use crate::account::state::{DownloadState, FolderStatus};
use crate::cache::imap::download::flow::{generate_uid_sequence_hashset, DEFAULT_BATCH_SIZE};
use crate::account::state::{DownloadState, DownloadStatus, FolderStatus};
use crate::cache::imap::mailbox::MailBox;
use crate::envelope::extractor::extract_envelope_and_store_it;
use crate::error::code::ErrorCode;
@@ -28,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;
@@ -43,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)
}
@@ -59,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)
}
@@ -77,9 +94,18 @@ 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.
///
/// When `before` is `Some(date)`, a two-step approach is used:
/// `UID SEARCH` to find matching UIDs (standard IMAP), then batch `UID FETCH`
/// for the specific UIDs. When `before` is `None`, a direct ranged
/// `UID FETCH {start}:*` is issued and results are streamed.
///
/// Returns `Ok(Some(max_uid))` with the highest UID fetched, or `Ok(None)`
/// if no new mail was found.
pub async fn fetch_new_mail(
session: &mut Session<Box<dyn SessionStream>>,
account: &AccountModel,
@@ -87,114 +113,221 @@ impl ImapExecutor {
start_uid: u64,
before: Option<&str>,
token: CancellationToken,
) -> BichonResult<()> {
) -> BichonResult<Option<u32>> {
assert!(start_uid > 0, "start_uid must be greater than 0");
let query = match before {
Some(date) => format!("UID {start_uid}:* BEFORE {date}"),
None => format!("UID {start_uid}:*"),
};
session
.examine(&mailbox.encoded_name())
.await
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
let uid_list = match Self::uid_search(session, &mailbox.encoded_name(), &query).await {
Ok(uid_list) => uid_list,
Err(e) => {
let err_msg = format!("UID search failed in [{}]: {:#?}", mailbox.name, e);
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
0,
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)?;
DownloadState::append_session_error(account.id, err_msg)?;
return Err(e);
match before {
Some(date) => {
Self::fetch_new_mail_with_before(session, account, mailbox, start_uid, date, token)
.await
}
};
None => Self::fetch_new_mail_range(session, account, mailbox, start_uid, token).await,
}
}
let len = uid_list.len();
if len == 0 {
let msg = match before {
Some(date) => format!("No emails found before {}.", date),
None => "No new emails found.".into(),
};
/// Two-step approach for date-filtered incremental fetch: UID SEARCH first,
/// then batch UID FETCH for matching UIDs. Uses standard IMAP syntax that
/// works across all compliant servers.
async fn fetch_new_mail_with_before(
session: &mut Session<Box<dyn SessionStream>>,
account: &AccountModel,
mailbox: &MailBox,
start_uid: u64,
date: &str,
token: CancellationToken,
) -> BichonResult<Option<u32>> {
let query = format!("UID {start_uid}:* BEFORE {date}");
info!(
"[account {}][mailbox {}] fetch_new_mail: UID SEARCH {}",
account.id, mailbox.name, query
);
let results = session.uid_search(&query).await.map_err(|e| {
let err_msg = format!("UID SEARCH failed in [{}]: {:#?}", mailbox.name, e);
let _ = DownloadState::append_session_error(account.id, err_msg);
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
})?;
if results.is_empty() {
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
Some(msg),
Some("No new emails found.".into()),
)?;
return Ok(());
return Ok(None);
}
info!(
"[account {}][mailbox {}] {} envelopes need to be fetched",
account.id, mailbox.name, len
);
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
let mut uid_vec: Vec<u32> = results.into_iter().collect();
uid_vec.sort();
let uid_batches = generate_uid_sequence_hashset(
uid_vec,
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
false,
);
let mut current_processed = 0u64;
let mut has_error_or_cancel = false;
for (index, batch) in uid_batches.into_iter().enumerate() {
let max_uid = uid_vec.last().copied();
let planned = uid_vec.len() as u64;
let batch_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize;
let uid_batches = generate_uid_sequence_hashset(uid_vec, batch_size);
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
planned,
0,
FolderStatus::Pending,
None,
)?;
let mut count = 0u64;
for batch in uid_batches {
if token.is_cancelled() {
break;
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("User stopped or system shutdown".to_string()),
)?;
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
planned,
count,
FolderStatus::Cancelled,
None,
)?;
return Err(raise_error!(
"Stream cancelled".into(),
ErrorCode::InternalError
));
}
match Self::uid_batch_retrieve_emails(
let processed = Self::uid_batch_retrieve_emails(
session,
account.id,
mailbox.id,
&batch.0,
account.max_email_size_bytes,
token.clone(),
)
.await
{
Ok(_) => {
current_processed += batch.1;
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
len as u64,
current_processed,
FolderStatus::Downloading,
None,
)?;
}
Err(e) => {
let err_msg = format!("Batch {} failed: {:#?}", index, e);
DownloadState::append_session_error(account.id, err_msg.clone())?;
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
len as u64,
current_processed,
FolderStatus::Failed,
Some(err_msg),
)?;
has_error_or_cancel = true;
break;
}
}
}
if !has_error_or_cancel {
.await?;
count += processed;
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
len as u64,
current_processed,
FolderStatus::Success,
planned,
count,
FolderStatus::Downloading,
None,
)?;
}
Ok(())
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
count,
count,
FolderStatus::Success,
None,
)?;
Ok(max_uid)
}
/// Direct ranged UID FETCH without date filtering. Streams results from
/// the server in a single IMAP round-trip.
async fn fetch_new_mail_range(
session: &mut Session<Box<dyn SessionStream>>,
account: &AccountModel,
mailbox: &MailBox,
start_uid: u64,
token: CancellationToken,
) -> BichonResult<Option<u32>> {
let uid_range = format!("{start_uid}:*");
info!(
"[account {}][mailbox {}] fetch_new_mail: direct UID FETCH {}",
account.id, mailbox.name, uid_range
);
let mut stream = session
.uid_fetch(&uid_range, BODY_FETCH_COMMAND)
.await
.map_err(|e| {
let err_msg = format!("UID FETCH failed in [{}]: {:#?}", mailbox.name, e);
let _ = DownloadState::append_session_error(account.id, err_msg);
raise_error!(format!("{:#?}", e), 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), classify_imap_error(&e)))?
{
if token.is_cancelled() {
tracing::info!("Account {}: fetch_new_mail stream interrupted.", account.id);
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("User stopped or system shutdown".to_string()),
)?;
return Err(raise_error!(
"Stream cancelled".into(),
ErrorCode::InternalError
));
}
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));
}
extract_envelope_and_store_it(fetch, account.id, mailbox.id).await?;
count += 1;
}
let total = count + skipped;
if total == 0 {
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
Some("No new emails found.".into()),
)?;
} else {
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
total,
count,
FolderStatus::Success,
if skipped > 0 {
Some(format!("{skipped} email(s) skipped due to size limit"))
} else {
None
},
)?;
}
Ok(max_uid)
}
pub async fn batch_retrieve_emails(
@@ -205,48 +338,149 @@ impl ImapExecutor {
page: u64,
page_size: u64,
encoded_mailbox_name: &str,
desc: bool,
max_email_size_bytes: Option<u64>,
token: CancellationToken,
max_uid: &mut Option<u32>,
) -> BichonResult<usize> {
assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0");
let (start, end) = if desc {
// Fetch messages starting from the newest (descending order)
let end = total.saturating_sub((page - 1) * page_size);
if end == 0 {
return Ok(0);
}
// Calculate start as end - page_size + 1 to avoid off-by-one errors
let start = end.saturating_sub(page_size - 1).max(1);
(start, end)
} else {
// Fetch messages starting from the oldest (ascending order)
let start = (page - 1) * page_size + 1;
if start > total {
return Ok(0);
}
// Calculate end, capped by the total number of messages
let end = (start + page_size - 1).min(total);
(start, end)
};
// Fetch messages starting from the oldest (ascending order).
let start = (page - 1) * page_size + 1;
if start > total {
return Ok(0);
}
let end = (start + page_size - 1).min(total);
let sequence_set = format!("{}:{}", start, end);
info!(
"Fetching mailbox '{}' messages: sequence {} (page {}, page_size {}, desc={})",
encoded_mailbox_name, sequence_set, page, page_size, desc
"Fetching mailbox '{}' messages: sequence {} (page {}, page_size {})",
encoded_mailbox_name, sequence_set, page, page_size
);
let mut stream = session
.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);
return Err(raise_error!(
"Stream cancelled".into(),
ErrorCode::InternalError
));
}
if let Some(uid) = fetch.uid {
*max_uid = Some((*max_uid).unwrap_or(0).max(uid));
}
extract_envelope_and_store_it(fetch, account_id, mailbox_id).await?;
count += 1;
}
Ok(count)
}
pub async fn uid_batch_retrieve_emails(
session: &mut Session<Box<dyn SessionStream>>,
account_id: u64,
mailbox_id: u64,
uid_set: &str,
max_email_size_bytes: Option<u64>,
token: CancellationToken,
) -> 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), classify_imap_error(&e)))?;
let mut count = 0u64;
while let Some(fetch) = body_stream
.try_next()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
{
if token.is_cancelled() {
tracing::info!("Account {}: UID fetch stream interrupted.", account_id);
@@ -261,32 +495,56 @@ impl ImapExecutor {
Ok(count)
}
pub async fn uid_batch_retrieve_emails(
/// Fetches the raw RFC822 body of a single message by UID.
///
/// Selects (read-only) the given mailbox and issues `UID FETCH <uid> (BODY.PEEK[])`.
/// Used for on-demand self-healing when an indexed message's content blob is missing.
/// Returns the raw bytes, or an error if the message cannot be retrieved.
pub async fn fetch_single_message_body(
session: &mut Session<Box<dyn SessionStream>>,
account_id: u64,
mailbox_id: u64,
uid_set: &str,
token: CancellationToken,
) -> BichonResult<()> {
let mut stream = session
.uid_fetch(uid_set, BODY_FETCH_COMMAND)
encoded_mailbox_name: &str,
uid: u32,
) -> BichonResult<Vec<u8>> {
session
.examine(encoded_mailbox_name)
.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 stream = session
.uid_fetch(uid.to_string(), BODY_FETCH_COMMAND)
.await
.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))?
{
if token.is_cancelled() {
tracing::info!("Account {}: UID fetch stream interrupted.", account_id);
return Err(raise_error!(
"Stream cancelled".into(),
ErrorCode::InternalError
));
}
extract_envelope_and_store_it(fetch, account_id, mailbox_id).await?;
}
Ok(())
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
.ok_or_else(|| {
raise_error!(
format!("UID {uid} not found on IMAP server"),
ErrorCode::ResourceNotFound
)
})?;
let body = fetch
.body()
.ok_or_else(|| {
raise_error!(
format!("No body returned for UID {uid}"),
ErrorCode::ImapUnexpectedResult
)
})?
.to_vec();
// // Drain any remaining items so the stream is fully consumed before reuse.
// while stream
// .try_next()
// .await
// .map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
// .is_some()
// {}
Ok(body)
}
pub async fn create_connection(
@@ -294,4 +552,250 @@ 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
/// comma-separated (e.g. `1:5,10,12:15`).
pub fn compress_uid_list(nums: Vec<u32>) -> String {
if nums.is_empty() {
return String::new();
}
let mut sorted_nums = nums;
sorted_nums.sort();
let mut result = Vec::new();
let mut current_range_start = sorted_nums[0];
let mut current_range_end = sorted_nums[0];
for &n in sorted_nums.iter().skip(1) {
if n == current_range_end + 1 {
current_range_end = n;
} else {
if current_range_start == current_range_end {
result.push(current_range_start.to_string());
} else {
result.push(format!("{}:{}", current_range_start, current_range_end));
}
current_range_start = n;
current_range_end = n;
}
}
if current_range_start == current_range_end {
result.push(current_range_start.to_string());
} else {
result.push(format!("{}:{}", current_range_start, current_range_end));
}
result.join(",")
}
/// Splits a sorted list of unique UIDs into compressed sequence-set batches.
/// Returns `Vec<(sequence_set_string, batch_count)>`.
pub fn generate_uid_sequence_hashset(
unique_nums: Vec<u32>,
chunk_size: usize,
) -> Vec<(String, u64)> {
assert!(!unique_nums.is_empty());
let mut result = Vec::new();
let nums = unique_nums;
for chunk in nums.chunks(chunk_size) {
let size = chunk.len() as u64;
let compressed = compress_uid_list(chunk.to_vec());
result.push((compressed, size));
}
result
}
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::*;
// ── compress_uid_list ──────────────────────────────────────────
#[test]
fn compress_empty() {
assert_eq!(compress_uid_list(vec![]), "");
}
#[test]
fn compress_single_uid() {
assert_eq!(compress_uid_list(vec![42]), "42");
}
#[test]
fn compress_consecutive_range() {
assert_eq!(compress_uid_list(vec![1, 2, 3, 4, 5]), "1:5");
}
#[test]
fn compress_mixed_ranges() {
assert_eq!(
compress_uid_list(vec![1, 2, 3, 5, 7, 8, 9, 10]),
"1:3,5,7:10"
);
}
#[test]
fn compress_gap_at_boundary() {
assert_eq!(compress_uid_list(vec![1, 2, 4, 5]), "1:2,4:5");
}
// ── generate_uid_sequence_hashset ──────────────────────────────
#[test]
fn batch_single_chunk() {
let batches = generate_uid_sequence_hashset(vec![1, 2, 3], 10);
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].0, "1:3");
assert_eq!(batches[0].1, 3);
}
#[test]
fn batch_multiple_chunks() {
let batches = generate_uid_sequence_hashset(vec![1, 2, 3, 4, 5], 2);
assert_eq!(batches.len(), 3);
assert_eq!(batches[0].0, "1:2");
assert_eq!(batches[0].1, 2);
assert_eq!(batches[1].0, "3:4");
assert_eq!(batches[1].1, 2);
assert_eq!(batches[2].0, "5");
assert_eq!(batches[2].1, 1);
}
// ── 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

@@ -0,0 +1,127 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
use crate::database::MemDbModel;
use crate::import::{ImportProgress, ImportStatus};
use serde::{Deserialize, Serialize};
/// Maximum number of import history entries to keep per user.
pub const MAX_HISTORY_PER_USER: usize = 5;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct ImportHistory {
/// Composite key: "{user_id}:{import_id}"
pub id: String,
pub user_id: u64,
pub import_id: String,
pub account_id: u64,
pub folder: String,
pub format: String,
pub status: String,
pub total: usize,
pub success: usize,
pub duplicates: usize,
pub failed: usize,
pub failed_details: Vec<crate::import::FailedItemDetail>,
/// Unix timestamp in milliseconds.
pub created_at: i64,
}
impl MemDbModel for ImportHistory {
fn collection() -> &'static str {
"import_history"
}
fn key(&self) -> String {
self.id.clone()
}
}
impl ImportHistory {
pub fn from_progress(
user_id: u64,
import_id: &str,
account_id: u64,
folder: &str,
progress: &ImportProgress,
) -> Self {
Self {
id: format!("{}:{}", user_id, import_id),
user_id,
import_id: import_id.to_string(),
account_id,
folder: folder.to_string(),
format: progress.format.clone(),
status: match progress.status {
ImportStatus::Pending => "pending",
ImportStatus::Processing => "processing",
ImportStatus::Completed => "completed",
ImportStatus::Failed => "failed",
}
.to_string(),
total: progress.total,
success: progress.success,
duplicates: progress.duplicates,
failed: progress.failed,
failed_details: progress.failed_details.clone(),
created_at: crate::utc_now!(),
}
}
}
/// Prune old entries for a user so only the latest `MAX_HISTORY_PER_USER` remain.
pub fn prune_user_history(user_id: u64) -> crate::error::BichonResult<()> {
use crate::database::manager::DB_MANAGER;
use crate::database::batch_delete_impl;
use crate::raise_error;
use crate::error::code::ErrorCode;
let db = DB_MANAGER.db();
let coll = db.collection(ImportHistory::collection());
let prefix = format!("{}:", user_id);
let mut entries: Vec<ImportHistory> = coll
.scan_prefix(&prefix)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if entries.len() <= MAX_HISTORY_PER_USER {
return Ok(());
}
// Sort by created_at descending (newest first), keep the first N
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let to_delete: Vec<String> = entries
.iter()
.skip(MAX_HISTORY_PER_USER)
.map(|e| e.id.clone())
.collect();
if !to_delete.is_empty() {
batch_delete_impl::<ImportHistory>(db, to_delete)?;
}
Ok(())
}
/// Save an import history record and prune old entries for the user.
pub fn save_import_history(
user_id: u64,
account_id: u64,
folder: &str,
progress: &ImportProgress,
) {
use crate::database::manager::DB_MANAGER;
use crate::database::upsert_impl;
let entry = ImportHistory::from_progress(user_id, &progress.import_id, account_id, folder, progress);
let db = DB_MANAGER.db();
if let Err(e) = upsert_impl::<ImportHistory>(db, entry) {
tracing::error!("Failed to save import history: {:?}", e);
return;
}
if let Err(e) = prune_user_history(user_id) {
tracing::warn!("Failed to prune import history: {:?}", e);
}
}

View File

@@ -18,7 +18,16 @@
//use poem_openapi::Object;
pub mod history;
pub mod reader;
pub mod pst;
pub use history::ImportHistory;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
path::Path,
sync::RwLock,
};
use crate::{
base64_decode_url_safe,
@@ -27,15 +36,20 @@ use crate::{
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
envelope::extractor::extract_envelope_from_eml,
error::{BichonResult, code::ErrorCode},
settings::dir::DATA_DIR_MANAGER,
utils::create_hash,
},
raise_error,
};
/// Skip individual emails larger than this after decoding (100 MB).
/// Maximum byte size of an individual email message after splitting (100 MB).
const MAX_SINGLE_EML_BYTES: usize = 100 * 1024 * 1024;
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
/// Max file size accepted via the web upload endpoint.
pub const MAX_WEB_EML_BYTES: usize = 100 * 1024 * 1024; // 100 MB
pub const MAX_WEB_MBOX_BYTES: usize = 1024 * 1024 * 1024; // 1 GB
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct BatchEmlRequest {
pub account_id: u64,
@@ -46,24 +60,26 @@ pub struct BatchEmlRequest {
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct FailedEmlDetail {
/// The 0-based index of the failed EML in the request list
pub struct FailedItemDetail {
/// The index (0-based) of the failed item.
pub index: usize,
/// The error message that caused the import to fail
/// The error message that caused the import to fail.
pub error_message: String,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct BatchEmlResult {
/// Total number of emails processed
/// Total number of emails processed.
pub total: usize,
/// Number of emails successfully imported
/// Number of emails successfully imported.
pub success: usize,
/// Number of emails failed to import
/// Number of duplicate emails skipped (content hash already existed).
pub duplicates: usize,
/// Number of emails failed to import.
pub failed: usize,
/// A list of details for failed imports
pub failed_details: Vec<FailedEmlDetail>,
/// A list of details for failed imports.
pub failed_details: Vec<FailedItemDetail>,
}
pub struct ImportEmls;
@@ -105,6 +121,7 @@ impl ImportEmls {
unseen: None,
uid_next: None,
uid_validity: None,
highest_uid: None,
};
let mailbox_id = mailbox.id;
// Upsert the mailbox, creating it if it doesn't exist
@@ -115,7 +132,7 @@ impl ImportEmls {
let account_id = account.id;
let mut success_count = 0;
let mut failed_details: Vec<FailedEmlDetail> = Vec::new(); // Store failure details
let mut failed_details: Vec<FailedItemDetail> = Vec::new(); // Store failure details
let total = request.emls.len();
let mut index: usize = 0;
@@ -126,7 +143,7 @@ impl ImportEmls {
let error_msg =
format!("Failed to decode base64 EML at index {}: {:?}", index, e);
tracing::error!("{}", error_msg);
failed_details.push(FailedEmlDetail {
failed_details.push(FailedItemDetail {
index,
error_message: error_msg,
});
@@ -143,7 +160,7 @@ impl ImportEmls {
index, size_mb,
);
tracing::warn!("{}", error_msg);
failed_details.push(FailedEmlDetail {
failed_details.push(FailedItemDetail {
index,
error_message: error_msg,
});
@@ -161,7 +178,7 @@ impl ImportEmls {
index, e
);
tracing::error!("{}", error_msg);
failed_details.push(FailedEmlDetail {
failed_details.push(FailedItemDetail {
index,
error_message: error_msg,
});
@@ -177,8 +194,650 @@ impl ImportEmls {
Ok(BatchEmlResult {
total,
success: success_count,
duplicates: 0,
failed: failed_count,
failed_details, // Return the list of failure details
})
}
}
// ── File upload import ──────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum ImportStatus {
Pending,
Processing,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct ImportProgress {
pub import_id: String,
pub status: ImportStatus,
pub format: String,
pub total: usize,
pub success: usize,
pub duplicates: usize,
pub failed: usize,
pub failed_details: Vec<FailedItemDetail>,
}
static PROGRESS_STORE: std::sync::LazyLock<RwLock<HashMap<String, ImportProgress>>> =
std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
pub fn get_import_progress(import_id: &str) -> Option<ImportProgress> {
PROGRESS_STORE.read().ok()?.get(import_id).cloned()
}
pub fn update_progress(import_id: &str, progress: ImportProgress) {
if let Ok(mut store) = PROGRESS_STORE.write() {
store.insert(import_id.to_string(), progress);
}
}
/// Check free disk space (in bytes) on the temp directory's filesystem.
pub fn check_temp_disk_space() -> BichonResult<u64> {
use sysinfo::Disks;
let disks = Disks::new_with_refreshed_list();
let temp_path = &DATA_DIR_MANAGER.temp_dir;
// Use the canonical path so we can match mount points
let canonical = std::fs::canonicalize(temp_path).unwrap_or_else(|_| temp_path.clone());
for disk in disks.list() {
if canonical.starts_with(disk.mount_point()) {
return Ok(disk.available_space());
}
}
// Fallback: if we can't find the mount point, report plenty of space
Ok(u64::MAX)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileFormat {
Eml,
Mbox,
Pst,
}
pub fn detect_format(bytes: &[u8], file_name: &str) -> Option<FileFormat> {
// PST files start with OLE2 compound document magic bytes
if bytes.len() >= 8 && &bytes[..8] == b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1" {
return Some(FileFormat::Pst);
}
// MBOX files start with "From " (note the trailing space after From)
if bytes.starts_with(b"From ") {
// Double-check: look for a valid date after the first "From " line
// MBOX format: "From sender@host DayOfWeek Mon DD HH:MM:SS YYYY"
if let Some(first_newline) = bytes.iter().position(|&b| b == b'\n') {
let from_line = std::str::from_utf8(&bytes[..first_newline]).unwrap_or("");
let parts: Vec<&str> = from_line.split_whitespace().collect();
if parts.len() >= 7 {
return Some(FileFormat::Mbox);
}
}
}
// EML: starts with a header line or "Return-Path:", "Received:", "From:", "Date:", etc.
// Or check extension
if bytes.starts_with(b"Return-Path:")
|| bytes.starts_with(b"Received:")
|| bytes.starts_with(b"Date:")
|| bytes.starts_with(b"From:")
|| bytes.starts_with(b"Subject:")
|| bytes.starts_with(b"To:")
|| bytes.starts_with(b"Message-ID:")
{
return Some(FileFormat::Eml);
}
// Fallback: check file extension
let lower = file_name.to_lowercase();
if lower.ends_with(".eml") {
Some(FileFormat::Eml)
} else if lower.ends_with(".mbox") {
Some(FileFormat::Mbox)
} else if lower.ends_with(".pst") {
Some(FileFormat::Pst)
} else {
None
}
}
/// Check whether `bytes` looks like a text file by inspecting the first chunk.
/// Returns `true` if it passes, `false` if it appears to be binary (video, executable, etc.).
///
/// Email files (EML/MBOX) are text-based with printable ASCII, whitespace, and
/// optional UTF-8. Binary files like video contain null bytes and high ratios of
/// non-printable control characters.
pub fn detect_text_file(bytes: &[u8]) -> bool {
let check_len = bytes.len().min(8192);
if check_len == 0 {
return false;
}
let sample = &bytes[..check_len];
// Null bytes are a strong binary indicator
if sample.contains(&0x00) {
return false;
}
let mut printable = 0usize;
let mut total = 0usize;
let mut i = 0;
while i < sample.len() {
total += 1;
let b = sample[i];
if b.is_ascii_graphic() || b.is_ascii_whitespace() {
// Printable ASCII + whitespace (space, tab, CR, LF)
printable += 1;
} else if b == 0x1b {
// ESC — common in terminal sequences, rare in email
// Count as printable to avoid false positives
printable += 1;
} else if b >= 0x80 {
// UTF-8 continuation or multi-byte lead byte — allow.
// Check that we have a valid UTF-8 sequence ahead.
let seq_len = match b {
b if b & 0xE0 == 0xC0 => 2,
b if b & 0xF0 == 0xE0 => 3,
b if b & 0xF8 == 0xF0 => 4,
_ => 0,
};
if seq_len > 0 && i + seq_len <= sample.len() {
let valid = std::str::from_utf8(&sample[i..i + seq_len]).is_ok();
if valid {
printable += 1;
i += 1; // lead byte counted, continuations counted in loop
}
// if invalid, don't count as printable
}
// standalone continuation byte — not printable
}
// Other control characters (0x01-0x1F except whitespace/Esc) are not counted as printable
i += 1;
}
// Require at least 90% printable characters
printable as f64 / total as f64 >= 0.90
}
/// Validate that the target account exists, is enabled, and is NoSync type.
fn validate_import_account(account_id: u64) -> BichonResult<AccountModel> {
let account = AccountModel::check_account_exists(account_id)?;
if !account.enabled {
return Err(raise_error!(
"The account is disabled.".into(),
ErrorCode::InvalidParameter
));
}
if !matches!(account.account_type, AccountType::NoSync) {
return Err(raise_error!(
"Import is only allowed for NoSync accounts. IMAP accounts sync from the server.".into(),
ErrorCode::InvalidParameter
));
}
Ok(account)
}
/// Resolve or create a mailbox/folder for the given account.
pub(super) fn resolve_mailbox(account: &AccountModel, folder: &str) -> BichonResult<u64> {
match account.account_type {
AccountType::IMAP => {
// Shouldn't reach here (validated above), but handle gracefully
let all_mailboxes = MailBox::list_all(account.id)?;
let mailbox = all_mailboxes.into_iter().find(|m| m.name == folder);
match mailbox {
Some(m) => Ok(m.id),
None => Err(raise_error!(
format!("Mail folder '{}' not found.", folder).into(),
ErrorCode::ResourceNotFound
)),
}
}
AccountType::NoSync => {
let mailbox = MailBox {
id: create_hash(account.id, folder),
account_id: account.id,
name: folder.to_string(),
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;
MailBox::batch_upsert(&[mailbox])?;
Ok(mailbox_id)
}
}
}
/// Resolve or create a mailbox for a given account_id and folder name.
/// Used by PST import to create per-folder mailboxes.
pub fn resolve_mailbox_by_account_id(account_id: u64, folder: &str) -> BichonResult<u64> {
let account = AccountModel::check_account_exists(account_id)?;
resolve_mailbox(&account, folder)
}
/// Process an uploaded file (EML or MBOX) and import into the given account/folder.
/// This runs synchronously and should be spawned on a background thread.
///
/// For MBOX files, the file is memory-mapped via `memmap2` and messages are yielded
/// one at a time — the full file is never loaded into RAM. Individual messages
/// exceeding `MAX_SINGLE_EML_BYTES` (100 MB) are skipped.
pub fn process_uploaded_file(
import_id: &str,
file_path: &Path,
file_name: &str,
account_id: u64,
folder: &str,
user_id: u64,
) {
let account = match validate_import_account(account_id) {
Ok(a) => a,
Err(e) => {
let progress = ImportProgress {
import_id: import_id.to_string(),
status: ImportStatus::Failed,
format: "unknown".to_string(),
total: 0,
success: 0,
duplicates: 0,
failed: 0,
failed_details: vec![FailedItemDetail {
index: 0,
error_message: format!("Account validation failed: {:?}", e),
}],
};
update_progress(import_id, progress.clone());
history::save_import_history(user_id, account_id, folder, &progress);
return;
}
};
let mailbox_id = match resolve_mailbox(&account, folder) {
Ok(id) => id,
Err(e) => {
let progress = ImportProgress {
import_id: import_id.to_string(),
status: ImportStatus::Failed,
format: "unknown".to_string(),
total: 0,
success: 0,
duplicates: 0,
failed: 0,
failed_details: vec![FailedItemDetail {
index: 0,
error_message: format!("Mailbox resolution failed: {:?}", e),
}],
};
update_progress(import_id, progress.clone());
history::save_import_history(user_id, account_id, folder, &progress);
return;
}
};
// Read a small prefix for format detection
let format = match detect_format_from_file(file_path, file_name) {
Ok(f) => f,
Err(e) => {
let progress = ImportProgress {
import_id: import_id.to_string(),
status: ImportStatus::Failed,
format: "unknown".to_string(),
total: 0,
success: 0,
duplicates: 0,
failed: 0,
failed_details: vec![FailedItemDetail {
index: 0,
error_message: format!("{:?}", e),
}],
};
update_progress(import_id, progress.clone());
history::save_import_history(user_id, account_id, folder, &progress);
let _ = std::fs::remove_file(file_path);
return;
}
};
match format {
FileFormat::Eml => process_eml_file(import_id, file_path, account_id, mailbox_id, user_id, folder),
FileFormat::Mbox => process_mbox_file(import_id, file_path, account_id, mailbox_id, user_id, folder),
FileFormat::Pst => process_pst_upload(import_id, file_path, account_id, mailbox_id, user_id, folder),
}
}
/// Detect format from a file by reading only the first few KB.
fn detect_format_from_file(file_path: &Path, file_name: &str) -> BichonResult<FileFormat> {
use std::io::Read;
let mut file = std::fs::File::open(file_path).map_err(|e| {
raise_error!(format!("Failed to open file: {}", e), ErrorCode::InternalError)
})?;
let mut buf = vec![0u8; 8192];
let n = file.read(&mut buf).unwrap_or(0);
buf.truncate(n);
detect_format(&buf, file_name).ok_or_else(|| {
raise_error!(
"Unknown file format. Supported: .eml, .mbox, .pst".into(),
ErrorCode::InvalidParameter
)
})
}
/// Process a single EML file. The file is at most `MAX_WEB_EML_BYTES` (100 MB),
/// so reading it entirely is safe.
fn process_eml_file(
import_id: &str,
file_path: &Path,
account_id: u64,
mailbox_id: u64,
user_id: u64,
folder: &str,
) {
let file_bytes = match std::fs::read(file_path) {
Ok(b) => b,
Err(e) => {
fail_progress(import_id, "eml", &format!("Failed to read file: {}", e), user_id, account_id, folder);
let _ = std::fs::remove_file(file_path);
return;
}
};
let total = 1;
update_progress(import_id, ImportProgress {
import_id: import_id.to_string(),
status: ImportStatus::Processing,
format: "eml".to_string(),
total,
success: 0,
duplicates: 0,
failed: 0,
failed_details: vec![],
});
let (success_count, failed_details) = process_single_eml(&file_bytes, 0, account_id, mailbox_id);
// Clean up
let _ = std::fs::remove_file(file_path);
let final_progress = ImportProgress {
import_id: import_id.to_string(),
status: ImportStatus::Completed,
format: "eml".to_string(),
total,
success: success_count,
duplicates: 0,
failed: failed_details.len(),
failed_details,
};
history::save_import_history(user_id, account_id, folder, &final_progress);
update_progress(import_id, final_progress);
}
/// Process an MBOX file using memory-mapped I/O. Messages are yielded one at a
/// time by `MboxReader` — the full file is never loaded into RAM.
fn process_mbox_file(
import_id: &str,
file_path: &Path,
account_id: u64,
mailbox_id: u64,
user_id: u64,
folder: &str,
) {
let mbox = match reader::MboxFile::from_file(file_path) {
Ok(m) => m,
Err(e) => {
fail_progress(import_id, "mbox", &format!("Failed to open MBOX file: {}", e), user_id, account_id, folder);
let _ = std::fs::remove_file(file_path);
return;
}
};
// First pass: count total messages (MboxReader is lazy, so this is O(n) but cheap)
let total = mbox.iter().count();
update_progress(import_id, ImportProgress {
import_id: import_id.to_string(),
status: ImportStatus::Processing,
format: "mbox".to_string(),
total,
success: 0,
duplicates: 0,
failed: 0,
failed_details: vec![],
});
let mut success_count = 0usize;
let mut failed_details: Vec<FailedItemDetail> = Vec::new();
for (index, entry) in mbox.iter().enumerate() {
let eml_bytes = entry.data;
if eml_bytes.len() > MAX_SINGLE_EML_BYTES {
let size_mb = eml_bytes.len() as f64 / 1024.0 / 1024.0;
failed_details.push(FailedItemDetail {
index,
error_message: format!(
"Email at index {} is {:.1} MB (limit {} MB). Skipping.",
index,
size_mb,
MAX_SINGLE_EML_BYTES / 1024 / 1024
),
});
continue;
}
match futures::executor::block_on(extract_envelope_from_eml(eml_bytes, account_id, mailbox_id)) {
Ok(_) => {
success_count += 1;
}
Err(e) => {
failed_details.push(FailedItemDetail {
index,
error_message: format!("{:?}", e),
});
}
};
// Update progress every 100 items
if index % 100 == 0 || index == total - 1 {
update_progress(import_id, ImportProgress {
import_id: import_id.to_string(),
status: ImportStatus::Processing,
format: "mbox".to_string(),
total,
success: success_count,
duplicates: 0,
failed: failed_details.len(),
failed_details: failed_details.clone(),
});
}
}
// Clean up temp file (drop the mmap first — MboxFile owns it)
drop(mbox);
let _ = std::fs::remove_file(file_path);
let final_progress = ImportProgress {
import_id: import_id.to_string(),
status: ImportStatus::Completed,
format: "mbox".to_string(),
total,
success: success_count,
duplicates: 0,
failed: failed_details.len(),
failed_details,
};
history::save_import_history(user_id, account_id, folder, &final_progress);
update_progress(import_id, final_progress);
}
/// Process a single EML byte slice and return (success_count, failed_details).
fn process_single_eml(
eml_bytes: &[u8],
index: usize,
account_id: u64,
mailbox_id: u64,
) -> (usize, Vec<FailedItemDetail>) {
if eml_bytes.len() > MAX_SINGLE_EML_BYTES {
let size_mb = eml_bytes.len() as f64 / 1024.0 / 1024.0;
return (0, vec![FailedItemDetail {
index,
error_message: format!(
"Email is {:.1} MB (limit {} MB). Skipping.",
size_mb,
MAX_SINGLE_EML_BYTES / 1024 / 1024
),
}]);
}
match futures::executor::block_on(extract_envelope_from_eml(eml_bytes, account_id, mailbox_id)) {
Ok(_) => (1, vec![]),
Err(e) => (0, vec![FailedItemDetail {
index,
error_message: format!("{:?}", e),
}]),
}
}
/// Process a PST file uploaded via the web UI.
/// Two-pass approach: count messages first, then process with periodic progress updates.
fn process_pst_upload(
import_id: &str,
file_path: &Path,
account_id: u64,
_mailbox_id: u64, // ignored; PST creates its own mailboxes per folder
user_id: u64,
folder: &str,
) {
// Pass 1: count total messages
let total = match pst::count_pst_messages(file_path) {
Ok(n) => n,
Err(e) => {
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
let _ = std::fs::remove_file(file_path);
return;
}
};
update_progress(import_id, ImportProgress {
import_id: import_id.to_string(),
status: ImportStatus::Processing,
format: "pst".to_string(),
total,
success: 0,
duplicates: 0,
failed: 0,
failed_details: vec![],
});
// Pass 2: process messages with progress updates
let mut success_count: usize = 0;
let mut failed_details: Vec<FailedItemDetail> = Vec::new();
let mut index: usize = 0;
let pst_store = match outlook_pst::open_store(file_path) {
Ok(s) => s,
Err(e) => {
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
let _ = std::fs::remove_file(file_path);
return;
}
};
let ipm_sub_tree = match pst_store.properties().ipm_sub_tree_entry_id() {
Ok(id) => id,
Err(e) => {
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
let _ = std::fs::remove_file(file_path);
return;
}
};
let ipm_subtree_folder = match pst_store.open_folder(&ipm_sub_tree) {
Ok(f) => f,
Err(e) => {
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
let _ = std::fs::remove_file(file_path);
return;
}
};
// Progress callback: update progress every 50 messages
let import_id = import_id.to_string();
let format_str = "pst".to_string();
pst::process_folder_with_progress(
&ipm_subtree_folder,
"", // parent_path starts empty
account_id,
total, // pass pre-counted total for accurate progress
&mut success_count,
&mut failed_details,
&mut index,
&|processed, actual_failed| {
update_progress(&import_id, ImportProgress {
import_id: import_id.clone(),
status: ImportStatus::Processing,
format: format_str.clone(),
total,
success: processed - actual_failed,
duplicates: 0,
failed: actual_failed,
failed_details: vec![],
});
},
);
// Clean up temp file
let _ = std::fs::remove_file(file_path);
let final_progress = ImportProgress {
import_id: import_id.to_string(),
status: ImportStatus::Completed,
format: "pst".to_string(),
total,
success: success_count,
duplicates: 0,
failed: failed_details.len(),
failed_details,
};
history::save_import_history(user_id, account_id, folder, &final_progress);
update_progress(&import_id, final_progress);
}
/// Record a fatal failure and save history.
fn fail_progress(
import_id: &str,
format: &str,
message: &str,
user_id: u64,
account_id: u64,
folder: &str,
) {
let progress = ImportProgress {
import_id: import_id.to_string(),
status: ImportStatus::Failed,
format: format.to_string(),
total: 0,
success: 0,
duplicates: 0,
failed: 0,
failed_details: vec![FailedItemDetail {
index: 0,
error_message: message.to_string(),
}],
};
update_progress(import_id, progress.clone());
history::save_import_history(user_id, account_id, folder, &progress);
}

View File

@@ -16,8 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use compressed_rtf::*;
use outlook_pst::ltp::prop_context::PropertyValue;
pub fn decode_subject(value: &PropertyValue) -> Option<String> {
@@ -60,5 +58,5 @@ pub fn decode_html_body(buffer: &[u8], code_page: u16) -> Option<String> {
}
pub fn decode_rtf_compressed(buffer: &[u8]) -> Option<String> {
decompress_rtf(buffer).ok()
compressed_rtf::decompress_rtf(buffer).ok()
}

View File

@@ -0,0 +1,486 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::base64_encode_url_safe;
use crate::envelope::extractor::extract_envelope_from_eml;
use chrono::{DateTime, TimeZone, Utc};
use mail_send::mail_builder::headers::text::Text;
use mail_send::mail_builder::MessageBuilder;
use outlook_pst::ltp::prop_context::PropertyValue;
use outlook_pst::messaging::attachment::AttachmentProperties;
use outlook_pst::messaging::folder::Folder;
use outlook_pst::messaging::message::{Message, MessageProperties};
use outlook_pst::ndb::node_id::NodeId;
use std::rc::Rc;
mod encoding;
/// Convert a PST Message into a base64-encoded EML string.
pub fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
let properties = message.properties();
let mut builder = MessageBuilder::new();
if let Some(sub) = extract_subject(properties) {
builder = builder.subject(sub);
}
if let Some(mid) = extract_string_property(properties, 0x1035) {
builder = builder.message_id(mid);
}
if let Some(irt) = extract_string_property(properties, 0x1042) {
builder = builder.in_reply_to(irt);
}
if let Some(refs) = extract_string_property(properties, 0x1039) {
builder = builder.header("References", Text::new(refs));
}
if let Some(cid_val) = properties.get(0x3013) {
if let PropertyValue::Binary(bin) = cid_val {
builder = builder.header(
"X-Bichon-Conversation-ID",
Text::new(hex::encode(bin.buffer())),
);
}
}
let from = extract_string_property(properties, 0x5D01)
.or_else(|| extract_string_property(properties, 0x5D02))
.or_else(|| extract_string_property(properties, 0x0C1F));
if let Some(f) = from {
builder = builder.from(f);
}
if let Some(filetime) = extract_i64_property(properties, &[0x0039, 0x0E06]) {
let dt = filetime_to_datetime(filetime).timestamp();
builder = builder.date(dt);
}
let (to, cc, bcc) = extract_recipients_list(&message);
if !to.is_empty() {
builder = builder.to(to.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if !cc.is_empty() {
builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if !bcc.is_empty() {
builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if let Some(html) = extract_html(properties) {
builder = builder.html_body(html);
}
if let Some(text) = extract_text(properties) {
builder = builder.text_body(text);
}
if let Some(attachment_table) = message.attachment_table() {
for row in attachment_table.rows_matrix() {
let node_id = NodeId::from(u32::from(row.id()));
if let Ok(attachment) = message.clone().read_attachment(node_id, None) {
let att_props = attachment.properties();
let name = extract_attachment_string_property(att_props, 0x3707);
let mime = extract_attachment_string_property(att_props, 0x370E)
.unwrap_or_else(|| "application/octet-stream".into());
let cid = extract_attachment_string_property(att_props, 0x3712);
let is_inline = att_props
.get(0x3714)
.and_then(|val| {
if let PropertyValue::Integer32(f) = val {
Some(f)
} else {
None
}
})
.map(|flag| (flag & 0x4) != 0)
.unwrap_or(false);
if let Some(PropertyValue::Binary(bin)) = att_props.get(0x3701) {
let data = bin.buffer().to_vec();
let file_name = name.unwrap_or_else(|| "unnamed_attachment".to_string());
if is_inline && cid.is_some() {
let content_id = cid.unwrap();
builder = builder.inline(mime, content_id, data);
} else {
builder = builder.attachment(mime, file_name, data);
}
}
}
}
}
match builder.write_to_vec() {
Ok(eml_vec) => Some(base64_encode_url_safe!(eml_vec)),
Err(e) => {
tracing::error!("Failed to generate EML from PST message: {:?}", e);
None
}
}
}
fn filetime_to_datetime(filetime: i64) -> DateTime<Utc> {
let unix_secs = (filetime / 10_000_000) - 11_644_473_600;
let nsecs = (filetime % 10_000_000) * 100;
Utc.timestamp_opt(unix_secs, nsecs as u32).unwrap()
}
fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<String>, Vec<String>) {
let mut to = Vec::new();
let mut cc = Vec::new();
let mut bcc = Vec::new();
let recipient_table = message.recipient_table();
if let Some(recipient_table) = recipient_table {
let context = recipient_table.context();
for row in recipient_table.rows_matrix() {
if let Ok(cols) = row.columns(context) {
let mut r_type = 0;
let mut email = String::new();
for (col, val) in context.columns().iter().zip(cols) {
let prop_val = val
.as_ref()
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
match col.prop_id() {
0x0C15 => {
if let Some(PropertyValue::Integer32(t)) = prop_val {
r_type = t;
}
}
0x39FE | 0x3003 => {
if let Some(s) = prop_val.and_then(|v| extract_string(&v)) {
email = s;
}
}
_ => {}
}
}
if !email.is_empty() {
match r_type {
1 => to.push(email),
2 => cc.push(email),
3 => bcc.push(email),
_ => {}
}
}
}
}
} else {
let receiver = extract_string_property(message.properties(), 0x0076);
if let Some(receiver) = receiver {
to.push(receiver);
}
}
(to, cc, bcc)
}
fn extract_subject(props: &MessageProperties) -> Option<String> {
props.get(0x0037).and_then(|val| encoding::decode_subject(val))
}
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
properties
.get(prop_id)
.and_then(|value| extract_string(value))
}
fn extract_attachment_string_property(
properties: &AttachmentProperties,
prop_id: u16,
) -> Option<String> {
properties
.get(prop_id)
.and_then(|value| extract_string(value))
}
fn extract_string(value: &PropertyValue) -> Option<String> {
match value {
PropertyValue::String8(value) => Some(value.to_string()),
PropertyValue::Unicode(value) => Some(value.to_string()),
_ => None,
}
}
fn extract_text(properties: &MessageProperties) -> Option<String> {
properties.get(0x1000).and_then(extract_string).or_else(|| {
properties.get(0x1009).and_then(|value| match value {
PropertyValue::Binary(value) => encoding::decode_rtf_compressed(value.buffer()),
_ => None,
})
})
}
fn extract_html(properties: &MessageProperties) -> Option<String> {
properties.get(0x1013).and_then(|value| match value {
PropertyValue::Binary(value) => {
let code_page = properties
.get(0x3FDE)
.and_then(|v| {
if let PropertyValue::Integer32(cpid) = v {
Some(*cpid as u16)
} else {
None
}
})
.unwrap_or(65001);
encoding::decode_html_body(value.buffer(), code_page)
}
PropertyValue::String8(value) => Some(value.to_string()),
PropertyValue::Unicode(value) => Some(value.to_string()),
_ => None,
})
}
fn extract_i64_property(properties: &MessageProperties, prop_ids: &[u16]) -> Option<i64> {
for &prop_id in prop_ids {
if let Some(PropertyValue::Time(value)) = properties.get(prop_id) {
return Some(*value);
}
}
None
}
/// Open a PST file and count total messages across all folders.
/// Called from the web upload flow to get the total before processing.
pub fn count_pst_messages(pst_path: &std::path::Path) -> crate::error::BichonResult<usize> {
let pst_store = outlook_pst::open_store(pst_path).map_err(|e| {
crate::raise_error!(
format!("Failed to open PST file: {:?}", e),
crate::error::code::ErrorCode::InvalidParameter
)
})?;
let ipm_sub_tree = pst_store.properties().ipm_sub_tree_entry_id().map_err(|e| {
crate::raise_error!(
format!("Could not find IPM_SUBTREE in PST: {:?}", e),
crate::error::code::ErrorCode::InvalidParameter
)
})?;
let ipm_subtree_folder = pst_store.open_folder(&ipm_sub_tree).map_err(|e| {
crate::raise_error!(
format!("Failed to open root mailbox folder: {:?}", e),
crate::error::code::ErrorCode::InvalidParameter
)
})?;
Ok(count_folder_messages(&ipm_subtree_folder))
}
fn count_folder_messages(folder: &Rc<dyn Folder>) -> usize {
let mut count = 0usize;
if let Some(contents_table) = folder.contents_table() {
for row in contents_table.rows_matrix() {
let store = folder.store().clone();
let entry_id = match store
.properties()
.make_entry_id(NodeId::from(u32::from(row.id())))
{
Ok(id) => id,
Err(_) => continue,
};
if store.open_message(&entry_id, None).is_ok() {
count += 1;
}
}
}
if let Some(hierarchy_table) = folder.hierarchy_table() {
for row in hierarchy_table.rows_matrix() {
let node = NodeId::from(u32::from(row.id()));
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
count += count_folder_messages(&sub_folder);
}
}
}
}
count
}
/// Walk all folders and process messages, calling the progress callback
/// every 50 messages. Used by the web upload flow.
pub fn process_folder_with_progress<F>(
folder: &Rc<dyn Folder>,
parent_path: &str,
account_id: u64,
total: usize,
success_count: &mut usize,
failed_details: &mut Vec<super::FailedItemDetail>,
index: &mut usize,
progress_cb: &F,
) where
F: Fn(usize, usize), // (processed, failed)
{
process_folder_with_progress_inner(
folder,
parent_path,
account_id,
total,
success_count,
failed_details,
index,
progress_cb,
);
}
fn process_folder_with_progress_inner<F>(
folder: &Rc<dyn Folder>,
parent_path: &str,
account_id: u64,
total: usize,
success_count: &mut usize,
failed_details: &mut Vec<super::FailedItemDetail>,
index: &mut usize,
progress_cb: &F,
) where
F: Fn(usize, usize),
{
let folder_name = folder
.properties()
.display_name()
.unwrap_or_else(|_| "Unknown".to_string());
let mail_folder = if parent_path.is_empty() {
folder_name
} else {
format!("{}/{}", parent_path, folder_name)
};
tracing::debug!("Processing PST folder: {}", mail_folder);
let mailbox_id = match super::resolve_mailbox_by_account_id(account_id, &mail_folder) {
Ok(id) => id,
Err(e) => {
tracing::error!("Failed to resolve mailbox '{}': {:?}", mail_folder, e);
// Still recurse into sub-folders even if this folder's mailbox creation fails
if let Some(hierarchy_table) = folder.hierarchy_table() {
for row in hierarchy_table.rows_matrix() {
let node = NodeId::from(u32::from(row.id()));
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
process_folder_with_progress_inner(
&sub_folder,
&mail_folder,
account_id,
total,
success_count,
failed_details,
index,
progress_cb,
);
}
}
}
}
return;
}
};
let mut batch_size = 0usize;
if let Some(contents_table) = folder.contents_table() {
for row in contents_table.rows_matrix() {
let store = folder.store().clone();
let entry_id = match store
.properties()
.make_entry_id(NodeId::from(u32::from(row.id())))
{
Ok(id) => id,
Err(e) => {
tracing::warn!("Skip PST row {}: {:?}", row.unique(), e);
continue;
}
};
match store.open_message(&entry_id, None) {
Ok(message) => match build_eml_base64(message) {
Some(base64_eml) => {
let decoded = match crate::base64_decode_url_safe!(base64_eml.as_bytes()) {
Ok(bytes) => bytes,
Err(e) => {
failed_details.push(super::FailedItemDetail {
index: *index,
error_message: format!(
"Failed to decode base64 EML at index {}: {:?}",
*index, e
),
});
*index += 1;
batch_size += 1;
continue;
}
};
match futures::executor::block_on(
extract_envelope_from_eml(&decoded, account_id, mailbox_id)
) {
Ok(_) => {
*success_count += 1;
}
Err(e) => {
failed_details.push(super::FailedItemDetail {
index: *index,
error_message: format!("{:?}", e),
});
}
};
*index += 1;
batch_size += 1;
}
None => {}
},
Err(e) => {
tracing::warn!("Open PST message error: {:?}", e);
}
}
// Report progress every 50 messages
if batch_size % 50 == 0 {
progress_cb(*success_count + failed_details.len(), failed_details.len());
}
}
}
if let Some(hierarchy_table) = folder.hierarchy_table() {
for row in hierarchy_table.rows_matrix() {
let node = NodeId::from(u32::from(row.id()));
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
process_folder_with_progress_inner(
&sub_folder,
&mail_folder,
account_id,
total,
success_count,
failed_details,
index,
progress_cb,
);
}
}
}
}
}

View File

@@ -21,6 +21,8 @@ use std::fs;
use std::io;
use std::path::Path;
/// Memory-mapped MBOX file. Messages are yielded one at a time without
/// loading the entire file into RAM.
pub struct MboxFile {
map: Mmap,
}
@@ -127,10 +129,6 @@ impl<'a> Iterator for MboxReader<'a> {
#[cfg(test)]
mod tests {
use mail_parser::MessageParser;
use crate::mbox::gmail::determine_folder;
use super::*;
fn collect_entries(data: &[u8]) -> Vec<&[u8]> {
@@ -144,6 +142,7 @@ mod tests {
let e = collect_entries(data);
assert_eq!(e, vec![b"mail1\n", b"mail2\n"]);
}
#[test]
fn no_trailing_newline() {
let data = b"From a\nmail1";
@@ -204,22 +203,4 @@ mod tests {
let e = collect_entries(&data);
assert_eq!(e.len(), 1000);
}
#[test]
fn test11() {
let mbox = MboxFile::from_file(Path::new("e:\\test.mbox")).unwrap();
for e in mbox.iter() {
let body = e.data;
let message = MessageParser::new().parse(body).unwrap();
let labels = message.header("X-Gmail-Labels").unwrap().as_text().unwrap();
//println!("offset={} X-Gmail-Labels={:?}", e.offset, labels);
println!(
"X-Gmail-Labels={:?}, determine_folder={}",
labels,
determine_folder(labels)
)
}
}
}

View File

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

View File

@@ -157,8 +157,13 @@ async fn fetch_remote_with_progress(account_id: u64) -> BichonResult<Vec<MailBox
mailbox.account_id = account_id;
mailbox.id = create_hash(account_id, &mailbox.name);
// Use STATUS instead of EXAMINE: gets MESSAGES/UNSEEN/UIDNEXT/UIDVALIDITY
// without selecting the mailbox, avoiding context switches.
let mx = session
.examine(mailbox_name.as_str())
.status(
mailbox_name.as_str(),
"(MESSAGES UNSEEN UIDNEXT UIDVALIDITY)",
)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
mailbox.exists = mx.exists;
@@ -205,8 +210,13 @@ pub async fn convert_names_to_mailboxes(
mailbox.account_id = account_id;
mailbox.id = create_hash(account_id, &mailbox.name);
// Use STATUS instead of EXAMINE: gets MESSAGES/UNSEEN/UIDNEXT/UIDVALIDITY
// without selecting the mailbox, avoiding context switches.
let mx = session
.examine(mailbox_name.as_str())
.status(
mailbox_name.as_str(),
"(MESSAGES UNSEEN UIDNEXT UIDVALIDITY)",
)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
mailbox.exists = mx.exists;

View File

@@ -22,6 +22,7 @@ use crate::envelope::extractor::{extract_envelope_from_nested_message, reattach_
use crate::error::code::ErrorCode;
use crate::store::envelope::Envelope;
use crate::utils::compute_content_hash;
use crate::utils::html::block_remote_content;
use crate::{error::BichonResult, raise_error};
use mail_parser::{MessageParser, MimeHeaders};
//use poem_openapi::Object;
@@ -46,6 +47,14 @@ pub struct AttachmentInfo {
/// Hash of the content.
pub content_hash: String,
pub is_message: bool,
/// Text extracted from the attachment body (Pro/Enterprise feature).
/// Populated during IMAP sync; None for inline attachments and unsupported file types.
pub extracted_text: Option<String>,
/// Page count reported by the extractor, if any.
pub extracted_page_count: Option<u32>,
/// Whether the extracted text came from OCR.
#[serde(default)]
pub extracted_is_ocr: bool,
}
impl AttachmentInfo {
@@ -142,6 +151,9 @@ pub struct FullMessageContent {
pub html: Option<String>,
// all Attachments include inline attachments
pub attachments: Option<Vec<AttachmentInfo>>,
/// True when remote content (http/https URLs) was detected and stripped from html.
#[serde(default)]
pub has_remote_content: bool,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
@@ -155,11 +167,15 @@ pub struct FullNestedMessageContent {
pub attachments: Option<Vec<AttachmentInfo>>,
/// Metadata for the email envelope.
pub envelope: Envelope,
/// True when remote content (http/https URLs) was detected and stripped from html.
#[serde(default)]
pub has_remote_content: bool,
}
pub fn retrieve_email_content(
account_id: u64,
envelope_id: String,
block_remote: bool,
) -> BichonResult<FullMessageContent> {
AccountModel::check_account_exists(account_id)?;
let (envelope, eml) = reattach_eml_content(account_id, envelope_id)?;
@@ -190,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() {
@@ -214,19 +232,31 @@ pub fn retrieve_email_content(
let is_message = attachment.is_message();
let content_hash = compute_content_hash(attachment.contents());
attachments.push(AttachmentInfo {
filename: filename.or(Some(content_hash.clone())), // Fallback to content_hash as the default filename if it is not provided.
filename: filename.or(Some(content_hash.clone())),
size: attachment.contents().len(),
inline,
file_type,
is_message,
content_hash,
content_id: attachment.content_id().map(Into::into),
extracted_text: None,
extracted_page_count: None,
extracted_is_ocr: false,
});
}
let mut has_remote_content = false;
if let Some(ref html_body) = html {
let filtered = block_remote_content(html_body);
has_remote_content = *html_body != filtered;
if block_remote {
html = Some(filtered);
}
}
Ok(FullMessageContent {
text,
html,
attachments: Some(attachments),
has_remote_content,
})
}
@@ -234,6 +264,7 @@ pub fn retrieve_nested_eml_content(
account_id: u64,
envelope_id: String,
content_hash: &str,
block_remote: bool,
) -> BichonResult<FullNestedMessageContent> {
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
let parent_message = MessageParser::default().parse(&eml).ok_or_else(|| {
@@ -273,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();
@@ -302,22 +335,133 @@ pub fn retrieve_nested_eml_content(
filename: attachment
.attachment_name()
.map(|n| n.to_string())
.or(Some(content_hash.clone())), // Fallback to content_hash as the default filename if it is not provided.
.or(Some(content_hash.clone())),
size: attachment.contents().len(),
inline: is_inline,
file_type,
content_hash,
is_message: attachment.is_message(),
content_id: cid.map(Into::into),
extracted_text: None,
extracted_page_count: None,
extracted_is_ocr: false,
});
}
let envelope = extract_envelope_from_nested_message(nested_message, account_id)?;
let mut has_remote_content = false;
if let Some(ref html_body) = html {
let filtered = block_remote_content(html_body);
has_remote_content = *html_body != filtered;
if block_remote {
html = Some(filtered);
}
}
Ok(FullNestedMessageContent {
text,
html,
attachments: Some(attachments),
envelope,
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

@@ -26,6 +26,6 @@ pub async fn delete_messages_impl(request: HashMap<u64, Vec<String>>) -> BichonR
.delete_envelopes_multi_account(request.clone())
.await?;
ATTACHMENT_MANAGER
.delete_envelopes_multi_account(request)
.delete_attachments_multi_account(request)
.await
}

View File

@@ -43,8 +43,20 @@ pub struct EmailSearchFilter {
pub to: Option<String>,
pub cc: Option<String>,
pub bcc: Option<String>,
/// Matches if the address appears in `to`, `cc`, or `bcc` (OR semantics).
pub any_recipient: Option<String>,
/// Matches if the address appears in `from`, `to`, `cc`, or `bcc` (OR semantics).
pub any_participant: Option<String>,
pub since: Option<i64>,
pub before: Option<i64>,
/// Lower bound (inclusive) on the IMAP server INTERNALDATE timestamp.
pub internal_date_since: Option<i64>,
/// Upper bound (inclusive) on the IMAP server INTERNALDATE timestamp.
pub internal_date_before: Option<i64>,
/// Lower bound (inclusive) on Bichon's archival (ingest) timestamp.
pub ingest_since: Option<i64>,
/// Upper bound (inclusive) on Bichon's archival (ingest) timestamp.
pub ingest_before: Option<i64>,
pub account_ids: Option<HashSet<u64>>,
pub mailbox_ids: Option<HashSet<u64>>,
pub min_size: Option<u64>,
@@ -64,6 +76,14 @@ pub enum SortBy {
#[default]
DATE,
SIZE,
/// Sort by the IMAP server INTERNALDATE timestamp.
#[serde(rename = "INTERNAL_DATE")]
#[cfg_attr(feature = "web-api", oai(rename = "INTERNAL_DATE"))]
InternalDate,
/// Sort by Bichon's archival (ingest) timestamp.
#[serde(rename = "INGEST_AT")]
#[cfg_attr(feature = "web-api", oai(rename = "INGEST_AT"))]
IngestAt,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]

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| {
@@ -114,6 +123,9 @@ pub fn detach_attachments_standalone(
content_id: att.content_id().map(|id| id.to_string()),
content_hash,
is_message: att.is_message(),
extracted_text: None,
extracted_page_count: None,
extracted_is_ocr: false,
});
}
@@ -259,6 +271,12 @@ impl NewIndexWriter {
.parse(eml_bytes)
.ok_or_else(|| raise_error!("failed to parse eml".into(), ErrorCode::InternalError))?;
if message.parts.is_empty() {
return Err(raise_error!(
"Malformed or completely empty EML (no parts found)".into(),
ErrorCode::InternalError
));
}
// ── text / preview ────────────────────────────────────────────────
let text = message
.body_text(0)
@@ -386,6 +404,7 @@ impl NewIndexWriter {
regular_attachment_count: attachment_docs.len(),
tags: None,
account_email: None,
account_name: None,
mailbox_name: None,
content_hash: email_content_hash,
};
@@ -436,7 +455,7 @@ impl NewIndexWriter {
.commit()
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
}
println!("tantivy commit elasped: {:#?}", start.elapsed());
println!("tantivy commit elapsed: {:#?}", start.elapsed());
tracing::info!(count = self.pending, "committed tantivy batch");
self.pending = 0;
Ok(())
@@ -451,16 +470,10 @@ impl NewIndexWriter {
("attachment", &mut self.attachment_writer),
] {
if let Some(writer) = writer_opt.as_mut() {
let reader = writer
let seg_ids = writer
.index()
.reader()
.searchable_segment_ids()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let seg_ids: Vec<_> = reader
.searcher()
.segment_readers()
.iter()
.map(|r| r.segment_id())
.collect();
println!("merging {} {} segments...", seg_ids.len(), name);
if seg_ids.len() > 1 {
let _ = writer.merge(&seg_ids);

View File

@@ -20,6 +20,7 @@ use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::oauth2::{entity::OAuth2, pending::OAuth2PendingEntity, token::OAuth2AccessToken};
use crate::settings::proxy::Proxy;
use crate::utils::net::parse_proxy_url;
use crate::{decrypt, encrypt, raise_error};
use oauth2::{
basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken,
@@ -265,13 +266,30 @@ impl OAuth2Flow {
fn build_http_client(use_proxy: Option<u64>) -> BichonResult<reqwest::Client> {
if let Some(proxy_id) = use_proxy {
let proxy = Proxy::get(proxy_id)?;
// Normalize the URL: reqwest only understands standard format user:pass@host:port.
// Our parse_proxy_url handles both standard and non-standard (host:port:user:pass).
let proxy_url = match parse_proxy_url(&proxy.url) {
Ok(addr) => {
if let (Some(user), Some(pass)) = (&addr.username, &addr.password) {
format!("socks5://{}:{}@{}:{}", user, pass, addr.host, addr.port)
} else if let Some(user) = &addr.username {
format!("socks5://{}@{}:{}", user, addr.host, addr.port)
} else {
format!("socks5://{}:{}", addr.host, addr.port)
}
}
Err(_) => {
// Fallback: pass through as-is for backward compatibility
proxy.url.clone()
}
};
return oauth2::reqwest::ClientBuilder::new()
.redirect(oauth2::reqwest::redirect::Policy::none())
.proxy(reqwest::Proxy::all(&proxy.url).map_err(|e| {
.proxy(reqwest::Proxy::all(&proxy_url).map_err(|e| {
raise_error!(
format!(
"Failed to configure SOCKS5 proxy ({}): {:#?}. Please check",
&proxy.url, e
&proxy_url, e
),
ErrorCode::InternalError
)

View File

@@ -308,6 +308,56 @@ pub struct Settings {
help = "Enable SMTP authentication requirement"
)]
pub bichon_smtp_auth_required: bool,
/// Enable OIDC-based Single Sign-On (Pro/Enterprise feature).
#[clap(long, default_value = "false", env, help = "Enable OpenID Connect SSO")]
pub bichon_oidc_enabled: bool,
/// OIDC issuer URL (e.g. https://keycloak.example.com/realms/myorg).
#[clap(long, env, help = "OpenID Connect issuer URL")]
pub bichon_oidc_issuer_url: Option<String>,
/// OIDC client ID registered with the IdP.
#[clap(long, env, help = "OpenID Connect client ID")]
pub bichon_oidc_client_id: Option<String>,
/// OIDC client secret registered with the IdP.
#[clap(long, env, help = "OpenID Connect client secret")]
pub bichon_oidc_client_secret: Option<String>,
/// OIDC redirect URI (must match what's registered with the IdP).
#[clap(long, env, help = "OpenID Connect redirect URI")]
pub bichon_oidc_redirect_uri: Option<String>,
/// Maximum HTTP request body size in MB for file uploads (default: 1100 MB).
/// Requests exceeding this limit are rejected at the framework level before
/// the application reads the body, preventing memory exhaustion attacks.
#[clap(
long,
default_value = "1100",
env,
help = "Maximum HTTP request body size in MB for file uploads"
)]
pub bichon_upload_body_limit_mb: u64,
/// Maximum per-file size in MB for MBOX uploads via the web UI (default: 1024 MB = 1 GB).
/// Individual EML files are always capped at 100 MB regardless of this setting.
#[clap(
long,
default_value = "1024",
env,
help = "Maximum per-file size in MB for MBOX uploads via the web UI"
)]
pub bichon_web_mbox_upload_limit_mb: u64,
/// Maximum per-file size in MB for PST uploads via the web UI (default: 2048 MB = 2 GB).
#[clap(
long,
default_value = "2048",
env,
help = "Maximum per-file size in MB for PST uploads via the web UI"
)]
pub bichon_web_pst_upload_limit_mb: u64,
}
impl Settings {
@@ -317,9 +367,8 @@ impl Settings {
// rejects it, fall back to parsing with only the binary name so that
// the settings come entirely from environment variables.
let args: Vec<String> = std::env::args().collect();
let s = Self::try_parse_from(&args).unwrap_or_else(|_| {
Self::parse_from(std::iter::once(args[0].clone()))
});
let s = Self::try_parse_from(&args)
.unwrap_or_else(|_| Self::parse_from(std::iter::once(args[0].clone())));
if s.bichon_encrypt_password.is_none() && s.bichon_encrypt_password_file.is_none() {
panic!(
"One of --bichon_encrypt_password or --bichon_encrypt_password_file has to be set"

View File

@@ -60,6 +60,17 @@ pub struct SystemConfigurations {
pub bichon_smtp_auth_required: bool,
pub bichon_smtp_tls_key_path: Option<String>,
pub bichon_smtp_tls_cert_path: Option<String>,
pub bichon_oidc_enabled: bool,
pub bichon_oidc_issuer_url: Option<String>,
pub bichon_oidc_client_id: Option<String>,
pub bichon_oidc_redirect_uri: Option<String>,
pub bichon_upload_body_limit_mb: u64,
pub bichon_web_mbox_upload_limit_mb: u64,
pub bichon_web_pst_upload_limit_mb: u64,
}
impl From<&Settings> for SystemConfigurations {
@@ -94,6 +105,13 @@ impl From<&Settings> for SystemConfigurations {
bichon_smtp_auth_required: s.bichon_smtp_auth_required,
bichon_smtp_tls_key_path: s.bichon_smtp_tls_key_path.clone(),
bichon_smtp_tls_cert_path: s.bichon_smtp_tls_cert_path.clone(),
bichon_oidc_enabled: s.bichon_oidc_enabled,
bichon_oidc_issuer_url: s.bichon_oidc_issuer_url.clone(),
bichon_oidc_client_id: s.bichon_oidc_client_id.clone(),
bichon_oidc_redirect_uri: s.bichon_oidc_redirect_uri.clone(),
bichon_upload_body_limit_mb: s.bichon_upload_body_limit_mb,
bichon_web_mbox_upload_limit_mb: s.bichon_web_mbox_upload_limit_mb,
bichon_web_pst_upload_limit_mb: s.bichon_web_pst_upload_limit_mb,
}
}
}

View File

@@ -26,7 +26,7 @@ use crate::{
},
error::{code::ErrorCode, BichonResult},
id, raise_error, utc_now,
utils::net::parse_proxy_addr,
utils::net::parse_proxy_url,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
@@ -98,9 +98,9 @@ impl Proxy {
insert_impl(DB_MANAGER.db(), self.to_owned())
}
/// Validate that the URL is a valid SOCKS5 proxy URL.
/// Validate that the URL is a valid proxy URL.
pub fn validate(&self) -> BichonResult<()> {
parse_proxy_addr(&self.url)?;
parse_proxy_url(&self.url)?;
Ok(())
}
}
@@ -111,7 +111,15 @@ mod tests {
#[test]
fn test_valid_proxy_urls() {
let urls = vec!["socks5://127.0.0.1:1080", "http://127.0.0.1:8080"];
let urls = vec![
"socks5://127.0.0.1:1080",
"http://127.0.0.1:8080",
"socks5://proxy.example.com:1080",
"socks5://user:pass@proxy.example.com:1080",
"socks5://user@proxy.example.com:1080",
// Non-standard format: host:port:user:pass
"socks5://server.nodeprovider.com:8080:username123:passwordhere",
];
for url in urls {
let proxy = Proxy::new(url.to_string());

View File

@@ -18,7 +18,7 @@
use crate::{
common::signal::SIGNAL_MANAGER,
envelope::extractor::reattach_eml_content,
envelope::extractor::reattach_eml_content_self_healing,
error::{code::ErrorCode, BichonResult},
settings::dir::DATA_DIR_MANAGER,
};
@@ -68,7 +68,9 @@ impl BlobManager {
}
}
Err(e) => tracing::error!("Fjall email_ks error: {:?}", e),
_ => {}
Ok(true) => {
tracing::debug!("Email blob already exists (dedup): {}", &email_hash);
}
}
if let Some(attachments) = eml.attachments {
@@ -80,7 +82,9 @@ impl BlobManager {
}
}
Err(e) => tracing::error!("Fjall attach_ks error: {:?}", e),
_ => {}
Ok(true) => {
tracing::debug!("Attachment blob already exists (dedup): {}", &a_hash);
}
}
}
}
@@ -145,9 +149,18 @@ impl BlobManager {
res = receiver.recv() => {
match res {
Some(eml) => {
Self::process_detached_email(eml, &email_ks, &attach_ks);
let mut batch = vec![eml];
while let Ok(next_eml) = receiver.try_recv() {
Self::process_detached_email(next_eml, &email_ks, &attach_ks);
batch.push(next_eml);
}
let email_ks = email_ks.clone();
let attach_ks = attach_ks.clone();
if let Err(e) = tokio::task::spawn_blocking(move || {
for eml in batch {
Self::process_detached_email(eml, &email_ks, &attach_ks);
}
}).await {
tracing::error!("BlobManager: spawn_blocking join error: {:#?}", e);
}
}
None => {
@@ -158,16 +171,25 @@ impl BlobManager {
}
_ = shutdown.recv() => {
receiver.close();
let remaining = receiver.len();
let mut remaining = Vec::new();
while let Some(eml) = receiver.recv().await {
remaining.push(eml);
}
tracing::info!(
"BlobManager: Shutdown signal received. Processing {} remaining tasks...",
remaining
remaining.len()
);
while let Some(eml) = receiver.recv().await {
Self::process_detached_email(eml, &email_ks, &attach_ks);
if !remaining.is_empty() {
let email_ks = email_ks.clone();
let attach_ks = attach_ks.clone();
if let Err(e) = tokio::task::spawn_blocking(move || {
for eml in remaining {
Self::process_detached_email(eml, &email_ks, &attach_ks);
}
}).await {
tracing::error!("BlobManager: shutdown spawn_blocking join error: {:#?}", e);
}
}
tracing::info!("BlobManager: All remaining tasks processed. Closing Fjall.");
break;
}
@@ -185,7 +207,9 @@ impl BlobManager {
}
pub async fn queue(&self, email: DetachedEmail) {
let _ = self.sender.send(email).await;
if let Err(e) = self.sender.send(email).await {
tracing::error!("BlobManager channel closed, email lost: {:#?}", e);
}
}
pub fn get_email(&self, content_hash: &str) -> BichonResult<Option<Bytes>> {
@@ -226,7 +250,13 @@ impl BlobManager {
}
}
pub fn get_reader(account_id: u64, eid: String) -> BichonResult<Cursor<Bytes>> {
let (_, data) = reattach_eml_content(account_id, eid)?;
/// Returns a reader over the raw EML for an indexed message.
///
/// If the message's content blob is missing from the blob store, it is fetched
/// on demand from the IMAP server, persisted, and returned (self-healing). The
/// underlying "content not found" error is only surfaced if that on-demand
/// fetch itself fails.
pub async fn get_reader(account_id: u64, eid: String) -> BichonResult<Cursor<Bytes>> {
let (_, data) = reattach_eml_content_self_healing(account_id, eid).await?;
Ok(Cursor::new(data))
}

View File

@@ -27,6 +27,7 @@ pub struct Envelope {
pub message_id: String,
pub account_id: u64,
pub account_email: Option<String>,
pub account_name: Option<String>,
pub mailbox_id: u64,
pub mailbox_name: Option<String>,
pub uid: u32,

View File

@@ -38,8 +38,8 @@ use crate::{
store::tantivy::{
fatal_commit,
fields::{
F_ATTACHMENT_CATEGORY, F_ATTACHMENT_CONTENT_TYPE, F_ATTACHMENT_EXT, F_DATE, F_SIZE,
F_TAGS,
F_ATTACHMENT_CATEGORY, F_ATTACHMENT_CONTENT_TYPE, F_ATTACHMENT_EXT, F_DATE,
F_INGEST_AT, F_SIZE, F_TAGS,
},
model::{extract_senders, AttachmentModel},
schema::SchemaTools,
@@ -154,7 +154,11 @@ impl IndexManager {
"Tantivy: Reached threshold ({} docs), committing...",
pending_count
);
fatal_commit(&mut writer);
tokio::task::block_in_place(|| fatal_commit(&mut writer));
tracing::debug!(
"Tantivy attach: committed {} docs, pending reset to 0",
pending_count
);
pending_count = 0;
commit_interval.reset();
}
@@ -163,7 +167,7 @@ impl IndexManager {
tracing::info!("Tantivy: Receiver closed. Finalizing...");
if pending_count > 0 {
let mut writer = writer.lock().await;
fatal_commit(&mut writer);
tokio::task::block_in_place(|| fatal_commit(&mut writer));
}
break;
},
@@ -172,16 +176,19 @@ impl IndexManager {
_ = commit_interval.tick() => {
if pending_count > 0 {
let mut writer = writer.lock().await;
fatal_commit(&mut writer);
tracing::debug!(
"Tantivy attach: periodic commit ({} docs pending)",
pending_count
);
tokio::task::block_in_place(|| fatal_commit(&mut writer));
pending_count = 0;
tracing::debug!("Tantivy: Periodic commit finished.");
}
}
_ = shutdown.recv() => {
tracing::info!("Tantivy: Shutdown signal received. Performing final commit...");
if pending_count > 0 {
let mut writer = writer.lock().await;
fatal_commit(&mut writer);
tokio::task::block_in_place(|| fatal_commit(&mut writer));
}
tracing::info!("Tantivy: Shutdown cleanup complete.");
break;
@@ -260,7 +267,7 @@ impl IndexManager {
IndexRecordOption::Basic,
);
let envelope_id_query = TermQuery::new(
Term::from_field_text(SchemaTools::attachment_fields().f_id, aid),
Term::from_field_text(SchemaTools::attachment_fields().f_envelope_id, aid),
IndexRecordOption::Basic,
);
let boolean_query = BooleanQuery::new(vec![
@@ -633,7 +640,7 @@ impl IndexManager {
Ok(())
}
pub async fn delete_envelopes_multi_account(
pub async fn delete_attachments_multi_account(
&self,
deletes: HashMap<u64, Vec<String>>,
) -> BichonResult<()> {
@@ -864,6 +871,30 @@ impl IndexManager {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
attachment_docs = size_docs.into_iter().map(|(_, addr)| addr).collect();
}
// Attachments carry no IMAP INTERNALDATE; fall back to the
// attachment's own date field so the sort remains well defined.
SortBy::InternalDate => {
let date_docs: Vec<(Option<i64>, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
attachment_docs = date_docs.into_iter().map(|(_, addr)| addr).collect();
}
SortBy::IngestAt => {
let ingest_at_docs: Vec<(Option<i64>, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INGEST_AT, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
attachment_docs = ingest_at_docs.into_iter().map(|(_, addr)| addr).collect();
}
}
let mut result = Vec::new();

View File

@@ -158,10 +158,10 @@ fn dedup_account(
) -> BichonResult<u64> {
let searcher = email_reader.searcher();
let fields = SchemaTools::email_fields();
eprintln!(
"DEBUG dedup_account: entry account={account_id} f_id_field={:?} f_content_hash_field={:?}",
fields.f_id, fields.f_content_hash
);
// eprintln!(
// "DEBUG dedup_account: entry account={account_id} f_id_field={:?} f_content_hash_field={:?}",
// fields.f_id, fields.f_content_hash
// );
let mut map: DedupMap = HashMap::new();
// ── Phase 1: build the dedup map via FAST column scans ──────────────────
@@ -204,7 +204,11 @@ fn dedup_account(
let ingest_at = ingest_col.values.get_val(doc_id);
// Read content_hash from the dictionary-encoded string column
let hash_ord = hash_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
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)
@@ -212,16 +216,20 @@ fn dedup_account(
let content_hash = hash_buf;
// Read f_id from the dictionary-encoded string column
let id_ord = id_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
let id_ord = id_col
.ords()
.values_for_doc(doc_id as u32)
.next()
.unwrap_or(0);
let mut id_buf = String::new();
id_col
.ord_to_str(id_ord, &mut id_buf)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let email_id = id_buf;
eprintln!(
"DEBUG dedup_account: account={account_id} doc_id={doc_id} mailbox={mailbox_id} hash={content_hash:?} id={email_id:?} ingest_at={ingest_at}"
);
// eprintln!(
// "DEBUG dedup_account: account={account_id} doc_id={doc_id} mailbox={mailbox_id} hash={content_hash:?} id={email_id:?} ingest_at={ingest_at}"
// );
map.entry((mailbox_id, content_hash))
.or_default()
@@ -247,7 +255,22 @@ fn dedup_account(
// uidvalidity, which is required for correct incremental sync.
entries.sort_by_key(|e| std::cmp::Reverse(e.ingest_at));
eprintln!("DEBUG Phase2: key={_key:?} kept={} deleting={}", entries[0].email_id, entries.len() - 1);
tracing::debug!(
"dedup: account={} mailbox={} hash={}: {} copies, keeping eid={} ingest_at={}, deleting {}",
account_id,
_key.0,
&_key.1,
entries.len(),
&entries[0].email_id,
entries[0].ingest_at,
entries.len() - 1
);
// eprintln!(
// "DEBUG Phase2: key={_key:?} kept={} deleting={}",
// entries[0].email_id,
// entries.len() - 1
// );
// Keep entries[0], soft-delete everything else via term query on f_id
for entry in &entries[1..] {
eprintln!(
@@ -315,13 +338,14 @@ mod tests {
/// Collect non-deleted f_id values from the email index.
fn surviving_email_ids(reader: &IndexReader) -> HashSet<String> {
reader
.reload()
.expect("reader reload failed");
reader.reload().expect("reader reload failed");
let searcher = reader.searcher();
let mut ids = HashSet::new();
let segments = searcher.segment_readers();
eprintln!("DEBUG surviving_email_ids: segment_count={}", segments.len());
eprintln!(
"DEBUG surviving_email_ids: segment_count={}",
segments.len()
);
for (seg_idx, seg) in segments.iter().enumerate() {
let id_col = seg
.fast_fields()
@@ -332,7 +356,11 @@ mod tests {
eprintln!("DEBUG surviving_email_ids: seg={seg_idx} max_doc={max_doc}");
for doc_id in 0..max_doc {
let is_del = seg.is_deleted(doc_id);
let ord = id_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
let ord = id_col
.ords()
.values_for_doc(doc_id as u32)
.next()
.unwrap_or(0);
let mut buf = String::new();
id_col.ord_to_str(ord, &mut buf).unwrap();
eprintln!("DEBUG surviving_email_ids: seg={seg_idx} doc_id={doc_id} is_deleted={is_del} ord={ord} buf={buf:?}");
@@ -359,7 +387,11 @@ mod tests {
if seg.is_deleted(doc_id) {
continue;
}
let ord = id_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
let ord = id_col
.ords()
.values_for_doc(doc_id as u32)
.next()
.unwrap_or(0);
let mut buf = String::new();
id_col.ord_to_str(ord, &mut buf).unwrap();
ids.insert(buf);
@@ -454,15 +486,17 @@ mod tests {
let email_r = email_idx.reader().unwrap();
let survivors = surviving_email_ids(&email_r);
let expected: HashSet<String> =
expected_emails.iter().map(|s| s.to_string()).collect();
let expected: HashSet<String> = expected_emails.iter().map(|s| s.to_string()).collect();
assert_eq!(survivors, expected, "[{case}] email survivors mismatch");
let attach_r = attach_idx.reader().unwrap();
let att_survivors = surviving_attachment_ids(&attach_r);
let att_expected: HashSet<String> =
expected_attachments.iter().map(|s| s.to_string()).collect();
assert_eq!(att_survivors, att_expected, "[{case}] attachment survivors mismatch");
assert_eq!(
att_survivors, att_expected,
"[{case}] attachment survivors mismatch"
);
}
}
@@ -494,7 +528,7 @@ mod tests {
add_attachment(af, aw, &format!("att-{i}"), &id, 1, 1);
}
},
&["dup-2"], // ingest_at=400, the latest
&["dup-2"], // ingest_at=400, the latest
&["att-2"],
)
.await;
@@ -591,7 +625,7 @@ mod tests {
/// This test is read-only — it does not modify the index.
#[test]
fn inspect_production_duplicates() {
let index_path = r"E:\db\data\bichon-indices\mail_metadata";
let index_path = r"E:\bichon-data\bichon-indices\mail_metadata";
let report_path = std::path::PathBuf::from(r"E:\bichon\dedup_report.txt");
let mut report = String::new();
@@ -622,26 +656,21 @@ mod tests {
let searcher = reader.searcher();
let mut total_docs = 0u64;
let mut groups: std::collections::HashMap<u64, std::collections::HashMap<(u64, String), u64>> =
std::collections::HashMap::new();
let mut groups: std::collections::HashMap<
u64,
std::collections::HashMap<(u64, String), u64>,
> = std::collections::HashMap::new();
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 = match segment_reader
.fast_fields()
.str(F_CONTENT_HASH)
.unwrap()
{
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 = match segment_reader.fast_fields().str(F_CONTENT_HASH).unwrap() {
Some(c) => c,
None => {
let _ = writeln!(report, "Segment has no FAST str column for content_hash, skipping");
let _ = writeln!(
report,
"Segment has no FAST str column for content_hash, skipping"
);
continue;
}
};
@@ -655,7 +684,11 @@ mod tests {
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 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();
let content_hash = hash_buf;

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

@@ -155,7 +155,8 @@ impl EnvelopeWithAttachments {
id: extract_string_field(doc, fields.f_id, F_ID)?,
message_id: extract_string_field(doc, fields.f_message_id, F_MESSAGE_ID)?,
account_id,
account_email: Some(account.email),
account_email: Some(account.email), //https://github.com/rustmailer/bichon/issues/306
account_name: account.account_name,
mailbox_id,
mailbox_name: Some(mailbox.name),
uid: extract_u64_field(doc, fields.f_uid, F_UID)? as u32,

View File

@@ -156,10 +156,16 @@ impl AccessTokenModel {
})?;
if matches!(token_model.token_type, TokenType::WebUI) {
let life = utc_now!() - token_model.created_at;
let max_life = SETTINGS.bichon_webui_token_expiration_hours * 60 * 60 * 1000;
// Use last_access_at if set, otherwise fall back to created_at
let last_active = if token_model.last_access_at > 0 {
token_model.last_access_at
} else {
token_model.created_at
};
let idle = utc_now!() - last_active;
let max_life = SETTINGS.bichon_webui_token_expiration_hours as i64 * 60 * 60 * 1000;
if life > (max_life as i64) {
if idle > max_life {
return Err(raise_error!(
"Permission denied: the WebUI token has expired.".into(),
ErrorCode::PermissionDenied
@@ -176,13 +182,15 @@ impl AccessTokenModel {
));
}
}
update_impl(DB_MANAGER.db(), &token_str, |current: AccessTokenModel| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
})?;
}
// Update last_access_at on every successful use for both token types
update_impl(DB_MANAGER.db(), &token_str, |current: AccessTokenModel| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
})?;
let user = UserModel::find(token_model.user_id)
?
.ok_or_else(|| raise_error!("The user associated with this access token does not exist or may have been deleted.".into(), ErrorCode::ResourceNotFound))?;

View File

@@ -87,6 +87,11 @@ pub struct BichonUserV2 {
pub theme: Option<String>,
pub language: Option<String>,
/// SSO identity: unique subject ID from the external IdP (e.g. OIDC `sub` claim).
pub sso_id: Option<String>,
/// SSO provider identifier: `"oidc"` or future `"saml"` / `"ldap"`.
pub sso_provider: Option<String>,
}
impl MemDbModel for BichonUserV2 {
@@ -192,6 +197,8 @@ impl BichonUserV2 {
global_permissions,
theme: self.theme,
language: self.language,
sso_id: self.sso_id,
sso_provider: self.sso_provider,
}
}
@@ -226,6 +233,8 @@ impl BichonUserV2 {
acl: None,
theme: None,
language: None,
sso_id: None,
sso_provider: None,
};
// 3. Generate and insert an initial access token for the first-time setup
@@ -382,6 +391,8 @@ impl BichonUserV2 {
account_access_map: request.account_access_map,
theme: request.theme,
language: request.language,
sso_id: None,
sso_provider: None,
};
let user_clone = user.clone();

View File

@@ -52,4 +52,9 @@ pub struct UserView {
pub acl: Option<AccessControl>,
pub theme: Option<String>,
pub language: Option<String>,
/// SSO identity: unique subject ID from the external IdP (e.g. OIDC `sub` claim).
pub sso_id: Option<String>,
/// SSO provider identifier: `"oidc"` or future `"saml"` / `"ldap"`.
pub sso_provider: Option<String>,
}

View File

@@ -26,10 +26,10 @@ use std::sync::LazyLock;
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::settings::cli::SETTINGS;
use crate::raise_error;
use crate::settings::cli::SETTINGS;
static ENCRYPT_PASSWORD: LazyLock<String> = LazyLock::new(|| {
pub static ENCRYPT_PASSWORD: LazyLock<String> = LazyLock::new(|| {
if let Some(file_path) = &SETTINGS.bichon_encrypt_password_file {
return fs::read_to_string(file_path)
.expect("failed to read the file with the encrypt password")
@@ -102,7 +102,10 @@ pub fn internal_encrypt_string(
Ok(general_purpose::URL_SAFE.encode(&result))
}
pub fn internal_decrypt_string(password: &str, data: &str) -> Result<String, ring::error::Unspecified> {
pub fn internal_decrypt_string(
password: &str,
data: &str,
) -> Result<String, ring::error::Unspecified> {
let data = general_purpose::URL_SAFE
.decode(data)
.map_err(|_| ring::error::Unspecified)?;
@@ -146,8 +149,7 @@ mod tests {
#[test]
fn test_wrong_password_fails() {
let encrypted =
internal_encrypt_string("correct_password", "secret").unwrap();
let encrypted = internal_encrypt_string("correct_password", "secret").unwrap();
assert!(internal_decrypt_string("wrong_password", &encrypted).is_err());
}

View File

@@ -17,9 +17,63 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use regex::Regex;
use std::panic;
use std::sync::LazyLock;
use tracing::error;
/// Removes remote content references from HTML email body.
///
/// Strips attributes that load content from http:// or https:// URLs,
/// keeping data: URIs and cid: references intact. Does NOT affect
/// navigation links (<a href>).
pub fn block_remote_content(html: &str) -> String {
let mut result = html.to_string();
// 1. Strip src, poster, data attributes with remote URLs.
// These always load content regardless of the tag.
static SRC_ATTR_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)\s+(src|poster|data)\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#).unwrap()
});
result = SRC_ATTR_RE.replace_all(&result, "").to_string();
// 2. Strip srcset attributes with remote URLs.
static SRCSET_ATTR_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)\s+srcset\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#).unwrap()
});
result = SRCSET_ATTR_RE.replace_all(&result, "").to_string();
// 3. Strip href on <link> tags (stylesheets), never <a> links.
static LINK_HREF_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)(<link\b[^>]*)\s+href\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#).unwrap()
});
result = LINK_HREF_RE.replace_all(&result, "$1").to_string();
// 4. Strip CSS url() references with remote URLs in inline styles.
static CSS_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)url\(\s*["']?\s*(?:https?://|//)[^)"'\s]*\s*["']?\s*\)"#).unwrap()
});
result = CSS_URL_RE.replace_all(&result, "").to_string();
// 5. Strip @import url(...) with remote URLs inside <style> blocks.
static IMPORT_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?i)@import\s+url\(\s*["']?\s*(?:https?://|//)[^)"'\s]*\s*["']?\s*\)\s*;"#,
)
.unwrap()
});
result = IMPORT_URL_RE.replace_all(&result, "").to_string();
// 6. Strip background attribute on <body> with remote URLs.
static BODY_BG_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)(<body\b[^>]*)\s+background\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#)
.unwrap()
});
result = BODY_BG_RE.replace_all(&result, "$1").to_string();
result
}
pub fn extract_text(html: String) -> String {
let result = panic::catch_unwind(|| {
html2text::config::plain()
@@ -88,4 +142,132 @@ mod tests {
let text = extract_text(html);
assert!(text.contains("Click here"));
}
mod block_remote {
use super::*;
#[test]
fn strips_img_src_http() {
let html = r#"<img src="https://tracker.example.com/pixel.gif" alt="x">"#;
let result = block_remote_content(html);
assert!(!result.contains("https://tracker.example.com"));
assert!(result.contains("alt=")); // other attrs preserved
}
#[test]
fn strips_img_src_protocol_relative() {
let html = r#"<img src="//tracker.example.com/pixel.gif">"#;
let result = block_remote_content(html);
assert!(!result.contains("//tracker.example.com"));
}
#[test]
fn preserves_data_uri() {
let html = r#"<img src="data:image/png;base64,ABC123" alt="embedded">"#;
let result = block_remote_content(html);
assert!(result.contains("data:image/png;base64,ABC123"));
}
#[test]
fn preserves_cid_reference() {
let html = r#"<img src="cid:abc123@example.com" alt="inline">"#;
let result = block_remote_content(html);
assert!(result.contains("cid:abc123@example.com"));
}
#[test]
fn preserves_anchor_href() {
let html = r#"<a href="https://example.com/page">Click</a>"#;
let result = block_remote_content(html);
assert!(result.contains(r#"href="https://example.com/page""#));
}
#[test]
fn strips_link_stylesheet_href() {
let html =
r#"<link rel="stylesheet" href="https://fonts.example.com/font.css">"#;
let result = block_remote_content(html);
assert!(!result.contains("https://fonts.example.com"));
assert!(result.contains("<link")); // tag preserved
}
#[test]
fn strips_script_src() {
let html = r#"<script src="https://evil.example.com/malware.js"></script>"#;
let result = block_remote_content(html);
assert!(!result.contains("https://evil.example.com"));
}
#[test]
fn strips_iframe_src() {
let html = r#"<iframe src="https://ads.example.com/banner"></iframe>"#;
let result = block_remote_content(html);
assert!(!result.contains("https://ads.example.com"));
}
#[test]
fn strips_css_url_in_style() {
let html = r#"<div style="background: url(https://tracker.example.com/bg.jpg)"></div>"#;
let result = block_remote_content(html);
assert!(!result.contains("https://tracker.example.com"));
}
#[test]
fn strips_css_url_protocol_relative() {
let html = r#"<div style="background: url(//tracker.example.com/bg.jpg)"></div>"#;
let result = block_remote_content(html);
assert!(!result.contains("//tracker.example.com"));
}
#[test]
fn strips_css_import() {
let html =
r#"<style>@import url("https://fonts.example.com/font.css");</style>"#;
let result = block_remote_content(html);
assert!(!result.contains("https://fonts.example.com"));
}
#[test]
fn strips_video_poster() {
let html = r#"<video poster="https://cdn.example.com/thumb.jpg"></video>"#;
let result = block_remote_content(html);
assert!(!result.contains("https://cdn.example.com"));
}
#[test]
fn strips_srcset() {
let html =
r#"<img srcset="https://cdn.example.com/img1.jpg 1x, https://cdn.example.com/img2.jpg 2x">"#;
let result = block_remote_content(html);
assert!(!result.contains("https://cdn.example.com"));
}
#[test]
fn strips_body_background() {
let html = r#"<body background="https://tracker.example.com/bg.jpg">"#;
let result = block_remote_content(html);
assert!(!result.contains("https://tracker.example.com"));
assert!(result.contains("<body"));
}
#[test]
fn handles_mixed_content() {
let html = r#"
<html>
<body>
<img src="https://spy.example.com/pixel.gif" width="1" height="1">
<img src="data:image/png;base64,OK123" alt="ok">
<a href="https://example.com/read-more">Read more</a>
<div style="background: url(https://tracker.example.com/bg.jpg) no-repeat"></div>
</body>
</html>"#;
let result = block_remote_content(html);
// Remote content gone
assert!(!result.contains("spy.example.com"));
assert!(!result.contains("tracker.example.com"));
// Safe content preserved
assert!(result.contains("data:image/png;base64,OK123"));
assert!(result.contains(r#"href="https://example.com/read-more""#));
}
}
}

View File

@@ -138,7 +138,7 @@ macro_rules! generate_token {
}};
}
pub(crate) fn generate_token_impl(bit_strength: usize) -> String {
pub fn generate_token_impl(bit_strength: usize) -> String {
let byte_length = (bit_strength + 23) / 24 * 3;
let random_bytes: Vec<u8> = (0..byte_length).map(|_| rand::random::<u8>()).collect();
let mut encoded = general_purpose::URL_SAFE.encode(&random_bytes);

View File

@@ -32,6 +32,15 @@ use tracing::error;
pub(crate) const TIMEOUT: Duration = Duration::from_secs(30);
/// Parsed proxy address components.
#[derive(Debug, Clone)]
pub struct ProxyAddr {
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
}
pub(crate) async fn establish_tcp_connection_with_timeout(
address: SocketAddr,
use_proxy: Option<u64>,
@@ -66,20 +75,27 @@ pub async fn establish_tls_connection(
Ok(tls_stream)
}
pub fn parse_proxy_addr(input: &str) -> BichonResult<SocketAddr> {
// Normalize and check protocol prefix
let (scheme, stripped) = if let Some(rest) = input
/// Parse a proxy URL into its components.
///
/// Supports two formats:
/// - **Standard**: `[scheme://][user:pass@]host:port`
/// - **Non-standard** (some proxy providers): `[scheme://]host:port:username:password`
///
/// The distinguishing feature is the `@` sign in the standard format.
pub fn parse_proxy_url(input: &str) -> BichonResult<ProxyAddr> {
// Normalize and strip scheme prefix
let stripped = if let Some(rest) = input
.strip_prefix("socks5://")
.or_else(|| input.strip_prefix("SOCKS5://"))
.or_else(|| input.strip_prefix("Socks5://"))
{
("socks5", rest)
rest
} else if let Some(rest) = input
.strip_prefix("http://")
.or_else(|| input.strip_prefix("HTTP://"))
.or_else(|| input.strip_prefix("Http://"))
{
("http", rest)
rest
} else {
return Err(raise_error!(
format!(
@@ -90,43 +106,207 @@ pub fn parse_proxy_addr(input: &str) -> BichonResult<SocketAddr> {
));
};
// Parse the remaining address
let addr = stripped.parse::<SocketAddr>().map_err(|e| {
raise_error!(
if stripped.is_empty() {
return Err(raise_error!(
"Proxy URL has empty address after scheme.".into(),
ErrorCode::InvalidParameter
));
}
// Check for standard format: user:pass@host:port
if let Some(at_pos) = stripped.rfind('@') {
let userinfo = &stripped[..at_pos];
let hostport = &stripped[at_pos + 1..];
let (username, password) = split_userinfo(userinfo)?;
let (host, port) = split_hostport(hostport)?;
return Ok(ProxyAddr {
host,
port,
username,
password,
});
}
// No '@' — check for non-standard format: host:port:user:pass
let parts: Vec<&str> = stripped.rsplitn(4, ':').collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>();
match parts.len() {
2 => {
// host:port, no auth
let (host, port) = split_hostport(stripped)?;
Ok(ProxyAddr {
host,
port,
username: None,
password: None,
})
}
4 => {
// Non-standard: host:port:username:password
let host = parts[0].to_string();
let port = parts[1]
.parse::<u16>()
.map_err(|_| {
raise_error!(
format!("Invalid port '{}' in proxy URL.", parts[1]),
ErrorCode::InvalidParameter
)
})?;
let username = parts[2].to_string();
let password = parts[3].to_string();
if host.is_empty() {
return Err(raise_error!(
"Empty hostname in proxy URL.".into(),
ErrorCode::InvalidParameter
));
}
if username.is_empty() {
return Err(raise_error!(
"Empty username in proxy URL.".into(),
ErrorCode::InvalidParameter
));
}
if password.is_empty() {
return Err(raise_error!(
"Empty password in proxy URL.".into(),
ErrorCode::InvalidParameter
));
}
Ok(ProxyAddr {
host,
port,
username: Some(username),
password: Some(password),
})
}
_ => Err(raise_error!(
format!(
"Failed to parse {} proxy address '{}': {}",
scheme, stripped, e
"Invalid proxy URL format '{}'. Expected '[scheme://][user:pass@]host:port' or 'scheme://host:port:user:pass'.",
input
),
ErrorCode::InvalidParameter
)),
}
}
/// Split "user:pass" into (Some(user), Some(pass)), or "user" into (Some(user), None).
fn split_userinfo(userinfo: &str) -> BichonResult<(Option<String>, Option<String>)> {
if userinfo.is_empty() {
return Ok((None, None));
}
if let Some(colon_pos) = userinfo.find(':') {
let user = &userinfo[..colon_pos];
let pass = &userinfo[colon_pos + 1..];
if user.is_empty() {
return Err(raise_error!(
"Empty username in proxy URL credentials.".into(),
ErrorCode::InvalidParameter
));
}
Ok((Some(user.to_string()), Some(pass.to_string())))
} else {
Ok((Some(userinfo.to_string()), None))
}
}
/// Split "host:port" into (host, port). Handles IPv6 addresses in brackets.
fn split_hostport(hostport: &str) -> BichonResult<(String, u16)> {
if hostport.is_empty() {
return Err(raise_error!(
"Empty host:port in proxy URL.".into(),
ErrorCode::InvalidParameter
));
}
// IPv6: [::1]:1080
if hostport.starts_with('[') {
let close_bracket = hostport.find(']').ok_or_else(|| {
raise_error!(
format!("Invalid IPv6 address in proxy URL: '{}'.", hostport),
ErrorCode::InvalidParameter
)
})?;
let host = hostport[1..close_bracket].to_string();
let after_bracket = &hostport[close_bracket + 1..];
if !after_bracket.starts_with(':') {
return Err(raise_error!(
format!("Missing port after IPv6 address in proxy URL: '{}'.", hostport),
ErrorCode::InvalidParameter
));
}
let port = after_bracket[1..].parse::<u16>().map_err(|_| {
raise_error!(
format!("Invalid port in proxy URL: '{}'.", hostport),
ErrorCode::InvalidParameter
)
})?;
return Ok((host, port));
}
// hostname:port or ip:port — split from right
let last_colon = hostport.rfind(':').ok_or_else(|| {
raise_error!(
format!("Missing port in proxy URL: '{}'.", hostport),
ErrorCode::InvalidParameter
)
})?;
let host = hostport[..last_colon].to_string();
let port = hostport[last_colon + 1..].parse::<u16>().map_err(|_| {
raise_error!(
format!("Invalid port in proxy URL: '{}'.", hostport),
ErrorCode::InvalidParameter
)
})?;
Ok(addr)
if host.is_empty() {
return Err(raise_error!(
"Empty hostname in proxy URL.".into(),
ErrorCode::InvalidParameter
));
}
Ok((host, port))
}
/// Try to connect via SOCKS5 proxy or TCP with timeout
/// Try to connect via SOCKS5 proxy or TCP with timeout.
async fn connect_with_optional_proxy(
use_proxy: Option<u64>,
address: SocketAddr,
) -> BichonResult<TcpStream> {
// Try if proxy is enabled
if let Some(proxy_id) = use_proxy {
let proxy = Proxy::get(proxy_id)?;
let proxy = parse_proxy_addr(&proxy.url)?;
return timeout(TIMEOUT, Socks5Stream::connect(proxy, address))
let addr = parse_proxy_url(&proxy.url)?;
let proxy_addr = (addr.host.as_str(), addr.port);
let result = if let (Some(ref user), Some(ref pass)) = (addr.username, addr.password) {
timeout(
TIMEOUT,
Socks5Stream::connect_with_password(proxy_addr, address, user.as_str(), pass.as_str()),
)
.await
} else {
timeout(TIMEOUT, Socks5Stream::connect(proxy_addr, address)).await
};
return result
.map_err(|_| {
error!(
"SOCKS5 proxy connection to {} via {} timed out after {}s",
"SOCKS5 proxy connection to {} via {}:{} timed out after {}s",
address,
proxy,
addr.host,
addr.port,
TIMEOUT.as_secs()
);
raise_error!(
format!(
"SOCKS5 proxy connection to {} via {} timed out after {}s",
"SOCKS5 proxy connection to {} via {}:{} timed out after {}s",
address,
proxy,
addr.host,
addr.port,
TIMEOUT.as_secs()
),
ErrorCode::ConnectionTimeout

View File

@@ -1,5 +1,5 @@
[package]
name = "memdb"
name = "bichon-memdb"
version = "0.1.0"
edition = "2021"

View File

@@ -1,4 +1,4 @@
use memdb::{DbError, MemDb, Page};
use bichon_memdb::{DbError, MemDb, Page};
use serde::{Deserialize, Serialize};
use tempfile::TempDir;
@@ -458,7 +458,7 @@ fn test_wal_seq_skips_already_snapshotted_entries() {
// Verify WAL only contains seq=3.
let wal_path = dir.path().join("wal.jsonl");
let entries = memdb::wal::read_after(&wal_path, 2).unwrap();
let entries = bichon_memdb::wal::read_after(&wal_path, 2).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].seq, 3);
@@ -696,7 +696,7 @@ async fn test_concurrent_writes_wal_seq_monotonic() {
// Verify WAL seq is strictly monotonic.
let wal_path = dir.path().join("wal.jsonl");
let entries = memdb::wal::read_after(&wal_path, 0).unwrap();
let entries = bichon_memdb::wal::read_after(&wal_path, 0).unwrap();
assert_eq!(entries.len(), 50);
let mut last = 0u64;
for e in &entries {

View File

@@ -1,4 +1,4 @@
use memdb::{Durability, MemDb, Page};
use bichon_memdb::{Durability, MemDb, Page};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -548,7 +548,7 @@ fn stress_wal_seq_monotonic_under_load() {
}
let wal_path = dir.path().join("wal.jsonl");
let entries = memdb::wal::read_after(&wal_path, 0).unwrap();
let entries = bichon_memdb::wal::read_after(&wal_path, 0).unwrap();
assert_eq!(entries.len(), n);
let mut last = 0u64;
for e in &entries {
@@ -842,7 +842,7 @@ async fn wal_concurrent_persistent_writes() {
// Verify strict seq ordering in WAL under concurrent load.
let wal_path = db_path.join("wal.jsonl");
let entries = memdb::wal::read_after(&wal_path, 0).unwrap();
let entries = bichon_memdb::wal::read_after(&wal_path, 0).unwrap();
assert_eq!(entries.len(), total as usize);
let mut last = 0u64;
for e in &entries {
@@ -1153,7 +1153,7 @@ fn wal_large_transaction_batch() {
// The entire transaction should be a single WAL entry.
let wal_path = dir.path().join("wal.jsonl");
let entries = memdb::wal::read_after(&wal_path, 0).unwrap();
let entries = bichon_memdb::wal::read_after(&wal_path, 0).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].ops.len(), n as usize);

View File

@@ -3,6 +3,10 @@ name = "bichon-server"
version.workspace = true
edition.workspace = true
[features]
default = ["embed-web"]
embed-web = ["dep:rust-embed"]
[dependencies]
bichon-core = { path = "../core", features = ["web-api"] }
@@ -17,7 +21,7 @@ poem-openapi = { version = "5.1.16", features = [
"swagger-ui",
"email",
] }
rust-embed = "8.11.0"
rust-embed = { version = "8.11.0", optional = true }
email_address.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -27,9 +31,9 @@ timeago.workspace = true
chrono.workspace = true
tracing.workspace = true
tokio.workspace = true
futures.workspace = true
http.workspace = true
urlencoding.workspace = true
mimalloc.workspace = true
[dev-dependencies]
poem = { version = "3.1.12", features = ["test"] }

Some files were not shown because too many files have changed in this diff Show More