Compare commits

...
192 Commits
Author SHA1 Message Date
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
rustmailerandGitHub 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
rustmailerandGitHub 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
rustmailerandGitHub 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
rustmailerandGitHub 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
rustmailerandGitHub 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 MAUDETandClaude Opus 4.7 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 MAUDETandClaude Opus 4.7 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 MAUDETandClaude Opus 4.7 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 MAUDETandClaude Opus 4.7 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
rustmailerandGitHub 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žinaandGitHub 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
rustmailerandGitHub 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
rustmailer 66fd50bc23 fix: bichon-cli OOMs on import #233 2026-05-18 20:25:13 +08:00
rustmailerandGitHub 9daab241b0 Merge pull request #236 from rustmailer/fix/deduplication
feat: add async index deduplication task
2026-05-18 18:03:14 +08:00
rustmailer 85d5490834 feat: add async index deduplication task 2026-05-18 15:30:37 +08:00
rustmailer 6e984f376c Update Cargo.lock 2026-05-17 12:29:48 +08:00
rustmailer 7470125a23 bump to v1.0.2 2026-05-17 12:29:42 +08:00
rustmailer 469d254e2b fix: can't select folders & scroll issue in Choose Mailboxes #222 #217 2026-05-17 12:26:49 +08:00
rustmailer 7fde7ee19a update 2026-05-17 10:35:32 +08:00
rustmailer d543508a23 fix: Overviews are breaking out of their boxes on the dashboard (v1.0.0) #218 2026-05-17 10:35:25 +08:00
rustmailer f440069912 add debug info in bichon-cli #224 2026-05-17 10:35:07 +08:00
rustmailer 9f9fc71d16 bump to v1.0.1 2026-05-16 22:19:15 +08:00
rustmailer b2a75643da fix(bichon-admin): reduce memory usage during data migration 2026-05-16 22:17:03 +08:00
rustmailer 37a38a2910 Update README.md 2026-05-15 12:21:06 +08:00
rustmailer 8817ed96f6 Update README.md 2026-05-15 12:19:50 +08:00
rustmailer 1ee2eade3a fix: rename bichonctl to bichon-cli 2026-05-15 12:07:26 +08:00
rustmailer 7afb1e29aa refactor: replace autoconfig with native impl, remove openssl dependency 2026-05-15 11:27:06 +08:00
rustmailer 715858183d Update pnpm-lock.yaml 2026-05-15 10:11:29 +08:00
rustmailer 407d865a9a update 2026-05-15 10:07:02 +08:00
rustmailer e708dc524b Update reset.rs 2026-05-15 01:17:40 +08:00
rustmailer 55996f8f9b Update README.md 2026-05-15 00:23:27 +08:00
rustmailer 8bb39b4095 update 2026-05-14 22:45:46 +08:00
rustmailer 0a7a22fa7f Update index.tsx 2026-05-14 22:15:58 +08:00
rustmailer 3fa4fe453c update 2026-05-14 22:11:10 +08:00
rustmailer 907e59027d update 2026-05-14 20:40:38 +08:00
rustmailer 3ea330c884 update 2026-05-14 20:25:56 +08:00
rustmailer d52c5eef3e update 2026-05-14 20:20:10 +08:00
rustmailer 6b6f11e4c1 feat: cache mailbox list for 10 minutes and show progress on initial fetch
- Add 10-minute cache after fetching mailbox list from mail accounts
- Display progress during initial mailbox retrieval
- Prevent timeouts when handling large mailbox lists
2026-05-14 20:01:45 +08:00
rustmailer 1682f21cf7 update 2026-05-14 18:59:13 +08:00
rustmailer 5406c4322c refactor: replace native_db with memdb and add tests 2026-05-14 02:29:23 +08:00
rustmailer 0abaa66a40 feat(admin): add interactive data migration tool 2026-05-12 01:56:24 +08:00
rustmailer 123260b69d update 2026-05-10 04:22:35 +08:00
rustmailer c086a3caf5 Update index.tsx 2026-05-10 04:14:45 +08:00
rustmailer c07e39495a feat: add multiple color themes to appearance settings 2026-05-10 04:11:43 +08:00
rustmailer 41322c8357 update 2026-05-10 03:43:50 +08:00
rustmailer 2f8ba5ad40 update ui layout 2026-05-10 03:19:25 +08:00
rustmailer 213c6452a8 update 2026-05-10 01:50:49 +08:00
rustmailer 9c91025c53 update 2026-05-10 01:48:26 +08:00
rustmailer be934e2b3f Update index.tsx 2026-05-08 20:53:39 +08:00
rustmailer 12e7bb6ba3 update 2026-05-08 18:07:37 +08:00
rustmailer f66a86392d Update index.css 2026-05-08 16:29:16 +08:00
rustmailer 66b595908c feat: detect legacy tantivy data layout and abort startup with migration hint 2026-05-08 16:28:11 +08:00
rustmailer 174d56e7b4 feat: add manual download and cancel download for email accounts 2026-05-08 01:00:46 +08:00
rustmailer 7eacfbfb20 update 2026-05-07 11:56:08 +08:00
rustmailer 2802c7ea07 Update tokenizers.rs 2026-05-05 20:18:32 +08:00
rustmailer f583c3413c feat: use stemmer for multilingual token matching 2026-05-05 03:49:20 +08:00
rustmailer 9bacf3fb7a Merge branch 'main' of https://github.com/rustmailer/bichon 2026-05-02 23:53:10 +08:00
rustmailer e2fb0ee39e update 2026-05-02 23:53:04 +08:00
root 1be5fea51a update 2026-04-28 23:27:14 +08:00
rustmailer 2d29a8111b update 2026-04-26 16:54:28 +08:00
rustmailer 0c2e540834 chore(deps): replace async-imap with custom fork for imap-proto update
Switched to a personal fork of async-imap to enable a newer version
of imap-proto, addressing dependency constraints and improving
compatibility with recent parser changes.
2026-04-26 16:31:18 +08:00
rustmailer e83a00fe39 feat(import): support X-Bichon-Metadata and optimize CLI progress reporting 2026-04-25 20:14:14 +08:00
rustmailer fa70437a62 feat: support export account emails to a single mbox file 2026-04-25 05:39:58 +08:00
rustmailer 0b866c81ff refactor(workspace): decompose project into multiple crates 2026-04-23 21:45:34 +08:00
rustmailer 5b884125f7 feat: nested eml quick view 2026-04-22 09:40:52 +08:00
rustmailer c3a12eafb2 update 2026-04-21 22:13:48 +08:00
rustmailer c14834abe8 update 2026-04-21 21:23:28 +08:00
rustmailer 51329fb2e1 Update index.tsx 2026-04-21 20:44:08 +08:00
rustmailer 5ad940269f Update index.tsx 2026-04-21 20:34:54 +08:00
rustmailerandGitHub f9ceb83293 Merge pull request #198 from defrance/main
Add more info when send fail (not just status)
2026-04-21 20:18:59 +08:00
rustmailer c185ea102c update 2026-04-21 18:15:54 +08:00
Charlène Benke da79916396 Add more info when send fail (not just status) 2026-04-20 14:55:48 +02:00
rustmailer b29c6ea9ff update 2026-04-20 00:12:18 +08:00
rustmailer 7dd722f9b8 feat: add attachment search view 2026-04-19 01:01:46 +08:00
rustmailer 19b5168960 update 2026-04-17 00:29:05 +08:00
rustmailer d90943bbfe fix: Inconsistent permissions for /oauth2: Access restricted to Global Manager only #196 2026-04-16 23:50:56 +08:00
rustmailer 18a0d52c57 fix: prevent deletion of roles that are currently in use #194 2026-04-16 15:43:55 +08:00
rustmailer 15dd26228c update 2026-04-16 14:57:56 +08:00
rustmailer 39d8168de5 fix: make email/login_name immutable and add ui sortable account_name #195 2026-04-16 14:50:27 +08:00
rustmailer 82915e76ba feat: restructure IMAP mail download state and ui 2026-04-15 20:02:41 +08:00
rustmailer 6d5953c73b adjust the indexing strategy for attachment attributes 2026-04-10 02:08:30 +08:00
rustmailer 61161b5f3b fix: set journal_compression to None 2026-04-09 03:46:02 +08:00
rustmailer 286ae16057 update 2026-04-05 16:06:07 +08:00
rustmailer 8b4dc44c07 update 2026-04-01 21:34:54 +08:00
rustmailer 64a66b4f98 update 2026-04-01 13:10:02 +08:00
rustmailer 5a50f5e327 update 2026-04-01 12:27:10 +08:00
rustmailer bd10e15c65 feat: use fjall to store detached emails and attachments 2026-04-01 04:49:18 +08:00
rustmailer c123ecb24a update dashboard desc 2026-03-27 14:41:09 +08:00
rustmailer 01182dc90d update profile-form.tsx #106 2026-03-27 09:04:18 +08:00
rustmailer 7626634863 update 2026-03-26 22:02:21 +08:00
rustmailer 0914bf710e update, remove the has_attachment field from the envelopes table. 2026-03-26 21:53:19 +08:00
rustmailer 3f11c5dbbf feat: Ability to add/remove tags from any list of messages #189 2026-03-26 21:22:21 +08:00
rustmailer 5d0039cb74 update 2026-03-25 17:26:57 +08:00
rustmailer a41b5417e3 Refactor: decouple email body and attachment storage 2026-03-24 21:48:04 +08:00
rustmailer c19f3977ba feat(search): add advanced attachment filters for extension, category and mime type 2026-03-19 20:23:20 +08:00
rustmailer 884fdeba10 feat(search): expand default search scope and support specific field filtering 2026-03-19 15:44:20 +08:00
rustmailer 2228e98410 feat(ui): sync search filters with URL and add dashboard navigation 2026-03-18 20:16:01 +08:00
rustmailer d690f57290 refactor: use UUID for envelope id to prevent accidental deletion 2026-03-18 01:04:41 +08:00
rustmailer 8a42fcdb4a update 2026-03-17 11:39:02 +08:00
rustmailer 5028061f20 Update nested-email-dialog.tsx 2026-03-15 20:26:14 +08:00
rustmailer a8b3b24d59 feat: support nested EML attachment preview and download #150 2026-03-15 18:55:24 +08:00
rustmailer af0f47c0e3 feat(search): integrate mailbox directory tree into search interface 2026-03-15 03:36:02 +08:00
rustmailer 2b10d201ee feat(bichonctl): add support for decoding MIME-encoded X-Gmail-Labels in mbox #182 2026-03-13 13:19:49 +08:00
rustmailer 638a93f184 fix: Bichonctl Thunderbird upload crashes #178 2026-03-12 11:09:59 +08:00
rustmailer 40eca89a75 feat: Allow to host under subpath #145 2026-03-12 01:55:14 +08:00
rustmailer f3c46f97b9 fix: add placeholders for dashboard data to prevent 500 errors 2026-03-11 23:18:16 +08:00
rustmailer 396383aa97 feat: Saving user's page size choices #171 2026-03-11 12:40:20 +08:00
rustmailer 8f331080bf Update license headers and copyright year to 2025-2026 across the codebase. 2026-03-10 01:32:57 +08:00
rustmailer 4b0d571cf2 feat(smtp): implement built-in SMTP server for mail ingestion
- Add lightweight SMTP server support using `lettre` and `tokio`.
- Implement `DATA_SMTP_INGEST` permission check for inbound mail.
- Support real-time email archiving via SMTP protocol.
- Integrate with existing EML index manager for automated indexing.
2026-03-10 01:25:02 +08:00
rustmailer 16f0fad91e Update README.md 2026-03-07 19:38:57 +08:00
rustmailer dda6d77046 Update README.md 2026-03-07 19:37:31 +08:00
rustmailer a273b7f5e1 Fix eml ID conversion issue 2026-03-07 17:50:20 +08:00
rustmailer 2cba001431 update tempalte 2026-03-07 10:09:34 +08:00
rustmailer 54ed2c3c0a chore: optimize CPU usage #159 2026-03-06 22:54:38 +08:00
rustmailer 5e3d0f1c06 fix : Memory usage keeps growing #167 2026-03-06 21:04:36 +08:00
rustmailer a9a9b4a85f fix delete emails 2026-03-06 20:49:18 +08:00
rustmailer a5ea98f731 Update README.md 2026-03-06 19:07:06 +08:00
rustmailer 21c3d2b795 Update README.md 2026-03-06 19:03:22 +08:00
rustmailer c15fe2a503 remove unnecessary code. 2026-03-06 16:03:43 +08:00
rustmailer 5f013ea173 add regex pattern validation via DuckDB 2026-03-05 16:41:30 +08:00
rustmailer 393b7361e8 update search placeholder to support regex 2026-03-05 16:23:12 +08:00
rustmailer fd35f4be8e fix: Sync settings modal doesn't fit on smaller viewport #168 2026-03-05 15:59:50 +08:00
rustmailer d2936ed4a7 refactor!: replace Tantivy search engine with DuckDB 2026-03-03 12:30:26 +08:00
rustmailer ef4ab3496e fix: Search before date picker: go back to selected date #148 2026-02-10 23:43:23 +08:00
rustmailer 2295585deb update 2026-02-01 21:58:54 +08:00
rustmailer 57afa30b5b fix: make pst recipient_table optional 2026-02-01 16:32:42 +08:00
rustmailer ba8ecdd899 update 2026-01-29 19:19:47 +08:00
rustmailer 673e593c4f fix: treat ID command as best-effort and ignore failures 2026-01-29 19:19:39 +08:00
rustmailer 579822762f chore: remove bb8 pool for IMAP; create a new session per operation to avoid stale connections 2026-01-28 20:41:17 +08:00
rustmailer d63b1e0d7c fix: batch size validation 2026-01-28 20:39:47 +08:00
rustmailer a0d8d069c0 bump versions 2026-01-28 13:20:45 +08:00
rustmailer bdbbc04832 chore: adjust IMAP connection timeout configuration 2026-01-28 13:20:09 +08:00
rustmailer fed28c3eca fix: add tolerant HTML-to-text extraction (#141) 2026-01-28 13:19:33 +08:00
rustmailer 01dba4f71b fix: switch from PUID/PGID env vars to Docker --user for permissions 2026-01-28 13:15:06 +08:00
572 changed files with 71251 additions and 46041 deletions
@@ -8,6 +8,12 @@ assignees: ""
> **Please write and communicate in English.**
---
**Help us build a more stable Bichon!** 🛠️
While we look into this bug, consider sharing your usage patterns in our [2026 Roadmap Survey](https://docs.google.com/forms/d/e/1FAIpQLScOlwsiUMfyQPBCLW2MLkygdRmAutEgvXDYPzzvEGPz0HFPXQ/viewform) to help us prioritize stability and features.
---
### Version
Which version are you using?
+7
View File
@@ -8,6 +8,13 @@ assignees: ""
> **Please write and communicate in English.**
---
### 📢 Shape the Future of Bichon
**Want your requested feature to be prioritized?** Help us shape the **2026 Roadmap** by filling out our 1-minute survey:
👉 **[Bichon User Survey](https://docs.google.com/forms/d/e/1FAIpQLScOlwsiUMfyQPBCLW2MLkygdRmAutEgvXDYPzzvEGPz0HFPXQ/viewform)**
---
### Description
What feature would you like to see?
+7 -7
View File
@@ -5,8 +5,8 @@ on:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
env:
BINARY_NAME: bichon
BINARY_CTL: bichonctl
BINARY_NAME: bichon-server
BINARY_CLI: bichon-cli
BINARY_ADMIN: bichon-admin
permissions:
@@ -82,18 +82,18 @@ jobs:
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
cargo install cross --force
cross build --release --features vendored-openssl --target=${{ matrix.target }}
cross build --release --target=${{ matrix.target }}
- name: Build Rust backend
if: matrix.target != 'aarch64-unknown-linux-gnu'
run: |
cargo build --release --features vendored-openssl --target=${{ matrix.target }}
cargo build --release --target=${{ matrix.target }}
- name: Strip binary (Linux and macOS)
if: matrix.os != 'windows-latest' && matrix.target != 'aarch64-unknown-linux-gnu'
run: |
strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
strip target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}
strip target/${{ matrix.target }}/release/${{ env.BINARY_CLI }}
strip target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}
- name: Pack artifact (Linux/macOS)
@@ -104,7 +104,7 @@ jobs:
BINARY="target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
cp README.md LICENSE release/
cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} release/
cp target/${{ matrix.target }}/release/${{ env.BINARY_CTL }} release/
cp target/${{ matrix.target }}/release/${{ env.BINARY_CLI }} release/
cp target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }} release/
tar -czvf "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" -C release .
mv "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" release/
@@ -123,7 +123,7 @@ jobs:
mkdir -p release
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" release/
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}.exe" release/
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_CLI }}.exe" release/
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}.exe" release/
Copy-Item -Path README.md -Destination release/
+3
View File
@@ -1,3 +1,6 @@
/target
.vscode
.idea
config.toml
node_modules
dedup_report.txt
Generated
+1667 -2004
View File
File diff suppressed because it is too large Load Diff
+56 -89
View File
@@ -1,57 +1,34 @@
[package]
name = "bichon"
version = "0.3.6"
[workspace]
members = [
"crates/memdb",
"crates/core",
"crates/server",
"crates/cli",
"crates/admin",
"crates/smtp",
]
resolver = "2"
[workspace.package]
version = "1.5.0"
edition = "2021"
[[bin]]
name = "bichon"
path = "src/main.rs"
[[bin]]
name = "bichonctl"
path = "src/bin/bichonctl.rs"
[[bin]]
name = "bichon-admin"
path = "src/bin/bichon_admin.rs"
[features]
default = []
vendored-openssl = ["openssl-sys"]
[profile.release]
strip = true
lto = true
opt-level = 3
codegen-units = 1
[dependencies]
chrono = "0.4.43"
clap = { version = "4.5.54", features = ["derive", "env"] }
mimalloc = "0.1.48"
native_db = "0.8.2"
[workspace.dependencies]
chrono = "0.4.44"
clap = { version = "4.6.1", features = ["derive", "env"] }
memdb = { path = "crates/memdb" }
itertools = "0.14.0"
native_model = "0.4.20"
poem = { version = "3.1.12", features = ["embed", "compression", "rustls"] }
poem-derive = "3.1.12"
poem-openapi = { version = "5.1.16", features = [
"openapi-explorer",
"rapidoc",
"scalar",
"redoc",
"swagger-ui",
"email",
] }
ring = { version = "0.17.14", features = ["std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tokio = { version = "1.49.0", features = ["full"] }
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.22", features = ["env-filter", "json"] }
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
base64 = "0.22.1"
snafu = "0.8.9"
snafu = "0.9.0"
reqwest = { version = "0.12.24", default-features = false, features = [
"json",
"stream",
@@ -61,70 +38,60 @@ reqwest = { version = "0.12.24", default-features = false, features = [
"socks",
] }
tokio-socks = "0.5.2"
http = "1.4.0"
regex = "1.12.2"
http = "1.4.1"
regex = "1.12.3"
email_address = "0.2.9"
futures = "0.3.31"
futures = "0.3.32"
utf7-imap = "0.3.2"
imap-proto = "0.16.6"
mail-parser = { version = '0.11.1', features = ["serde"] }
mail-parser = { version = '0.11.3', features = ["serde"] }
# mail-send = "0.5.2"
tokio-rustls = { version = "0.26.4", default-features = false, features = [
"ring",
"tls12",
] }
timeago = "0.5.0"
ahash = "0.8.12"
timeago = "0.6.0"
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
url = { version = "2.5.8", features = ["serde"] }
sysinfo = "0.37.2"
sysinfo = "0.39.2"
num_cpus = "1.17.0"
cacache = { version = "13.1.0", default-features = false, features = [
"tokio-runtime",
"mmap",
] }
rand = "0.9.2"
rand = "0.10.1"
encoding_rs = "0.8.35"
async-imap = { version = "0.11.1", default-features = false, features = [
"runtime-tokio",
"compress",
] }
webpki-roots = "1.0.5"
rustls = { version = "0.23.36", default-features = false, features = ["ring"] }
rustls-pki-types = "1.14.0"
webpki-roots = "1.0.7"
rustls = { version = "0.23.40", default-features = false, features = ["ring"] }
rustls-pki-types = "1.14.1"
tokio-io-timeout = "1.2.1"
bb8 = "0.9.1"
semver = "1.0.27"
semver = "1.0.28"
governor = "0.10.4"
lru = "0.16.3"
lru = "0.18.0"
mime_guess = "2.0.5"
hex = "0.4.3"
time = { version = "0.3.45", features = [
time = { version = "0.3.47", features = [
"formatting",
"parsing",
"local-offset",
] }
rust-embed = "8.11.0"
murmur3 = "0.5.2"
autoconfig = "0.4.0"
urlencoding = "2.1.3"
dashmap = "6.1.0"
# Statically links OpenSSL by compiling from source, avoiding system library dependencies
openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
dashmap = "6.2.1"
gethostname = "1.1.0"
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
itoa = "1.0.17"
html2text = "0.16.6"
bytes = "1.11.0"
itoa = "1.0.18"
html2text = "0.17.1"
bytes = "1.11.1"
dialoguer = "0.12.0"
console = "0.16.2"
toml = "0.9.8"
memmap2 = "0.9.9"
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
compressed-rtf = "1.0.0"
codepage-strings = "1.0.2"
mail-send = "0.5.2"
[dev-dependencies]
#bincode = "1.3.3"
#secret-lib = "1.0.0"
tempfile = "3.24.0"
console = "0.16.3"
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"] }
tracing-log = "0.2.0"
tokio-util = "0.7.18"
indicatif = "0.18.4"
[profile.release]
strip = true
lto = true
opt-level = 3
codegen-units = 1
+636 -421
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,2 +1,2 @@
base_url = "http://localhost:15630"
api_token = "lZHmfpH1CRr9XsRiOGd1RnOr"
api_token = "eErI7WN3PtKeLwWAbIfSXCP6"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "bichon-admin"
version.workspace = true
edition.workspace = true
[dependencies]
bichon-core = { path = "../core" }
tokio.workspace = true
dialoguer.workspace = true
console.workspace = true
indicatif.workspace = true
native_db = "0.8.2"
native_model = "0.4.20"
serde.workspace = true
serde_json.workspace = true
itertools.workspace = true
snafu.workspace = true
memdb.workspace = true
+61
View File
@@ -0,0 +1,61 @@
//
// 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 console::style;
use dialoguer::{theme::ColorfulTheme, Select};
use crate::{migrate::handle_migration, reset::handle_reset_password};
pub mod meta;
pub mod migrate;
pub mod reset;
fn main() {
run_interactive();
}
#[tokio::main]
async fn run_interactive() {
let theme = ColorfulTheme::default();
println!(
"\n{}\n",
style("BICHON ADMINISTRATIVE TOOL").bold().bright().cyan()
);
let main_options = vec![
"Reset Admin Password",
"Migrate Legacy v0.3.7 Storage to v1.x",
"Exit",
];
let selection = Select::with_theme(&theme)
.with_prompt("Select an operation")
.default(0)
.items(&main_options)
.interact()
.unwrap();
match selection {
0 => handle_reset_password(&theme),
1 => handle_migration(&theme),
_ => {
println!("{}", style("Exiting...").dim());
}
}
}
+912
View File
@@ -0,0 +1,912 @@
use std::{
collections::{BTreeMap, BTreeSet},
path::PathBuf,
sync::{Arc, LazyLock},
};
use bichon_core::{
account::{
entity::ImapConfig,
migration::{AccountModel, AccountType},
since::{DateSince, RelativeDate},
},
autoconfig::entity::MailServerConfig,
cache::imap::mailbox::Attribute,
database::batch_insert_impl,
error::{code::ErrorCode, BichonError, BichonResult},
raise_error,
token::TokenType,
users::{acl::AccessControl, role::RoleType},
};
use console::style;
use itertools::Itertools;
use memdb::{Durability, MemDb};
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000;
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[native_model(id = 3, version = 1)]
#[native_db]
pub struct CachedMailSettings {
#[primary_key]
pub domain: String,
pub config: MailServerConfig,
pub created_at: i64,
}
impl From<CachedMailSettings> for bichon_core::autoconfig::CachedMailSettings {
fn from(value: CachedMailSettings) -> Self {
Self {
domain: value.domain,
config: value.config,
created_at: value.created_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 4, version = 1)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV1 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub use_proxy: Option<u64>,
}
impl AccountV1 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 4, version = 2, from = AccountV1)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV2 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV2 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 4, version = 3, from = AccountV2)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV3 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub sync_batch_size: Option<u32>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV3 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
impl From<AccountV1> for AccountV2 {
fn from(value: AccountV1) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: false,
pgp_key: None,
}
}
}
impl From<AccountV2> for AccountV1 {
fn from(value: AccountV2) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
}
}
}
impl From<AccountV3> for AccountV2 {
fn from(value: AccountV3) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
}
}
}
impl From<AccountV2> for AccountV3 {
fn from(value: AccountV2) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
created_by: DEFAULT_ADMIN_USER_ID,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
sync_batch_size: None,
date_before: None,
}
}
}
impl From<AccountV3> for AccountModel {
fn from(value: AccountV3) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
account_name: None,
login_name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
date_before: value.date_before,
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,
created_by: value.created_by,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
imap_quota_window: None,
imap_quota_bytes: None,
auto_download_new_mailboxes: None,
download_schedule: None,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 5, version = 1)]
#[native_db(primary_key(pk -> String))]
pub struct OAuth2 {
/// A unique identifier for the OAuth2 configuration.
#[secondary_key(unique)]
pub id: u64,
/// A description of what this configuration is used for.
pub description: Option<String>,
/// The client ID used for authenticating the application with the OAuth2 provider.
pub client_id: String,
/// The client secret used in conjunction with the client ID.
///
/// Users should provide a plaintext secret.
/// The server will encrypt it using AES-256-GCM and securely store it.
/// The plaintext secret is never stored, so users must ensure it is valid for OAuth2 authentication.
pub client_secret: String,
/// The URL to redirect users to for OAuth2 authorization.
pub auth_url: String,
/// The URL to exchange authorization codes for access tokens.
pub token_url: String,
/// The URI where the OAuth2 provider will redirect to after authorization.
pub redirect_uri: String,
/// The scopes of access that are being requested (e.g., email, profile).
pub scopes: Option<Vec<String>>,
/// Any additional parameters to include in the OAuth2 requests (e.g., access_type, prompt).
pub extra_params: Option<BTreeMap<String, String>>,
/// Indicates whether this configuration is enabled or disabled.
pub enabled: bool,
/// route OAuth through proxy (when direct access is blocked)
pub use_proxy: Option<u64>,
/// The timestamp when the configuration was created, in milliseconds since the Unix epoch.
pub created_at: i64,
/// The timestamp when the configuration was last updated, in milliseconds since the Unix epoch.
pub updated_at: i64,
}
impl OAuth2 {
fn pk(&self) -> String {
format!("{}_{}", &self.created_at, &self.id)
}
}
impl From<OAuth2> for bichon_core::oauth2::entity::OAuth2 {
fn from(value: OAuth2) -> Self {
Self {
id: value.id,
description: value.description,
client_id: value.client_id,
client_secret: value.client_secret,
auth_url: value.auth_url,
token_url: value.token_url,
redirect_uri: value.redirect_uri,
scopes: value.scopes,
extra_params: value.extra_params,
enabled: value.enabled,
use_proxy: value.use_proxy,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 6, version = 1)]
#[native_db]
pub struct OAuth2PendingEntity {
/// Unique identifier for the OAuth2 request record
pub oauth2_id: u64,
pub account_id: u64,
/// CSRF protection state parameter used to verify the integrity of the authorization request
#[primary_key]
pub state: String,
/// PKCE code verifier used in the authorization code exchange process to ensure security
pub code_verifier: String,
/// Timestamp when the OAuth2 request was created, used to determine request expiration
pub created_at: i64,
}
impl From<OAuth2PendingEntity> for bichon_core::oauth2::pending::OAuth2PendingEntity {
fn from(value: OAuth2PendingEntity) -> Self {
Self {
oauth2_id: value.oauth2_id,
account_id: value.account_id,
state: value.state,
code_verifier: value.code_verifier,
created_at: value.created_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 7, version = 1)]
#[native_db]
pub struct OAuth2AccessToken {
/// The ID of the account associated with this access token.
#[primary_key]
pub account_id: u64,
/// The id of the OAuth2 configuration associated with this access token.
#[secondary_key]
pub oauth2_id: u64,
/// The OAuth2 access token used to authenticate requests to the provider.
pub access_token: Option<String>,
/// The OAuth2 refresh token used to obtain new access tokens.
pub refresh_token: Option<String>,
/// The timestamp when the token record was created, in milliseconds since the Unix epoch.
pub created_at: i64,
/// The timestamp when the token record was last updated, in milliseconds since the Unix epoch.
pub updated_at: i64,
}
impl From<OAuth2AccessToken> for bichon_core::oauth2::token::OAuth2AccessToken {
fn from(value: OAuth2AccessToken) -> Self {
Self {
account_id: value.account_id,
oauth2_id: value.oauth2_id,
access_token: value.access_token,
refresh_token: value.refresh_token,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 8, version = 1)]
#[native_db]
pub struct Proxy {
/// The unique identifier for this proxy configuration.
#[primary_key]
pub id: u64,
/// The proxy URL (e.g., socks5://127.0.0.1:1080) used to route network requests.
pub url: String,
/// The creation timestamp of this record, represented as milliseconds since the Unix epoch.
pub created_at: i64,
/// The last update timestamp of this record, represented as milliseconds since the Unix epoch.
pub updated_at: i64,
}
impl From<Proxy> for bichon_core::settings::proxy::Proxy {
fn from(value: Proxy) -> Self {
Self {
id: value.id,
url: value.url,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 9, version = 1)]
#[native_db]
pub struct UserRole {
#[primary_key]
pub id: u64,
pub name: String,
pub description: Option<String>,
pub permissions: BTreeSet<String>,
pub is_builtin: bool,
pub created_at: i64,
pub role_type: RoleType,
pub updated_at: i64,
}
impl From<UserRole> for bichon_core::users::role::UserRole {
fn from(value: UserRole) -> Self {
Self {
id: value.id,
name: value.name,
description: value.description,
permissions: value.permissions,
is_builtin: value.is_builtin,
created_at: value.created_at,
role_type: value.role_type,
updated_at: value.updated_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 10, version = 1)]
#[native_db]
pub struct BichonUser {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 10, version = 2, from = BichonUser)]
#[native_db]
pub struct BichonUserV2 {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl From<BichonUserV2> for BichonUser {
fn from(value: BichonUserV2) -> Self {
BichonUser {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
}
}
}
impl From<BichonUser> for BichonUserV2 {
fn from(value: BichonUser) -> Self {
BichonUserV2 {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
theme: None,
language: None,
}
}
}
impl From<BichonUserV2> for bichon_core::users::BichonUserV2 {
fn from(value: BichonUserV2) -> Self {
Self {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
theme: value.theme,
language: value.language,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[native_model(id = 11, version = 1)]
#[native_db]
pub struct AccessTokenModel {
/// The ID of the user who owns this token
#[secondary_key]
pub user_id: u64,
/// The unique token string used for authentication
#[primary_key]
pub token: String,
/// An optional name of the token.
pub name: Option<String>,
/// Token type: WebUI or API
pub token_type: TokenType,
/// The timestamp (in milliseconds since epoch) when the token was created.
pub created_at: i64,
/// The timestamp (in milliseconds since epoch) when the token was last updated.
pub updated_at: i64,
/// The timestamp (in milliseconds since epoch) when the token expires.
/// None means the token does not expire (this applies only to API tokens).
pub expire_at: Option<i64>,
/// The timestamp (in milliseconds since epoch) when the token was last used.
pub last_access_at: i64,
}
impl From<AccessTokenModel> for bichon_core::token::AccessTokenModel {
fn from(value: AccessTokenModel) -> Self {
Self {
user_id: value.user_id,
token: value.token,
name: value.name,
token_type: value.token_type,
created_at: value.created_at,
updated_at: value.updated_at,
expire_at: value.expire_at,
last_access_at: value.last_access_at,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[native_model(id = 1, version = 1)]
#[native_db]
pub struct MailBox {
/// The unique identifier for the mailbox
#[primary_key]
pub id: u64,
/// The ID of the account associated with the mailbox
#[secondary_key]
pub account_id: u64,
/// The unique, decoded, human-readable name of the mailbox (e.g., "INBOX", "Sent Items").
/// This is the decoded name as presented to users, derived from the IMAP server's mailbox name
/// (e.g., after decoding UTF-7 or other encodings per RFC 3501).
pub name: String,
/// Optional delimiter used to separate mailbox names in a hierarchy (e.g., "/" or ".").
/// Used in IMAP to structure nested mailboxes (e.g., "INBOX/Archive").
pub delimiter: Option<String>,
/// List of attributes associated with the mailbox (e.g., `\NoSelect`, `\Deleted`).
/// These indicate special properties, such as whether the mailbox can hold messages.
pub attributes: Vec<Attribute>,
/// The number of messages that currently exist in the mailbox.
pub exists: u32,
/// Optional number of unseen messages in the mailbox (i.e., messages without the `\Seen` flag).
pub unseen: Option<u32>,
/// The next unique identifier (UID) that will be assigned to a new message in the mailbox.
/// If `None`, the IMAP server has not provided this information.
pub uid_next: Option<u32>,
/// 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>,
}
impl From<MailBox> for bichon_core::cache::imap::mailbox::MailBox {
fn from(value: MailBox) -> Self {
Self {
id: value.id,
account_id: value.account_id,
name: value.name,
delimiter: value.delimiter,
attributes: value.attributes,
exists: value.exists,
unseen: value.unseen,
uid_next: value.uid_next,
uid_validity: value.uid_validity,
highest_uid: None,
}
}
}
pub static META_MODELS: LazyLock<Models> = LazyLock::new(|| {
let mut adapter = ModelsAdapter::new();
adapter.register_metadata_models();
adapter.models
});
pub static MAILBOX_MODELS: LazyLock<Models> = LazyLock::new(|| {
let mut adapter = ModelsAdapter::new();
adapter.register_model::<MailBox>();
adapter.models
});
pub struct ModelsAdapter {
pub models: Models,
}
impl ModelsAdapter {
pub fn new() -> Self {
ModelsAdapter {
models: Models::new(),
}
}
pub fn register_model<T: ToInput>(&mut self) {
self.models.define::<T>().expect("failed to define model ");
}
pub fn register_metadata_models(&mut self) {
self.register_model::<CachedMailSettings>();
self.register_model::<AccountV1>();
self.register_model::<AccountV2>();
self.register_model::<AccountV3>();
self.register_model::<OAuth2>();
self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>();
self.register_model::<Proxy>();
self.register_model::<UserRole>();
self.register_model::<BichonUser>();
self.register_model::<BichonUserV2>();
self.register_model::<AccessTokenModel>();
}
}
fn init_meta_database(root_path: &PathBuf) -> BichonResult<Arc<Database<'static>>> {
let mut database = Builder::new()
.set_cache_size(134217728)
.create(&META_MODELS, root_path.join("meta.db"))
.map_err(handle_database_error)?;
let rw = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<AccountV3>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<BichonUserV2>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
database
.compact()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Arc::new(database))
}
fn init_evenlope_database(root_path: &PathBuf) -> BichonResult<Arc<Database<'static>>> {
let mut database = Builder::new()
.set_cache_size(1073741824)
.create(&MAILBOX_MODELS, root_path.join("mailbox.db"))
.map_err(handle_database_error)?;
let rw = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
database
.compact()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Arc::new(database))
}
fn handle_database_error(error: native_db::db_type::Error) -> BichonError {
raise_error!(
format!("Failed to create database: {:?}", error),
ErrorCode::InternalError
)
}
pub fn list_all_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
) -> BichonResult<Vec<T>> {
let r_transaction = database
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entities: Vec<T> = r_transaction
.scan()
.primary()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entities)
}
pub fn migrate_metadata(root_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
// Pre-flight: verify old metadata databases exist
let meta_db_path = root_path.join("meta.db");
if !meta_db_path.exists() {
return Err(format!(
"Legacy metadata database not found at '{}'. \
Make sure the root directory points to a valid v0.3.7 installation.",
meta_db_path.display()
)
.into());
}
let mailbox_db_path = root_path.join("mailbox.db");
if !mailbox_db_path.exists() {
return Err(format!(
"Legacy mailbox database not found at '{}'. \
Make sure the root directory points to a valid v0.3.7 installation.",
mailbox_db_path.display()
)
.into());
}
// Initialize legacy database connections
let meta_db = init_meta_database(root_path)
.map_err(|e| format!("Failed to initialize legacy metadata database: {}", e))?;
let envelope_db = init_evenlope_database(root_path)
.map_err(|e| format!("Failed to initialize legacy envelope database: {}", e))?;
// Prepare new database directory
let db_path = root_path.join("memdb");
if !db_path.exists() {
std::fs::create_dir_all(&db_path)?;
}
// Open new database (disable full durability for faster bulk writes)
let db = MemDb::open_with(&db_path, Durability::Off)
.map_err(|e| format!("Failed to open new memdb database: {}", e))?;
println!(
"{}",
style("Step 1: Migrating Metadata Entities...")
.bold()
.cyan()
);
// Migration helper macro to reduce boilerplate
macro_rules! migrate_collection {
($name:expr, $old_type:ty, $new_type:ty, $source_db:expr) => {
print!(" > {:<25} ", $name);
let items = list_all_impl::<$old_type>($source_db)?;
let count = items.len();
let converted: Vec<$new_type> = items.into_iter().map(|a| a.into()).collect();
batch_insert_impl(&db, converted)?;
println!("{} ({} items)", style("done").green(), count);
};
}
// --- Migrate each entity type ---
migrate_collection!(
"Mail Settings",
CachedMailSettings,
bichon_core::autoconfig::CachedMailSettings,
&meta_db
);
migrate_collection!("Accounts", AccountV3, AccountModel, &meta_db);
migrate_collection!(
"OAuth2 Entities",
OAuth2,
bichon_core::oauth2::entity::OAuth2,
&meta_db
);
migrate_collection!(
"OAuth2 Pending",
OAuth2PendingEntity,
bichon_core::oauth2::pending::OAuth2PendingEntity,
&meta_db
);
migrate_collection!(
"OAuth2 Access Tokens",
OAuth2AccessToken,
bichon_core::oauth2::token::OAuth2AccessToken,
&meta_db
);
migrate_collection!(
"Proxy Settings",
Proxy,
bichon_core::settings::proxy::Proxy,
&meta_db
);
migrate_collection!(
"User Roles",
UserRole,
bichon_core::users::role::UserRole,
&meta_db
);
migrate_collection!(
"Users",
BichonUserV2,
bichon_core::users::BichonUserV2,
&meta_db
);
migrate_collection!(
"Access Tokens",
AccessTokenModel,
bichon_core::token::AccessTokenModel,
&meta_db
);
// Mailboxes (from envelope_db)
migrate_collection!(
"Mailboxes",
MailBox,
bichon_core::cache::imap::mailbox::MailBox,
&envelope_db
);
// Persist and finish
db.snapshot()
.map_err(|e| format!("Snapshot save failed: {}", e))?;
println!(
"{}",
style("Metadata migration completed successfully.")
.green()
.bold()
);
Ok(())
}
+449
View File
@@ -0,0 +1,449 @@
use std::path::{Path, PathBuf};
use bichon_core::migrate::{
count_eml_segments, do_migrate_segment, is_tantivy_index_dir,
store::{LegacyDirs, NewDirs, NewIndexWriter},
};
use console::style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
use indicatif::{ProgressBar, ProgressStyle};
pub fn handle_migration(theme: &ColorfulTheme) {
println!(
"\n{}",
style("MIGRATION: Bichon v0.3.7 Storage Architecture → v1.x")
.bold()
.yellow()
);
println!(
"{}",
style(
"This tool migrates data from the legacy v0.3.7 Tantivy-based storage \
architecture to the new v1.x \
separated index and Fjall-backed storage format."
)
.dim()
);
println!(
"{}",
style(
"Legacy v0.3.7 architecture:\n\
• envelope metadata stored in Tantivy\n\
• message data stored in Tantivy\n\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\
• attachment blobs stored in Fjall"
)
.dim()
);
println!(
"\n{} {}",
style("IMPORTANT:").yellow().bold(),
style(
"The paths below must exactly match what your old bichon server was configured with."
)
.yellow()
);
// --- bichon-root-dir ---
let root_dir_str: String = Input::with_theme(theme)
.with_prompt("Enter --bichon-root-dir (same value used by the old server)")
.validate_with(|input: &String| -> Result<(), &str> {
let path = Path::new(input);
if !path.is_absolute() {
return Err("Path must be absolute.");
}
if !path.exists() {
return Err("Directory does not exist.");
}
Ok(())
})
.interact_text()
.unwrap();
let root_path = PathBuf::from(&root_dir_str);
// --- bichon-index-dir ---
let default_index = root_path.join("envelope");
let default_new_index = root_path.join("bichon-indices");
let index_dir_str: String = Input::with_theme(theme)
.with_prompt(format!(
"Enter --bichon-index-dir (leave blank to use default: {})",
style(default_index.display()).cyan()
))
.allow_empty(true)
.validate_with(|input: &String| -> Result<(), &str> {
if input.is_empty() {
return Ok(());
}
let path = Path::new(input);
if !path.is_absolute() {
return Err("Path must be absolute.");
}
if !path.exists() {
return Err("Directory does not exist.");
}
Ok(())
})
.interact_text()
.unwrap();
let index_path = if index_dir_str.is_empty() {
default_index
} else {
PathBuf::from(&index_dir_str)
};
let new_index_path = if index_dir_str.is_empty() {
default_new_index
} else {
PathBuf::from(&index_dir_str).join("bichon-indices")
};
// --- bichon-data-dir ---
let default_data = root_path.join("eml");
let default_new_data = root_path.join("bichon-storage");
let data_dir_str: String = Input::with_theme(theme)
.with_prompt(format!(
"Enter --bichon-data-dir (leave blank to use default: {})",
style(default_data.display()).cyan()
))
.allow_empty(true)
.validate_with(|input: &String| -> Result<(), &str> {
if input.is_empty() {
return Ok(());
}
let path = Path::new(input);
if !path.is_absolute() {
return Err("Path must be absolute.");
}
if !path.exists() {
return Err("Directory does not exist.");
}
Ok(())
})
.interact_text()
.unwrap();
let data_path = if data_dir_str.is_empty() {
default_data
} else {
PathBuf::from(&data_dir_str)
};
let new_data_path = if data_dir_str.is_empty() {
default_new_data
} else {
PathBuf::from(&data_dir_str).join("bichon-storage")
};
println!("\n{}", style("Paths to be migrated:").bold());
println!("----------------------------------------");
println!(
"{:<20} : {}",
"bichon-root-dir",
style(root_path.display()).cyan()
);
println!(
"{:<20} : {}",
"bichon-index-dir",
style(index_path.display()).cyan()
);
println!(
"{:<20} : {}",
"bichon-data-dir",
style(data_path.display()).cyan()
);
println!("----------------------------------------");
println!(
"\n{} Checking legacy v0.3.7 storage layout...",
style("").yellow()
);
match is_legacy_data_layout_with_paths(&index_path, &data_path) {
Ok(true) => {
println!(
"{} {}",
style("").green(),
style("Legacy v0.3.7 Tantivy-based storage detected. Migration to v1.x is required.")
.yellow()
);
}
Ok(false) => {
println!(
"{} {}",
style("").green(),
style("No legacy v0.3.7 storage layout was detected at the specified paths.").green()
);
println!(
"{}",
style(
"The selected directories may already be using the v1.x storage architecture."
)
.dim()
);
return;
}
Err(e) => {
eprintln!(
"{} Failed to verify legacy storage layout: {:?}",
style("ERROR:").red().bold(),
e
);
std::process::exit(1);
}
}
println!(
"\n{} {}",
style("").yellow(),
style(
"This migration is non-destructive. Existing v0.x storage files will remain unchanged."
)
.yellow()
);
if !Confirm::with_theme(theme)
.with_prompt("Ready to migrate?")
.default(true)
.interact()
.unwrap()
{
println!("{}", style("Migration cancelled.").dim());
return;
}
// Step 1: Migrate metadata (meta.db + mailbox.db → memdb)
match crate::meta::migrate_metadata(&root_path) {
Ok(()) => {}
Err(e) => {
eprintln!(
"\n{} Metadata migration failed:\n{}",
style("").red().bold(),
style(e).red()
);
eprintln!(
"{}",
style("Aborting migration. No changes have been made to Tantivy data.").yellow()
);
return;
}
}
println!(
"\n{} {}",
style("").yellow(),
style("Step 2: Migrating email index and blob data...").cyan()
);
println!(
"\n{} {}",
style("").blue(),
style("Batch size controls memory usage during migration:").dim()
);
println!(
" {} 1000 — ~500MB RAM (slower, low memory)",
style("").dim()
);
println!(" {} 3000 — ~1GB RAM (recommended)", style("").dim());
println!(
" {} 5000 — ~2GB RAM (faster, high memory)",
style("").dim()
);
println!(
" {} Note: actual memory usage depends on your average email size.",
style("").yellow()
);
println!(
" {} If your mailbox contains many large attachments, use a smaller batch size.\n",
style(" ").dim()
);
let batch_size: u32 = {
let input: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter batch size (affects memory usage, see notes above)")
.default("3000".to_string())
.validate_with(|s: &String| match s.trim().parse::<usize>() {
Ok(n) if n > 0 => Ok(()),
_ => Err("Please enter a valid positive number"),
})
.interact_text()
.unwrap_or("3000".to_string());
input.trim().parse::<u32>().unwrap_or(3000)
};
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,
Err(e) => {
eprintln!(
"\n{} Failed to count EML segments:\n{:?}",
style("").red().bold(),
e
);
return;
}
};
if total_segments == 0 {
println!(
"{} {}",
style("").green(),
style("No EML segments found. Nothing to migrate.").bold()
);
return;
}
println!(
"{} EML segments to migrate: {}",
style("").yellow(),
style(total_segments).cyan()
);
let pb = ProgressBar::new(total_segments as u64);
pb.set_style(
ProgressStyle::default_bar()
.template(
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}",
)
.unwrap()
.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;
for seg_idx in 0..total_segments {
let seg_total: std::cell::Cell<usize> = std::cell::Cell::new(0);
pb.set_message(format!("Segment {}/{}", seg_idx + 1, total_segments));
let legacy = LegacyDirs::new(index_path.clone(), data_path.clone());
match do_migrate_segment(
batch_size,
legacy,
&mut writer,
seg_idx,
|msg| {
if let Some(data) = msg.strip_prefix("TOTAL:") {
seg_total.set(data.parse().unwrap_or(0));
} else if let Some(data) = msg.strip_prefix("PHASE1:") {
let parts: Vec<&str> = data.split('/').collect();
let scanned: usize = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
let total: usize = parts
.get(1)
.and_then(|s| s.split_once(" skipped:").map(|(n, _)| n))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let skipped: usize = data
.split_once("skipped:")
.and_then(|(_, s)| s.parse().ok())
.unwrap_or(0);
let pct = if total > 0 {
(scanned * 100) / total
} else {
0
};
pb.set_message(format!(
"Segment {}/{} [scanning {}/{} skipped:{} {}%]",
seg_idx + 1,
total_segments,
scanned,
total,
skipped,
pct,
));
} else if let Some(data) = msg.strip_prefix("PROGRESS:") {
let parts: Vec<&str> = data.split(':').collect();
let migrated: usize = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
let total = seg_total.get();
let pct = if total > 0 {
(migrated * 100) / total
} else {
0
};
pb.set_message(format!(
"Segment {}/{} [migrating {}/{} {}%]",
seg_idx + 1,
total_segments,
migrated,
total,
pct,
));
} else if let Some(warn) = msg.strip_prefix("WARN:") {
pb.println(format!("{} {}", style("").yellow(), warn));
} else if let Some(done_data) = msg.strip_prefix("DONE:") {
let parts: Vec<&str> = done_data.split(':').collect();
let migrated: usize = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
let skipped: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
grand_total_migrated += migrated;
grand_total_skipped += skipped;
}
},
) {
Ok(()) => {}
Err(e) => {
pb.finish_with_message(format!("{}", style("Migration failed.").red()));
eprintln!("\n{} {:?}", style("").red().bold(), e);
return;
}
}
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
));
println!(
"{} {}",
style("").green(),
style("Migration completed successfully!").bold()
);
}
pub fn is_legacy_data_layout_with_paths(
envelope_dir: &PathBuf,
eml_dir: &PathBuf,
) -> std::io::Result<bool> {
let envelope_result = is_tantivy_index_dir(envelope_dir)?;
let eml_result = is_tantivy_index_dir(eml_dir)?;
Ok(envelope_result || eml_result)
}
@@ -1,41 +1,14 @@
use std::{
fs,
path::{Path, PathBuf},
rc::Rc,
};
use std::path::{Path, PathBuf};
use bichon::modules::{
cli::admin::meta::{find_admin, init_meta_database, update_admin_password},
error::BichonError,
use bichon_core::{
admin::meta::{find_admin, open_database, update_admin_password},
utils::encrypt::internal_decrypt_string,
};
use console::{style, Emoji};
use dialoguer::Confirm;
use dialoguer::{theme::ColorfulTheme, Input, Password, Select};
use native_db::Database;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
#[tokio::main]
async fn main() {
let theme = ColorfulTheme::default();
println!(
"\n{}\n",
style("BICHON ADMINISTRATIVE TOOL").bold().bright().cyan()
);
let main_options = vec!["Reset Admin Password", "Exit"];
let selection = Select::with_theme(&theme)
.with_prompt("Select an operation")
.default(0)
.items(&main_options)
.interact()
.unwrap();
if selection == 1 {
println!("{}", style("Exiting...").dim());
return;
}
let root_dir_str: String = Input::with_theme(&theme)
pub fn handle_reset_password(theme: &ColorfulTheme) {
let root_dir_str: String = Input::with_theme(theme)
.with_prompt("Enter the absolute path for 'bichon_root_dir'")
.validate_with(|input: &String| -> Result<(), &str> {
let path = Path::new(input);
@@ -45,9 +18,9 @@ async fn main() {
if !path.exists() {
return Err("Directory does not exist.");
}
let has_metadata = path.join("meta.db").exists();
if !has_metadata {
return Err("Invalid directory: 'meta.db' not found.");
let memdb_dir = path.join("memdb");
if !memdb_dir.exists() || !memdb_dir.is_dir() {
return Err("Invalid directory: 'memdb' data directory not found.");
}
Ok(())
})
@@ -55,40 +28,14 @@ async fn main() {
.unwrap();
let root_path = PathBuf::from(&root_dir_str);
let database: Rc<Database<'static>> = match init_meta_database(&root_path.join("meta.db")) {
Ok(database) => database,
Err(e) => match e {
BichonError::Generic {
message,
location,
code,
} => {
if message.contains("RedbDatabaseError(DatabaseAlreadyOpen") {
println!("\n{}", style("ERROR: Database is locked.").red().bold());
println!(
"{}",
style("The Bichon service is likely still running.").yellow()
);
println!(
"Since the database cannot be shared between multiple instances, \n\
you must {} the Bichon service before proceeding.",
style("STOP").underlined().bold()
);
std::process::exit(1);
} else {
eprintln!(
"\n{} (Code: {:#?})\nLocation: {}\nMessage: {}",
style("A database error occurred:").red().bold(),
code,
location,
message
);
std::process::exit(1);
}
}
},
};
let database = open_database(&root_path.join("memdb")).unwrap_or_else(|e| {
eprintln!(
"\n{} Failed to open database.",
style("ERROR:").red().bold()
);
eprintln!("Details: {:?}", e);
std::process::exit(1);
});
let admin = find_admin(&database);
@@ -124,24 +71,24 @@ async fn main() {
"Enter encryption password manually",
"Read from password file",
];
let method = Select::with_theme(&theme)
let method = Select::with_theme(theme)
.with_prompt("How would you like to provide the database encryption key?")
.items(&auth_methods)
.interact()
.unwrap();
let raw_key = if method == 0 {
Password::with_theme(&theme)
Input::with_theme(theme)
.with_prompt("Enter Encryption Password")
.interact()
.unwrap()
} else {
let file_path: String = Input::with_theme(&theme)
let file_path: String = Input::with_theme(theme)
.with_prompt("Enter path to encryption password file")
.interact_text()
.unwrap();
match fs::read_to_string(&file_path) {
match std::fs::read_to_string(&file_path) {
Ok(content) => content.trim().to_string(),
Err(e) => {
println!("{}: {}", style("Failed to read file").red(), e);
@@ -170,7 +117,7 @@ async fn main() {
style("BICHON_ENCRYPT_PASSWORD_FILE").green()
);
if Confirm::with_theme(&theme)
if Confirm::with_theme(theme)
.with_prompt(prompt_message)
.default(true)
.interact()
@@ -216,7 +163,7 @@ async fn main() {
println!("{:<12} : {}", "Password", pwd_display);
println!("----------------------------------------");
if !dialoguer::Confirm::with_theme(&theme)
if !dialoguer::Confirm::with_theme(theme)
.with_prompt(format!(
"Do you want to reset the password for '{}'?",
user.username
@@ -255,13 +202,13 @@ async fn main() {
.bold()
);
let new_login_password = Password::with_theme(&theme)
let new_login_password = Password::with_theme(theme)
.with_prompt("Enter new Admin Login Password")
.with_confirmation("Repeat password to confirm", "Passwords do not match!")
.interact()
.unwrap();
if !Confirm::with_theme(&theme)
if !Confirm::with_theme(theme)
.with_prompt("Proceed with database update?")
.interact()
.unwrap()
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "bichon-cli"
version.workspace = true
edition.workspace = true
[dependencies]
bichon-core = { path = "../core" }
tokio.workspace = true
serde.workspace = true
clap.workspace = true
dialoguer.workspace = true
console.workspace = true
mail-parser.workspace = true
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
+81
View File
@@ -0,0 +1,81 @@
use crate::BichonCliConfig;
use bichon_core::{base64_encode, envelope::meta::BichonMetadata, store::envelope::Envelope};
use chrono::{TimeZone, Utc};
use reqwest::Client;
use tokio::io::AsyncWriteExt;
pub async fn download_and_export_with_json_header(
client: &Client,
config: &BichonCliConfig,
envelope: Envelope,
file: &mut tokio::fs::File,
) -> bool {
let url = format!(
"{}/api/v1/download-message/{}/{}",
config.base_url, &envelope.account_id, &envelope.id
);
let response = match client
.get(&url)
.header("Authorization", format!("Bearer {}", config.api_token))
.send()
.await
{
Ok(res) => {
if !res.status().is_success() {
eprintln!(
" ✘ HTTP Error {}: Failed for {}",
res.status(),
&envelope.id
);
return false;
}
res
}
Err(e) => {
eprintln!(" ✘ Network error: {} for {}", e, &envelope.id);
return false;
}
};
let email_bytes = match response.bytes().await {
Ok(b) => b,
Err(e) => {
eprintln!(
" ✘ Failed to read response body for {}: {}",
&envelope.id, e
);
return false;
}
};
let date_dt = Utc.timestamp_opt(envelope.date / 1000, 0).unwrap();
let date_str = date_dt.format("%a %b %e %H:%M:%S %Y").to_string();
let from_line = format!("From {} {}\n", envelope.from.clone(), date_str);
let custom_header = build_metadata_header(BichonMetadata {
account_email: envelope.account_email,
mailbox_name: envelope.mailbox_name,
tags: envelope.tags,
});
let mut final_buffer =
Vec::with_capacity(from_line.len() + custom_header.len() + email_bytes.len() + 2);
final_buffer.extend_from_slice(from_line.as_bytes());
final_buffer.extend_from_slice(custom_header.as_bytes());
final_buffer.extend_from_slice(&email_bytes);
final_buffer.extend_from_slice(b"\n\n");
if let Err(e) = file.write_all(&final_buffer).await {
eprintln!(" ✘ IO Error: Failed to write to mbox: {}", e);
return false;
}
true
}
fn build_metadata_header(meta: BichonMetadata) -> String {
let json_str = serde_json::to_string(&meta).ok().unwrap();
let encoded = base64_encode!(json_str);
format!("X-Bichon-Metadata: {}\r\n", encoded)
}
+4
View File
@@ -0,0 +1,4 @@
pub mod download;
pub mod search;
pub mod sender;
pub mod stats;
+58
View File
@@ -0,0 +1,58 @@
use bichon_core::{
common::paginated::DataPage,
message::search::{EmailSearchFilter, EmailSearchRequest, SortBy},
store::envelope::Envelope,
};
use reqwest::Client;
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 {
account_ids,
..Default::default()
},
page,
page_size,
sort_by: Some(SortBy::DATE),
desc: Some(false),
};
match client
.post(&url)
.header("Authorization", format!("Bearer {}", config.api_token))
.json(&payload)
.send()
.await
{
Ok(res) if res.status().is_success() => match res.json::<DataPage<Envelope>>().await {
Ok(data) => Some(data),
Err(e) => {
eprintln!(" ✘ Failed to parse search response: {}", e);
None
}
},
Ok(res) => {
let status = res.status();
let error_body = res.text().await.unwrap_or_default();
eprintln!(
" ✘ Failed to search messages. Status: {}\n Server error: {}",
status, error_body
);
None
}
Err(e) => {
eprintln!(" ✘ Network error performing search: {}", e);
None
}
}
}
+77
View File
@@ -0,0 +1,77 @@
//
// 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 console::style;
use reqwest::Client;
use bichon_core::import::BatchEmlRequest;
use crate::BichonCliConfig;
pub async fn send_batch_request(
client: &Client,
config: &BichonCliConfig,
account_id: u64,
folder: &str,
emls: Vec<String>,
) {
let url = format!("{}/api/v1/import", config.base_url);
let payload = BatchEmlRequest {
account_id,
mail_folder: folder.to_string(),
emls,
};
let count = payload.emls.len();
match client
.post(&url)
.header("Authorization", format!("Bearer {}", config.api_token))
.json(&payload)
.send()
.await
{
Ok(res) if res.status().is_success() => {
println!(
" {} Sent {} emails to [{}]",
style("").green(),
count,
folder
);
}
Ok(res) => {
let status = res.status();
let error_body = res.text().await.unwrap_or_default();
eprintln!(
" {} Failed to send to [{}]. Status: {}\n Server error: {}",
style("").red(),
folder,
status,
error_body
);
}
Err(e) => {
eprintln!(
" {} Network error on [{}]: {}",
style("").red(),
folder,
e
);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
use bichon_core::account::stats::AccountStats;
use reqwest::Client;
use crate::BichonCliConfig;
pub async fn fetch_account_stats(
client: &Client,
config: &BichonCliConfig,
account_id: u64,
) -> Option<AccountStats> {
let url = format!("{}/api/v1/accounts/{}/stats", config.base_url, account_id);
match client
.get(&url)
.header("Authorization", format!("Bearer {}", config.api_token))
.send()
.await
{
Ok(res) if res.status().is_success() => {
match res.json::<AccountStats>().await {
Ok(stats) => Some(stats),
Err(e) => {
eprintln!(" ✘ Failed to parse stats response: {}", e);
None
}
}
}
Ok(res) => {
let status = res.status();
let error_body = res.text().await.unwrap_or_default();
eprintln!(
" ✘ Failed to fetch stats for account [{}]. Status: {}\n Server error: {}",
account_id,
status,
error_body
);
None
}
Err(e) => {
eprintln!(
" ✘ Network error fetching stats for [{}]: {}",
account_id,
e
);
None
}
}
}
@@ -1,22 +1,43 @@
//
// 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 std::process;
use console::style;
use dialoguer::{theme::ColorfulTheme, Select};
use reqwest::Client;
use crate::modules::{
use bichon_core::{
account::payload::MinimalAccount,
cli::BichonCtlConfig,
users::{permissions::Permission, view::UserView},
};
pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &ColorfulTheme) -> u64 {
let client = Client::new();
let url = format!("{}/api/v1/current-user", config.base_url);
use crate::BichonCliConfig;
async fn fetch_json<T: serde::de::DeserializeOwned>(
client: &Client,
url: &str,
token: &str,
label: &str,
) -> T {
let response = match client
.get(&url)
.header("Authorization", format!("Bearer {}", config.api_token))
.get(url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
{
@@ -36,19 +57,15 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
}
};
if !response.status().is_success() {
let status = response.status();
let error_body = response
.text()
.await
.unwrap_or_else(|_| "No error detail provided".to_string());
let status = response.status();
let body = response.text().await.unwrap_or_else(|_| String::new());
if !status.is_success() {
eprintln!(
"\n{} Server returned an error (Status: {})",
style("✘ API Error:").red().bold(),
style(status).yellow()
);
if status == 401 {
eprintln!(
"{} Your API Token seems to be invalid or expired.",
@@ -60,36 +77,66 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
style("Context:").dim()
);
}
eprintln!("{} {}", style("Response:").dim(), error_body);
eprintln!("{} {}", style("Response:").dim(), body);
process::exit(1);
}
let user: UserView = response.json().await.expect("Failed to parse user data");
println!("Welcome, {}!", style(&user.username).cyan());
let account_list_url = format!(
"{}/api/v1/minimal-account-list?only_nosync=true",
config.base_url
);
let acc_response = client
.get(&account_list_url)
.header("Authorization", format!("Bearer {}", config.api_token))
.send()
.await
.expect("Failed to fetch account list");
if !acc_response.status().is_success() {
panic!(
"Failed to retrieve accounts. Status: {}",
acc_response.status()
if body.is_empty() {
eprintln!(
"\n{} Server returned an empty response for [{}] (Status: {})",
style("✘ Empty Response:").red().bold(),
label,
status
);
eprintln!(
"{} This may be caused by a reverse proxy or middleware issue.",
style("Tip:").cyan()
);
process::exit(1);
}
let accounts: Vec<MinimalAccount> = acc_response
.json()
.await
.expect("Failed to parse minimal account list");
match serde_json::from_str::<T>(&body) {
Ok(data) => data,
Err(e) => {
eprintln!(
"\n{} Failed to parse response for [{}]: {}",
style("✘ Parse Error:").red().bold(),
label,
e
);
eprintln!("{} Raw body: {}", style("Debug:").dim(), body);
process::exit(1);
}
}
}
pub async fn verify_user_and_get_account(
config: &BichonCliConfig,
theme: &ColorfulTheme,
only_nosync: bool,
) -> MinimalAccount {
let client = Client::new();
let user: UserView = fetch_json(
&client,
&format!("{}/api/v1/current-user", config.base_url),
&config.api_token,
"current-user",
)
.await;
println!("Welcome, {}!", style(&user.username).cyan());
let accounts: Vec<MinimalAccount> = fetch_json(
&client,
&format!(
"{}/api/v1/minimal-account-list?only_nosync={only_nosync}",
config.base_url
),
&config.api_token,
"minimal-account-list",
)
.await;
if accounts.is_empty() {
println!(
@@ -106,6 +153,7 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
);
process::exit(1);
}
let required_permission = Permission::DATA_IMPORT_BATCH;
let mut selectable_accounts = Vec::new();
let mut options = Vec::new();
@@ -165,5 +213,5 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
style(&selected_acc.email).cyan().bold()
);
selected_acc.id
selected_acc.clone()
}
@@ -1,3 +1,21 @@
//
// 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 std::{
collections::HashMap,
fs,
@@ -9,13 +27,12 @@ use dialoguer::{theme::ColorfulTheme, Input};
use mail_parser::MessageParser;
use reqwest::Client;
use crate::{
base64_encode_url_safe,
modules::cli::{sender::send_batch_request, BichonCtlConfig},
};
use bichon_core::base64_encode_url_safe;
use crate::{BichonCliConfig, api::sender::send_batch_request};
pub async fn handle_eml_directory_import(
config: &BichonCtlConfig,
config: &BichonCliConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
@@ -83,7 +100,7 @@ fn scan_dir(
}
async fn process_and_upload(
config: &BichonCtlConfig,
config: &BichonCliConfig,
account_id: u64,
tasks: HashMap<String, Vec<PathBuf>>,
) {
+215
View File
@@ -0,0 +1,215 @@
use crate::api::download::download_and_export_with_json_header;
use crate::api::search::search_messages;
use crate::api::stats::fetch_account_stats;
use crate::BichonCliConfig;
use bichon_core::account::payload::MinimalAccount;
use console::style;
use dialoguer::Confirm;
use dialoguer::{theme::ColorfulTheme, Input};
use indicatif::{ProgressBar, ProgressStyle};
use reqwest::Client;
use std::path::{Path, PathBuf};
use sysinfo::Disks;
pub async fn handle_account_export(
config: &BichonCliConfig,
account: MinimalAccount,
theme: &ColorfulTheme,
) {
let client = Client::new();
println!("Fetching account statistics...");
let stats = match fetch_account_stats(&client, config, account.id).await {
Some(s) => s,
None => {
eprintln!("{} Failed to fetch account statistics.", style("").red());
return;
}
};
println!("\n--- Account Statistics ---");
println!(" Total Emails: {}", style(stats.total_count).cyan());
println!(
" Total Size: {}",
style(format_bytes(stats.total_size)).cyan()
);
let path = loop {
let input: String = Input::with_theme(theme)
.with_prompt("Enter ABSOLUTE directory path for MBOX file")
.interact_text()
.unwrap();
let p = PathBuf::from(&input);
if !p.is_absolute() {
eprintln!(
" {} {}",
style("").red(),
style("Invalid path: Must be an absolute path.").red()
);
continue;
}
if !p.exists() {
eprintln!(
" {} {}",
style("").red(),
style("Invalid path: Directory does not exist.").red()
);
continue;
}
if !p.is_dir() {
eprintln!(
" {} {}",
style("").red(),
style("Invalid path: The path provided is not a directory.").red()
);
continue;
}
break p;
};
let disks = Disks::new_with_refreshed_list();
let disk_result = disks
.list()
.iter()
.find(|d| path.starts_with(d.mount_point()))
.ok_or_else(|| "Could not identify the disk for the provided path.");
match disk_result {
Ok(disk) => {
let free_space = disk.available_space();
let required_space = (stats.total_size as f64 * 1.2) as u64;
if free_space < required_space {
eprintln!(
" {} Insufficient disk space (including 10% safety buffer)!\n Required: {} (Base: {})\n Available: {}",
style("").red(),
style(format_bytes(required_space)).yellow(),
style(format_bytes(stats.total_size)).yellow(),
style(format_bytes(free_space)).yellow()
);
return;
}
println!(
" {} Disk space check passed. (Required: {}, Available: {})",
style("").green(),
style(format_bytes(required_space)).cyan(),
style(format_bytes(free_space)).cyan()
);
}
Err(e) => {
eprintln!(" {} {}", style("").red(), style(e).red());
return;
}
}
let mbox_file = get_unique_mbox_path(&path, account.id, &account.email);
if Confirm::with_theme(theme)
.with_prompt(format!(
"Export {} emails to '{}'?",
stats.total_count,
mbox_file.display()
))
.default(true)
.interact()
.unwrap()
{
println!(
" {} Starting export ({} items per page)...",
style("").green(),
100
);
let pb = ProgressBar::new(stats.total_count as u64);
pb.set_style(ProgressStyle::with_template(
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}"
).unwrap());
let mut file = match tokio::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&mbox_file)
.await
{
Ok(f) => f,
Err(e) => {
eprintln!(" ✘ Failed to open file '{}': {}", path.display(), e);
return;
}
};
let page_size = 100;
let mut current_page = 1;
let mut total_pages;
loop {
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.clone(), &mut file)
.await;
if !success {
eprintln!(
" ✘ Failed to export email {}, skipping...",
envelope.id
);
continue;
}
pb.inc(1);
}
if current_page >= total_pages {
break;
}
current_page += 1;
} else {
pb.finish_with_message("Error");
eprintln!(
" ✘ Failed to fetch page {}. Aborting process...",
current_page
);
return;
}
}
pb.finish();
println!(" {} Export complete!", style("").green());
}
}
fn format_bytes(bytes: u64) -> String {
if bytes < 1024 {
format!("{:.2} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.2} KB", bytes / 1024)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.2} MB", bytes / 1024 / 1024)
} else {
format!("{:.2} GB", bytes / 1024 / 1024 / 1024)
}
}
fn get_unique_mbox_path(base_dir: &Path, account_id: u64, email: &str) -> PathBuf {
let email_part = email.replace(' ', "_");
let mut base_name = format!("account_{}_{}", account_id, email_part);
if base_name.starts_with('.') {
base_name = format!("_{}", base_name);
}
let mut final_path = base_dir.join(format!("{}.mbox", base_name));
let mut counter = 1;
while final_path.exists() {
final_path = base_dir.join(format!("{}_{}.mbox", base_name, counter));
counter += 1;
}
final_path
}
+172
View File
@@ -0,0 +1,172 @@
//
// 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 bichon_core::bichon_version;
use clap::Parser;
use console::style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use serde::{Deserialize, Serialize};
use std::fs;
use crate::{
auth::verify_user_and_get_account, eml::handle_eml_directory_import,
export::handle_account_export, mbox::handle_mbox_single_file_import, pst::handle_pst_import,
thunderbird::handle_thunderbird_import,
};
pub mod api;
pub mod auth;
pub mod eml;
pub mod export;
pub mod mbox;
pub mod pst;
pub mod thunderbird;
#[derive(Parser, Debug)]
#[command(
name = "bichon-cli",
author = "rustmailer",
version = bichon_version!(),
about = "A CLI tool to import email data into Bichon service"
)]
pub struct BichonCli {
/// Path to the configuration file
#[arg(
short,
long,
default_value = "config.toml",
value_name = "FILE",
help = "Sets a custom config file"
)]
pub config: std::path::PathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BichonCliConfig {
pub base_url: String,
pub api_token: String,
}
#[tokio::main]
async fn main() {
let cli = BichonCli::parse();
let theme = ColorfulTheme::default();
let config_path = &cli.config;
let mut current_config: Option<BichonCliConfig> = None;
if config_path.exists() {
if let Ok(content) = fs::read_to_string(config_path) {
if let Ok(config) = toml::from_str::<BichonCliConfig>(&content) {
println!("{}", style("✔ Existing configuration found:").green());
println!(" Base URL: {}", style(&config.base_url).yellow());
println!(" API Token: {}", style(&config.api_token).yellow());
// Confirm with user
if Confirm::with_theme(&theme)
.with_prompt("Do you want to use this configuration?")
.default(true)
.interact()
.unwrap()
{
current_config = Some(config);
}
}
}
}
let final_config = match current_config {
Some(conf) => conf,
None => {
println!("\n{}", style("Please enter Bichon service details:").bold());
let url: String = Input::with_theme(&theme)
.with_prompt("Bichon Base URL")
.default("http://localhost:15630".into())
.interact_text()
.unwrap();
let token: String = Input::with_theme(&theme)
.with_prompt("API Token")
.interact_text()
.unwrap();
let conf = BichonCliConfig {
base_url: url,
api_token: token,
};
// 3. Offer to save the new configuration
if Confirm::with_theme(&theme)
.with_prompt("Save this configuration for future use?")
.default(true)
.interact()
.unwrap()
{
let toml_str = toml::to_string(&conf).unwrap();
fs::write(config_path, toml_str).expect("Failed to save config file");
println!("{}", style("Configuration saved successfully!").green());
}
conf
}
};
let operations = &[
"1. Import: Upload email data to Bichon",
"2. Export: Download account data as MBOX file",
];
let op_idx = Select::with_theme(&theme)
.with_prompt("Select operation")
.items(operations)
.default(0)
.interact()
.unwrap();
match op_idx {
0 => {
let target_account = verify_user_and_get_account(&final_config, &theme, true).await;
let import_modes = &[
"1. EML: Scan directory recursively (Maintains folder structure)",
"2. MBOX: Single archive file (Stream from one file)",
"3. Thunderbird: Import from local profile directory",
"4. PST: Outlook Personal Storage (Single .pst file)",
];
let mode_idx = Select::with_theme(&theme)
.with_prompt("Select import method")
.items(import_modes)
.default(0)
.interact()
.unwrap();
match mode_idx {
0 => handle_eml_directory_import(&final_config, target_account.id, &theme).await,
1 => handle_mbox_single_file_import(&final_config, target_account.id, &theme).await,
2 => handle_thunderbird_import(&final_config, target_account.id, &theme).await,
3 => handle_pst_import(&final_config, target_account.id, &theme).await,
_ => unreachable!(),
}
}
1 => {
let target_account = verify_user_and_get_account(&final_config, &theme, false).await;
handle_account_export(&final_config, target_account, &theme).await;
}
_ => unreachable!(),
}
}
+134
View File
@@ -0,0 +1,134 @@
//
// 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 std::collections::HashSet;
pub fn determine_folder(labels_raw: &str) -> String {
let mut status_blacklist = HashSet::new();
status_blacklist.insert("Opened");
status_blacklist.insert("Unread");
status_blacklist.insert("Archived");
let all_labels: Vec<&str> = labels_raw
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if all_labels.is_empty() {
return "Unknown".to_string();
}
let filtered: Vec<&str> = all_labels
.iter()
.filter(|&&l| !status_blacklist.contains(l))
.cloned()
.collect();
match filtered.len() {
// Case A: If all labels were status labels, fallback to the first original label
0 => all_labels[0].to_string(),
// Case B: If only one label remains, that's our target destination
1 => filtered[0].to_string(),
// Case C: Multiple labels remain (e.g., ["Inbox", "medium"])
_ => {
// Prioritize custom business labels by excluding generic locations like "Inbox" or "Sent"
let business_label = filtered.iter().find(|&&l| l != "Inbox" && l != "Sent");
match business_label {
// Return the first non-generic label found
Some(label) => label.to_string(),
// If only generic labels remain (e.g., ["Sent", "Inbox"]), pick the first available
None => filtered[0].to_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());
}
}
+419
View File
@@ -0,0 +1,419 @@
//
// 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 std::collections::HashMap;
use std::path::PathBuf;
use crate::api::sender::send_batch_request;
use crate::mbox::gmail::determine_folder;
use crate::mbox::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::MessageParser;
use reqwest::Client;
/// Skip emails larger than this with a warning (100 MB).
const MAX_EMAIL_BYTES: usize = 100 * 1024 * 1024;
/// Flush a folder buffer when accumulated base64 bytes exceed this (200 MB).
const MAX_BUFFER_BYTES: usize = 200 * 1024 * 1024;
pub mod gmail;
pub mod reader;
pub async fn handle_mbox_single_file_import(
config: &BichonCliConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
let path_str: String = Input::with_theme(theme)
.with_prompt("Enter the path to your SINGLE .mbox file")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if !p.exists() {
return Err("The specified path does not exist.");
}
if !p.is_file() {
return Err("MBOX mode requires a SINGLE file, not a directory.");
}
Ok(())
})
.interact_text()
.unwrap();
let mbox_path = PathBuf::from(path_str);
let options = vec![
"Use labels from mail headers (X-Gmail-Labels)",
"Specify a single target folder for all emails",
"Use X-Bichon-Metadata header (Automatic)",
];
let selection = Select::with_theme(theme)
.with_prompt("How should we determine the target folder?")
.items(&options)
.default(0)
.interact()
.unwrap();
let target_folder: Option<String> = match selection {
0 => None,
1 => {
let folder: String = Input::with_theme(theme)
.with_prompt("Target folder name")
.default("INBOX".into())
.interact_text()
.unwrap();
Some(folder)
}
2 => None,
_ => unreachable!(),
};
if let Some(ref folder) = target_folder {
println!(
"{}",
style(format!("Mode: Fixed folder ({})", folder)).dim()
);
} else {
println!("{}", style("Mode: Dynamic (header-based)").dim());
}
println!(
"\n{} Ready to process MBOX file: {}",
style("").green(),
style(mbox_path.display()).cyan()
);
if let Ok(meta) = std::fs::metadata(&mbox_path) {
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
println!(
"{}",
style(format!("Processing file: {:.1} MB", size_mb)).dim()
);
}
if Confirm::with_theme(theme)
.with_prompt("Start importing?")
.default(true)
.interact()
.unwrap()
{
run_import(account_id, &mbox_path, config, target_folder).await
}
}
pub async fn run_import(
account_id: u64,
mbox_path: &PathBuf,
config: &BichonCliConfig,
target_folder: Option<String>,
) {
let client = Client::new();
let mbox = match MboxFile::from_file(mbox_path) {
Ok(mbox) => mbox,
Err(err) => {
println!("Skipping invalid MBOX: {} ({})", mbox_path.display(), err);
return;
}
};
let mut folder_buffers: HashMap<String, Vec<String>> = HashMap::new();
let mut total_buffered_bytes: usize = 0;
let batch_limit = 50;
let mut skipped_count: u64 = 0;
println!("Starting import process...");
for (index, e) in mbox.iter().enumerate() {
let msg_num = index + 1;
let body = e.data;
if body.len() > MAX_EMAIL_BYTES {
let size_mb = body.len() as f64 / 1024.0 / 1024.0;
eprintln!(
"{} {}: email #{} is {:.1} MB (limit 100 MB). Skipping...",
style("Warning").yellow().bold(),
style(format!("oversized")).dim(),
msg_num,
size_mb,
);
skipped_count += 1;
continue;
}
let message = match MessageParser::new()
.with_minimal_headers()
.default_header_text()
.parse(body)
{
Some(msg) => msg,
None => {
eprintln!(
"{} {}: {}",
style("Warning").yellow().bold(),
style(format!("at message #{}", msg_num)).dim(),
"Failed to parse email structure. Skipping..."
);
skipped_count += 1;
continue;
}
};
let mut metadata: Option<BichonMetadata> = None;
if let Some(meta_header) = message.header_raw("X-Bichon-Metadata") {
metadata = parse_bichon_metadata(meta_header);
}
let get_default_folder = || {
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 {
folder.clone()
} else if let Some(ref meta) = metadata {
meta.mailbox_name.clone().unwrap_or_else(get_default_folder)
} else {
get_default_folder()
};
// Drop message before base64-encoding to free MIME parse memory.
drop(message);
let b64_eml = base64_encode_url_safe!(&body);
let encoded_len = b64_eml.len();
let buffer = folder_buffers
.entry(folder_name.clone())
.or_insert_with(Vec::new);
buffer.push(b64_eml);
total_buffered_bytes += encoded_len;
if buffer.len() >= batch_limit || total_buffered_bytes >= MAX_BUFFER_BYTES {
let emls_to_send = folder_buffers.remove(&folder_name).unwrap();
let freed: usize = emls_to_send.iter().map(|s| s.len()).sum();
total_buffered_bytes = total_buffered_bytes.saturating_sub(freed);
send_batch_request(&client, config, account_id, &folder_name, emls_to_send).await;
}
}
for (folder_name, emls) in folder_buffers {
if !emls.is_empty() {
send_batch_request(&client, config, account_id, &folder_name, emls).await;
}
}
if skipped_count > 0 {
println!(
"{}",
style(format!(
"Skipped {} email(s) (oversized or unparseable).",
skipped_count
))
.yellow()
.bold()
);
}
println!("{}", style("Import completed successfully!").green().bold());
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// Fake sender: records every flushed batch as (folder_name, email_count, total_bytes).
struct FakeSender {
batches: Vec<(String, usize, usize)>,
}
impl FakeSender {
fn new() -> Self {
Self { batches: vec![] }
}
fn send(&mut self, folder: &str, emls: Vec<String>) {
let count = emls.len();
let bytes: usize = emls.iter().map(|s| s.len()).sum();
self.batches.push((folder.to_string(), count, bytes));
// emls is dropped here, simulating real send
}
}
fn fake_encode(size: usize) -> String {
// base64 expands ~1.33x, so the encoded string is roughly this long.
// We just need a predictable byte size, so use a repeated character.
"x".repeat(size)
}
#[test]
fn flush_on_global_byte_threshold() {
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
let mut total_bytes: usize = 0;
let batch_limit = 50;
let mut sender = FakeSender::new();
// Simulate 3 emails, each 80 MB encoded, spread across 3 folders.
// After each email, global total goes up by 80 MB.
// After the 3rd email: 240 MB > 200 MB → flush the folder that got the 3rd email.
let emails = vec![
("Inbox", 80_000_000),
("Sent", 80_000_000),
("Archive", 80_000_000),
];
for (folder, eml_size) in emails {
let encoded = fake_encode(eml_size);
let len = encoded.len();
let buffer = buffers.entry(folder.to_string()).or_insert_with(Vec::new);
buffer.push(encoded);
total_bytes += len;
if buffer.len() >= batch_limit || total_bytes >= MAX_BUFFER_BYTES {
let sent = buffers.remove(folder).unwrap();
let freed: usize = sent.iter().map(|s| s.len()).sum();
total_bytes = total_bytes.saturating_sub(freed);
sender.send(folder, sent);
}
}
// The 3rd email should trigger a global flush of "Archive".
assert_eq!(sender.batches.len(), 1);
assert_eq!(sender.batches[0].0, "Archive");
assert_eq!(sender.batches[0].1, 1);
// "Inbox" and "Sent" are still buffered (160 MB total).
assert_eq!(buffers.len(), 2);
assert!(buffers.contains_key("Inbox"));
assert!(buffers.contains_key("Sent"));
assert_eq!(total_bytes, 160_000_000);
}
#[test]
fn flush_on_count_threshold() {
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
let mut total_bytes: usize = 0;
let batch_limit = 3;
let mut sender = FakeSender::new();
// 4 small emails all to Inbox, well under byte threshold.
for _ in 0..4 {
let encoded = fake_encode(100); // tiny
let len = encoded.len();
let buffer = buffers
.entry("Inbox".to_string())
.or_insert_with(Vec::new);
buffer.push(encoded);
total_bytes += len;
if buffer.len() >= batch_limit || total_bytes >= MAX_BUFFER_BYTES {
let sent = buffers.remove("Inbox").unwrap();
let freed: usize = sent.iter().map(|s| s.len()).sum();
total_bytes = total_bytes.saturating_sub(freed);
sender.send("Inbox", sent);
}
}
// Count=3 should trigger flush once; the 4th email stays buffered.
assert_eq!(sender.batches.len(), 1);
assert_eq!(sender.batches[0].1, 3); // 3 emails flushed
let remaining = buffers.get("Inbox").unwrap();
assert_eq!(remaining.len(), 1); // 1 still buffered
}
#[test]
fn global_bytes_exact_boundary() {
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
let mut total_bytes: usize = 0;
let mut sender = FakeSender::new();
// Push one email that puts us right at 200 MB.
let encoded = fake_encode(MAX_BUFFER_BYTES);
let len = encoded.len();
buffers
.entry("Inbox".to_string())
.or_insert_with(Vec::new)
.push(encoded);
total_bytes += len;
if total_bytes >= MAX_BUFFER_BYTES {
let sent = buffers.remove("Inbox").unwrap();
let freed: usize = sent.iter().map(|s| s.len()).sum();
total_bytes = total_bytes.saturating_sub(freed);
sender.send("Inbox", sent);
}
// Should have flushed on the boundary.
assert_eq!(sender.batches.len(), 1);
assert_eq!(total_bytes, 0);
}
#[test]
fn flush_one_folder_does_not_lose_others() {
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
let mut total_bytes: usize = 0;
let batch_limit = 50;
let mut sender = FakeSender::new();
// Build up A to 150 MB, B to 100 MB (total 250 MB > 200 MB).
// A should trigger flush; B should stay buffered.
let folder_a = "A".to_string();
let folder_b = "B".to_string();
// Folder A: 150 MB
let encoded = fake_encode(150_000_000);
let len = encoded.len();
buffers.entry(folder_a.clone()).or_insert_with(Vec::new).push(encoded);
total_bytes += len;
// Folder B: 100 MB → total 250 MB → trigger flush on B
let encoded = fake_encode(100_000_000);
let len = encoded.len();
buffers.entry(folder_b.clone()).or_insert_with(Vec::new).push(encoded);
total_bytes += len;
// Check trigger on B
let b_buffer = buffers.get(&folder_b).unwrap();
if b_buffer.len() >= batch_limit || total_bytes >= MAX_BUFFER_BYTES {
let sent = buffers.remove(&folder_b).unwrap();
let freed: usize = sent.iter().map(|s| s.len()).sum();
total_bytes = total_bytes.saturating_sub(freed);
sender.send(&folder_b, sent);
}
assert_eq!(sender.batches.len(), 1);
assert_eq!(sender.batches[0].0, "B"); // B flushed
assert!(buffers.contains_key("A")); // A still there
assert_eq!(total_bytes, 150_000_000);
}
#[test]
fn skip_oversized_email() {
assert!(100 <= MAX_EMAIL_BYTES);
// Use vec! so the 100 MB array lives on the heap, not the stack.
let huge = vec![0u8; MAX_EMAIL_BYTES + 1];
assert!(huge.len() > MAX_EMAIL_BYTES);
}
}
@@ -1,3 +1,21 @@
//
// 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 memmap2::Mmap;
use std::fs;
use std::io;
@@ -111,7 +129,7 @@ impl<'a> Iterator for MboxReader<'a> {
mod tests {
use mail_parser::MessageParser;
use crate::modules::cli::mbox::gmail::determine_folder;
use crate::mbox::gmail::determine_folder;
use super::*;
@@ -202,7 +220,6 @@ mod tests {
labels,
determine_folder(labels)
)
}
}
}
@@ -1,3 +1,22 @@
//
// 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 compressed_rtf::*;
use outlook_pst::ltp::prop_context::PropertyValue;
@@ -1,3 +1,21 @@
//
// 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 chrono::{DateTime, TimeZone, Utc};
use dialoguer::theme::ColorfulTheme;
use dialoguer::Input;
@@ -5,10 +23,10 @@ use mail_send::mail_builder::headers::text::Text;
use mail_send::mail_builder::MessageBuilder;
use outlook_pst::ltp::prop_context::PropertyValue;
use crate::base64_encode_url_safe;
use crate::modules::cli::pst::encoding::decode_subject;
use crate::modules::cli::sender::send_batch_request;
use crate::modules::cli::BichonCtlConfig;
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 outlook_pst::messaging::folder::Folder;
@@ -42,7 +60,7 @@ pub struct EmailAttachment {
pub data: Option<Vec<u8>>,
}
pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme: &ColorfulTheme) {
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")
.validate_with(|input: &String| {
@@ -97,7 +115,7 @@ pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme:
}
}
async fn parse_pst(pst_path: PathBuf, config: &BichonCtlConfig, account_id: u64) {
async fn parse_pst(pst_path: PathBuf, config: &BichonCliConfig, account_id: u64) {
let client = Client::new();
let pst_store = match outlook_pst::open_store(&pst_path) {
@@ -143,7 +161,7 @@ fn process_folder_recursively<'a>(
client: &'a Client,
folder: &'a Rc<dyn Folder>,
parent_path: &'a str,
config: &'a BichonCtlConfig,
config: &'a BichonCliConfig,
account_id: u64,
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
Box::pin(async move {
@@ -342,48 +360,54 @@ fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<Strin
let mut bcc = Vec::new();
let recipient_table = message.recipient_table();
let context = recipient_table.context();
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 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;
}
}
_ => {}
}
}
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;
}
if !email.is_empty() {
match r_type {
1 => to.push(email),
2 => cc.push(email),
3 => bcc.push(email),
_ => {}
}
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: &BichonCtlConfig,
config: &BichonCliConfig,
account_id: u64,
folder_path: &str,
emls: Vec<String>,
@@ -1,11 +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/>.
use std::{collections::HashMap, path::PathBuf};
use crate::modules::cli::{mbox::run_import, BichonCtlConfig};
use crate::{mbox::run_import, BichonCliConfig};
use console::style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
pub async fn handle_thunderbird_import(
config: &BichonCtlConfig,
config: &BichonCliConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
+74
View File
@@ -0,0 +1,74 @@
[package]
name = "bichon-core"
version.workspace = true
edition.workspace = true
[features]
default = ["web-api"]
web-api = ["dep:poem-openapi"]
[dependencies]
poem-openapi = { version = "5.1.16", features = [
"openapi-explorer",
"rapidoc",
"scalar",
"redoc",
"swagger-ui",
"email",
], optional = true }
chrono.workspace = true
clap.workspace = true
memdb.workspace = true
itertools.workspace = true
ring.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true
base64.workspace = true
snafu.workspace = true
reqwest.workspace = true
tokio-socks.workspace = true
regex.workspace = true
email_address.workspace = true
futures.workspace = true
utf7-imap.workspace = true
mail-parser.workspace = true
tokio-rustls.workspace = true
oauth2.workspace = true
sysinfo.workspace = true
num_cpus.workspace = true
rand.workspace = true
encoding_rs.workspace = true
async-imap = { git = "https://github.com/rustmailer/async-imap.git", branch = "main", default-features = false, features = [
"runtime-tokio",
"compress",
] }
tantivy = { version = "0.26.1", features = ["zstd-compression", "quickwit"] }
webpki-roots.workspace = true
rustls.workspace = true
rustls-pki-types.workspace = true
tokio-io-timeout.workspace = true
governor.workspace = true
lru.workspace = true
time.workspace = true
murmur3.workspace = true
dashmap.workspace = true
itoa.workspace = true
html2text.workspace = true
bytes.workspace = true
mail-send.workspace = true
blake3.workspace = true
uuid.workspace = true
fjall.workspace = true
tracing-log.workspace = true
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"
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,19 +16,25 @@
// 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::{encrypt, error::BichonResult};
use crate::{encrypt, modules::error::BichonResult};
use poem_openapi::{Enum, Object};
//use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Object)]
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct ImapConfig {
/// IMAP server hostname or IP address
#[oai(validator(max_length = 253, pattern = r"^[a-zA-Z0-9\-\.]+$"))]
#[cfg_attr(
feature = "web-api",
oai(validator(max_length = 253, pattern = r"^[a-zA-Z0-9\-\.]+$"))
)]
pub host: String,
/// IMAP server port number
#[oai(validator(minimum(value = "1"), maximum(value = "65535")))]
#[cfg_attr(
feature = "web-api",
oai(validator(minimum(value = "1"), maximum(value = "65535")))
)]
pub port: u16,
/// Connection encryption method
pub encryption: Encryption,
@@ -52,8 +58,8 @@ impl ImapConfig {
}
}
#[derive(Enum, Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[derive(Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum AuthType {
/// Standard password authentication (PLAIN/LOGIN)
#[default]
@@ -62,7 +68,8 @@ pub enum AuthType {
OAuth2,
}
#[derive(Object, Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[derive(Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct AuthConfig {
///Authentication method to use
pub auth_type: AuthType,
@@ -70,7 +77,7 @@ pub struct AuthConfig {
///
/// Users should provide a plaintext password (1 to 256 characters).
/// The server will encrypt the password using AES-256-GCM and securely store it.
#[oai(validator(max_length = 256, min_length = 1))]
#[cfg_attr(feature = "web-api", oai(validator(max_length = 256, min_length = 1)))]
pub password: Option<String>,
}
@@ -97,7 +104,8 @@ impl AuthConfig {
}
}
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Enum)]
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum Encryption {
/// SSL/TLS encrypted connection
#[default]
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,14 +16,15 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem_openapi::Object;
//use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{
modules::{
raise_error, utc_now,
{
account::migration::AccountModel,
common::auth::ClientContext,
database::{manager::DB_MANAGER, with_transaction},
database::{manager::DB_MANAGER, with_transaction, MemDbModel},
error::{code::ErrorCode, BichonResult},
users::{
permissions::Permission,
@@ -31,10 +32,10 @@ use crate::{
UserModel,
},
},
raise_error, utc_now,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct BatchAccountRoleRequest {
pub account_ids: Vec<u64>,
pub user_ids: Vec<u64>,
@@ -42,8 +43,8 @@ pub struct BatchAccountRoleRequest {
}
impl BatchAccountRoleRequest {
pub async fn validate_existence(&self) -> BichonResult<()> {
let role = UserRole::find(self.role_id).await?.ok_or_else(|| {
pub fn validate_existence(&self) -> BichonResult<()> {
let role = UserRole::find(self.role_id)?.ok_or_else(|| {
raise_error!(
format!("Role ID {} not found", self.role_id),
ErrorCode::ResourceNotFound
@@ -58,7 +59,7 @@ impl BatchAccountRoleRequest {
}
for id in &self.account_ids {
let exists = AccountModel::find(*id).await?; // Assuming an exists helper
let exists = AccountModel::find(*id)?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("Account ID {} not found", id),
@@ -68,7 +69,7 @@ impl BatchAccountRoleRequest {
}
for id in &self.user_ids {
let exists = UserModel::find(*id).await?; // Assuming an exists helper
let exists = UserModel::find(*id)?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("User ID {} not found", id),
@@ -80,44 +81,38 @@ impl BatchAccountRoleRequest {
Ok(())
}
async fn grant_batch_account_access(
fn grant_batch_account_access(
account_ids: Vec<u64>,
user_ids: Vec<u64>,
role_id: u64,
) -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
with_transaction(DB_MANAGER.db(), move |txn| {
let mut txn = txn;
for &uid in &user_ids {
// Fetch the current user record from the database
let user = rw
.get()
.primary::<UserModel>(uid)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", uid),
ErrorCode::ResourceNotFound
)
})?;
let db = DB_MANAGER.db();
let coll = db.collection(UserModel::collection());
let key = uid.to_string();
let user: UserModel = coll
.get_required(&key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut updated_user = user.clone();
// Apply the role to each specified account_id
for &aid in &account_ids {
updated_user.account_access_map.insert(aid, role_id);
}
updated_user.updated_at = utc_now!();
// Save the updated user back to the database within the transaction
rw.update(user, updated_user)
txn = txn
.upsert(UserModel::collection(), key, &updated_user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
Ok(txn)
})
.await
}
pub async fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
pub fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
for account_id in &self.account_ids {
// Get the user's specific access for this account
let assigned_role_id =
@@ -133,7 +128,7 @@ impl BatchAccountRoleRequest {
})?;
// Fetch the role definition from the database
let user_scoped_role = UserRole::find(*assigned_role_id).await?.ok_or_else(|| {
let user_scoped_role = UserRole::find(*assigned_role_id)?.ok_or_else(|| {
raise_error!(
"Assigned account role no longer exists".into(),
ErrorCode::InternalError
@@ -155,6 +150,6 @@ impl BatchAccountRoleRequest {
// This is where you'd compare target_role.permissions vs manager's perms
}
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id).await
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id)
}
}
+448
View File
@@ -0,0 +1,448 @@
//
// 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 serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use tracing::info;
use crate::{
account::{
entity::ImapConfig,
payload::{AccountCreateRequest, AccountUpdateRequest, MinimalAccount},
since::{DateSince, RelativeDate},
state::DownloadState,
},
cache::imap::{mailbox::MailBox, task::SYNC_TASKS},
common::paginated::DataPage,
context::controller::DOWNLOAD_CONTROLLER,
database::{
count_impl, delete_impl, find_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
paginate_impl, update_impl, MemDbModel,
},
encrypt,
error::{code::ErrorCode, BichonResult},
id,
oauth2::token::OAuth2AccessToken,
raise_error,
store::tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
users::{payload::UserUpdateRequest, role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel},
utc_now,
};
pub type AccountModel = Account;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum AccountType {
#[default]
IMAP,
NoSync,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum QuotaWindow {
Hourly,
#[default]
Daily,
Weekly,
Monthly,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct Account {
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
#[cfg_attr(
feature = "web-api",
oai(validator(custom = "crate::common::validator::EmailValidator"))
)]
pub email: String,
pub account_name: Option<String>,
pub login_name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
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,
pub created_by: u64, //user id
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>,
}
impl MemDbModel for Account {
fn collection() -> &'static str {
"accounts"
}
fn key(&self) -> String {
self.id.to_string()
}
}
impl Account {
pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult<Self> {
Ok(Self {
id: id!(64),
email: request.email,
login_name: request.login_name,
account_name: request.account_name,
imap: request.imap.map(|i| i.try_encrypt_password()).transpose()?,
enabled: request.enabled,
capabilities: None,
date_since: request.date_since,
download_folders: None,
known_folders: None,
account_type: request.account_type,
download_interval_min: request.download_interval_min,
created_at: utc_now!(),
updated_at: utc_now!(),
use_proxy: request.use_proxy,
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,
})
}
pub fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
Self::get(account_id)
}
pub fn get(account_id: u64) -> BichonResult<AccountModel> {
let result: AccountModel = Self::find(account_id)?.ok_or_else(|| {
raise_error!(
format!("Account with ID '{account_id}' not found"),
ErrorCode::ResourceNotFound
)
})?;
Ok(result)
}
pub fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
let result = find_impl::<AccountModel>(DB_MANAGER.db(), &account_id.to_string())?;
Ok(result)
}
pub async fn create_account(
user_id: u64,
request: AccountCreateRequest,
) -> BichonResult<AccountModel> {
let entity = request.create_entity(user_id)?;
let cloned = entity.clone();
// Insert account into memdb
insert_impl(DB_MANAGER.db(), entity)?;
// Update user's account_access_map
let user = UserModel::find(user_id)?.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", user_id),
ErrorCode::ResourceNotFound
)
})?;
let mut updated_map = user.account_access_map.clone();
updated_map.insert(cloned.id, DEFAULT_ACCOUNT_MANAGER_ROLE_ID);
UserModel::update(
user_id,
UserUpdateRequest {
username: None,
email: None,
password: None,
avatar_base64: None,
global_roles: None,
account_access_map: Some(updated_map),
acl: None,
description: None,
theme: None,
language: None,
},
)?;
if matches!(cloned.account_type, AccountType::IMAP) {
DOWNLOAD_CONTROLLER
.trigger_schedule(cloned.id, cloned.email.clone())
.await;
}
Ok(cloned)
}
pub fn update(
account_id: u64,
request: AccountUpdateRequest,
validate: bool,
) -> BichonResult<()> {
let account = AccountModel::get(account_id)?;
if validate {
request.validate_update_request(&account)?;
}
update_impl(
DB_MANAGER.db(),
&account_id.to_string(),
move |current: Account| Self::apply_update_fields(&current, request),
)?;
Ok(())
}
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);
}
Ok(())
}
fn delete_account(account: &AccountModel) -> BichonResult<()> {
delete_impl::<AccountModel>(DB_MANAGER.db(), &account.id.to_string())
}
async fn cleanup_account_resources_sequential(account: &AccountModel) -> BichonResult<()> {
if matches!(account.account_type, AccountType::IMAP) {
SYNC_TASKS.stop(account.id).await?;
DownloadState::delete(account.id)?;
}
OAuth2AccessToken::try_delete(account.id)?;
UserModel::cleanup_account(account.id)?;
MailBox::clean(account.id)?;
ENVELOPE_MANAGER
.delete_account_envelopes(account.id)
.await?;
ATTACHMENT_MANAGER
.delete_account_attachments(account.id)
.await?;
Self::delete_account(account)?;
info!("Sequential cleanup completed for account: {}", account.id);
Ok(())
}
pub fn update_download_folders(
account_id: u64,
download_folders: Vec<String>,
) -> BichonResult<()> {
update_impl(
DB_MANAGER.db(),
&account_id.to_string(),
move |current: Account| {
let mut updated = current.clone();
updated.download_folders = Some(download_folders);
Ok(updated)
},
)?;
Ok(())
}
pub fn update_known_folders(
account_id: u64,
known_folders: BTreeSet<String>,
) -> BichonResult<()> {
update_impl(
DB_MANAGER.db(),
&account_id.to_string(),
move |current: Account| {
let mut updated = current.clone();
updated.known_folders = Some(known_folders);
Ok(updated)
},
)?;
Ok(())
}
pub fn update_capabilities(account_id: u64, capabilities: Vec<String>) -> BichonResult<()> {
update_impl(
DB_MANAGER.db(),
&account_id.to_string(),
move |current: Account| {
let mut updated = current.clone();
updated.capabilities = Some(capabilities);
Ok(updated)
},
)?;
Ok(())
}
/// Retrieves a list of all `AccountEntity` instances.
pub fn list_all() -> BichonResult<Vec<AccountModel>> {
list_all_impl::<AccountModel>(DB_MANAGER.db())
}
pub fn find_by_email(email: &str) -> BichonResult<Option<AccountModel>> {
let all: Vec<AccountModel> = list_all_impl::<AccountModel>(DB_MANAGER.db())?;
let target_email = email.trim().to_lowercase();
let first_match = all
.into_iter()
.find(|acc| acc.email.to_lowercase() == target_email);
Ok(first_match)
}
pub fn minimal_list(only_nosync: bool) -> BichonResult<Vec<MinimalAccount>> {
let result = list_all_impl::<AccountModel>(DB_MANAGER.db())?
.into_iter()
.filter(|account: &AccountModel| {
!only_nosync || matches!(account.account_type, AccountType::NoSync)
})
.map(|account: AccountModel| MinimalAccount {
id: account.id,
email: account.email,
})
.collect::<Vec<MinimalAccount>>();
Ok(result)
}
pub fn count() -> BichonResult<usize> {
count_impl::<AccountModel>(DB_MANAGER.db())
}
pub fn paginate_list(
page: Option<u64>,
page_size: Option<u64>,
desc: Option<bool>,
) -> BichonResult<DataPage<AccountModel>> {
paginate_impl::<AccountModel>(DB_MANAGER.db(), page, page_size, desc).map(DataPage::from)
}
// This method applies the updates from the request to the old account entity
fn apply_update_fields(
old: &AccountModel,
request: AccountUpdateRequest,
) -> BichonResult<AccountModel> {
let mut new = old.clone();
if let Some(date_since) = request.date_since {
new.date_since = Some(date_since);
new.date_before = None;
}
if let Some(date_before) = request.date_before {
new.date_before = Some(date_before);
new.date_since = None;
}
if let Some(clear_date_range) = request.clear_date_range {
if clear_date_range {
new.date_since = None;
new.date_before = None;
}
}
if let Some(account_name) = request.account_name {
new.account_name = Some(account_name);
}
if matches!(old.account_type, AccountType::IMAP) {
if let Some(imap) = &request.imap {
if let Some(current_imap) = &mut new.imap {
current_imap.host = imap.host.clone();
current_imap.port = imap.port.clone();
current_imap.encryption = imap.encryption.clone();
current_imap.auth.auth_type = imap.auth.auth_type.clone();
if let Some(password) = &imap.auth.password {
let encrypted_password = encrypt!(password)?;
current_imap.auth.password = Some(encrypted_password);
}
current_imap.use_proxy = imap.use_proxy;
}
}
if let Some(folder_names) = request.sync_folders {
new.download_folders = Some(folder_names);
}
if let Some(sync_interval_min) = &request.download_interval_min {
new.download_interval_min = Some(*sync_interval_min);
}
if let Some(download_batch_size) = &request.download_batch_size {
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);
}
}
if matches!(old.account_type, AccountType::NoSync) {
if let Some(email) = &request.email {
new.email = email.clone();
}
}
if let Some(enabled) = request.enabled {
new.enabled = enabled;
}
if let Some(use_dangerous) = request.use_dangerous {
new.use_dangerous = use_dangerous;
}
if let Some(pgp_key) = request.pgp_key {
new.pgp_key = Some(pgp_key);
}
if let Some(imap_quota_bytes) = request.imap_quota_bytes {
new.imap_quota_bytes = Some(imap_quota_bytes);
}
if let Some(imap_quota_window) = request.imap_quota_window {
new.imap_quota_window = Some(imap_quota_window);
}
if let Some(auto_download_new_mailboxes) = request.auto_download_new_mailboxes {
new.auto_download_new_mailboxes = Some(auto_download_new_mailboxes);
}
if let Some(download_schedule) = request.download_schedule {
new.download_schedule = Some(download_schedule);
}
if request.clear_download_schedule == Some(true) {
new.download_schedule = None;
}
new.updated_at = utc_now!();
Ok(new)
}
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,11 +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/>.
pub mod dispatcher;
pub mod entity;
pub mod grant;
pub mod migration;
pub mod old_state;
pub mod payload;
pub mod since;
pub mod state;
pub mod stats;
pub mod view;
+245
View File
@@ -0,0 +1,245 @@
//
// 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 serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct MailboxBatchProgress {
pub total_batches: u32,
pub current_batch: u32,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct AccountRunningState {
pub account_id: u64,
pub last_incremental_sync_start: i64,
pub last_incremental_sync_end: Option<i64>,
pub errors: Vec<AccountError>,
pub is_initial_sync_completed: bool,
pub progress: Option<BTreeMap<String, MailboxBatchProgress>>,
pub initial_sync_start_time: Option<i64>,
pub initial_sync_end_time: Option<i64>,
pub initial_sync_failed_time: Option<i64>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct AccountError {
pub error: String,
pub at: i64,
}
// impl AccountRunningState {
// pub async fn add(account_id: u64) -> BichonResult<()> {
// let info = AccountRunningState {
// account_id,
// last_incremental_sync_start: 0,
// last_incremental_sync_end: None,
// errors: vec![],
// is_initial_sync_completed: false,
// progress: None,
// initial_sync_start_time: Some(utc_now!()),
// initial_sync_end_time: None,
// initial_sync_failed_time: None,
// };
// upsert_impl(DB_MANAGER.envelope_db(), info).await
// }
// pub async fn get(account_id: u64) -> BichonResult<Option<AccountRunningState>> {
// async_find_impl(DB_MANAGER.envelope_db(), account_id).await
// }
// async fn update_account_running_state(
// account_id: u64,
// updater: impl FnOnce(&AccountRunningState) -> BichonResult<AccountRunningState> + Send + 'static,
// ) -> BichonResult<()> {
// if Self::get(account_id).await?.is_some() {
// update_impl(
// DB_MANAGER.envelope_db(),
// move |rw| {
// rw.get()
// .primary::<AccountRunningState>(account_id)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// .ok_or_else(|| {
// raise_error!(
// format!("Cannot find sync info of account={}", account_id),
// ErrorCode::ResourceNotFound
// )
// })
// },
// updater,
// )
// .await?;
// }
// Ok(())
// }
// pub async fn delete(account_id: u64) -> BichonResult<()> {
// if Self::get(account_id).await?.is_none() {
// return Ok(());
// }
// delete_impl(DB_MANAGER.envelope_db(), move |rw| {
// rw.get()
// .primary::<AccountRunningState>(account_id)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// .ok_or_else(|| {
// raise_error!(
// format!(
// "AccountRunningState '{}' not found during deletion process.",
// account_id
// ),
// ErrorCode::ResourceNotFound
// )
// })
// })
// .await
// }
// // pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
// // Self::update_account_running_state(account_id, move |current| {
// // let mut updated = current.clone();
// // updated.initial_sync_start_time = Some(utc_now!());
// // Ok(updated)
// // })
// // .await
// // }
// pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> {
// Self::update_account_running_state(account_id, move |current| {
// let mut updated = current.clone();
// updated.is_initial_sync_completed = true;
// updated.initial_sync_end_time = Some(utc_now!());
// Ok(updated)
// })
// .await
// }
// pub async fn set_initial_sync_failed(account_id: u64) -> BichonResult<()> {
// Self::update_account_running_state(account_id, move |current| {
// let mut updated = current.clone();
// updated.initial_sync_failed_time = Some(utc_now!());
// Ok(updated)
// })
// .await
// }
// pub async fn set_current_sync_batch_number(
// account_id: u64,
// syncing_folder: String,
// batch_number: u32,
// ) -> BichonResult<()> {
// Self::update_account_running_state(account_id, move |current| {
// let mut updated = current.clone();
// let mut progress_map = updated.progress.clone().unwrap_or_default();
// let entry =
// progress_map
// .entry(syncing_folder.to_string())
// .or_insert(MailboxBatchProgress {
// total_batches: 0,
// current_batch: 0,
// });
// entry.current_batch = batch_number;
// updated.progress = Some(progress_map);
// Ok(updated)
// })
// .await
// }
// pub async fn set_folder_initial_sync_completed(
// account_id: u64,
// syncing_folder: String,
// ) -> BichonResult<()> {
// Self::update_account_running_state(account_id, move |current| {
// let mut updated = current.clone();
// let mut progress_map = updated.progress.clone().unwrap_or_default();
// let entry =
// progress_map
// .entry(syncing_folder.to_string())
// .or_insert(MailboxBatchProgress {
// total_batches: 0,
// current_batch: 0,
// });
// entry.current_batch = entry.total_batches;
// updated.progress = Some(progress_map);
// Ok(updated)
// })
// .await
// }
// pub async fn set_initial_current_syncing_folder(
// account_id: u64,
// current_syncing_folder: String,
// total_sync_batches: u32,
// ) -> BichonResult<()> {
// Self::update_account_running_state(account_id, move |current| {
// let mut updated = current.clone();
// let mut progress_map = updated.progress.clone().unwrap_or_default();
// progress_map.insert(
// current_syncing_folder.clone(),
// MailboxBatchProgress {
// total_batches: total_sync_batches,
// current_batch: 0,
// },
// );
// updated.progress = Some(progress_map);
// Ok(updated)
// })
// .await
// }
// pub async fn set_incremental_sync_start(account_id: u64) -> BichonResult<()> {
// Self::update_account_running_state(account_id, move |current| {
// let mut updated = current.clone();
// updated.last_incremental_sync_start = utc_now!();
// updated.last_incremental_sync_end = None;
// Ok(updated)
// })
// .await
// }
// pub async fn set_incremental_sync_end(account_id: u64) -> BichonResult<()> {
// Self::update_account_running_state(account_id, move |current| {
// let mut updated = current.clone();
// updated.last_incremental_sync_end = Some(utc_now!());
// Ok(updated)
// })
// .await
// }
// pub async fn append_error_message(account_id: u64, error: String) -> BichonResult<()> {
// Self::update_account_running_state(account_id, move |current| {
// let mut updated = current.clone();
// updated.append_error_log(error);
// Ok(updated)
// })
// .await
// }
// pub fn append_error_log(&mut self, error: String) {
// let new_error = AccountError {
// error,
// at: utc_now!(),
// };
// self.errors.push(new_error);
// if self.errors.len() > ERROR_COUNT_PER_ACCOUNT {
// self.errors.remove(0);
// }
// }
// }
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,34 +16,46 @@
// 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::modules::account::entity::ImapConfig;
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::account::since::{DateSince, RelativeDate};
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use std::str::FromStr;
use crate::account::entity::ImapConfig;
use crate::account::migration::{AccountModel, AccountType, QuotaWindow};
use crate::account::since::{DateSince, RelativeDate};
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::{raise_error, validate_email};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct AccountCreateRequest {
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
#[cfg_attr(
feature = "web-api",
oai(validator(custom = "crate::common::validator::EmailValidator"))
)]
pub email: String,
pub name: Option<String>,
pub login_name: Option<String>,
pub account_name: Option<String>,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub account_type: AccountType,
#[oai(validator(minimum(value = "100")))]
pub folder_limit: Option<u32>,
#[oai(validator(minimum(value = "10")))]
pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>,
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "10"))))]
pub download_interval_min: Option<i64>,
#[cfg_attr(
feature = "web-api",
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>,
}
impl AccountCreateRequest {
@@ -56,6 +68,13 @@ impl AccountCreateRequest {
));
}
if self.imap_quota_bytes.is_some() ^ self.imap_quota_window.is_some() {
return Err(raise_error!(
"Quota bytes and quota window must be provided together or omitted together".into(),
ErrorCode::InvalidParameter
));
}
if let Some(date_since) = self.date_since.as_ref() {
date_since.validate()?;
}
@@ -75,12 +94,15 @@ impl AccountCreateRequest {
))
}
}
if self.sync_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 => {}
}
@@ -96,7 +118,8 @@ impl AccountCreateRequest {
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct AccountUpdateRequest {
pub email: Option<String>,
/// Represents the account activation status.
@@ -105,8 +128,7 @@ pub struct AccountUpdateRequest {
/// and any attempts to access them should return an error indicating the account
/// is inactive.
pub enabled: Option<bool>,
/// Display name for the account (optional)
pub name: Option<String>,
pub account_name: Option<String>,
/// IMAP server configuration
pub imap: Option<ImapConfig>,
/// Controls initial synchronization time range
@@ -122,12 +144,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).
#[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:
@@ -142,11 +158,15 @@ pub struct AccountUpdateRequest {
/// Defaults to standard folders (`INBOX`, `Sent`) if empty.
/// Modified folders will be automatically synced on the next update.
pub sync_folders: Option<Vec<String>>,
/// Incremental sync interval (seconds)
#[oai(validator(minimum(value = "10")))]
pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>,
/// Incremental download interval (seconds)
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "10"))))]
pub download_interval_min: Option<i64>,
#[cfg_attr(
feature = "web-api",
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.
@@ -155,6 +175,11 @@ pub struct AccountUpdateRequest {
pub use_dangerous: Option<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>,
pub clear_download_schedule: Option<bool>,
}
impl AccountUpdateRequest {
@@ -167,9 +192,9 @@ impl AccountUpdateRequest {
));
}
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
if self.imap_quota_bytes.is_some() ^ self.imap_quota_window.is_some() {
return Err(raise_error!(
"clear_folder_limit cannot be combined with folder_limit".into(),
"Quota bytes and quota window must be provided together or omitted together".into(),
ErrorCode::InvalidParameter
));
}
@@ -200,12 +225,38 @@ impl AccountUpdateRequest {
));
}
}
if self.clear_download_schedule == Some(true) && self.download_schedule.is_some() {
return Err(raise_error!(
"clear_download_schedule cannot be combined with download_schedule".into(),
ErrorCode::InvalidParameter
));
}
if let Some(ref schedule) = self.download_schedule {
validate_cron_expression(schedule)?;
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
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,
@@ -222,3 +273,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());
}
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -17,14 +17,14 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::error::{code::ErrorCode, BichonResult},
error::{code::ErrorCode, BichonResult},
raise_error,
};
use chrono::{Datelike, Days, Local, Months, NaiveDate, Utc};
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct DateSince {
/// Absolute date boundary in ISO 8601 format (YYYY-MM-DD)
///
@@ -38,7 +38,7 @@ pub struct DateSince {
/// "fixed": "2025-05-01"
/// }
/// ```
#[oai(validator(pattern = r"^\d{4}-\d{2}-\d{2}$"))]
#[cfg_attr(feature = "web-api", oai(validator(pattern = r"^\d{4}-\d{2}-\d{2}$")))]
pub fixed: Option<String>,
/// Relative time period from current date
///
@@ -58,7 +58,8 @@ pub struct DateSince {
pub relative: Option<RelativeDate>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum Unit {
#[default]
Days,
@@ -66,12 +67,13 @@ pub enum Unit {
Years,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct RelativeDate {
/// The time unit to use for the offset (days, months, or years)
pub unit: Unit,
/// The quantity of time units to offset (must be a positive integer)
#[oai(validator(minimum(value = "1")))]
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "1"))))]
pub value: u32,
}
@@ -255,19 +257,47 @@ impl DateSince {
#[cfg(test)]
mod test {
use crate::modules::account::since::{DateSince, RelativeDate, Unit};
use crate::account::since::{DateSince, RelativeDate, Unit};
#[test]
fn test1() {
fn fixed_date_valid() {
let e = DateSince {
fixed: Some("2014-09-12".to_string()),
relative: None,
};
assert!(e.validate().is_ok());
assert!(!e.since_date().unwrap().is_empty());
}
e.validate().unwrap();
#[test]
fn fixed_date_in_future_fails() {
let e = DateSince {
fixed: Some("2099-01-01".to_string()),
relative: None,
};
assert!(e.validate().is_err());
}
println!("{}", e.since_date().unwrap());
#[test]
fn fixed_date_before_1970_fails() {
let e = DateSince {
fixed: Some("1960-01-01".to_string()),
relative: None,
};
assert!(e.validate().is_err());
}
#[test]
fn fixed_date_bad_format_fails() {
let e = DateSince {
fixed: Some("01-01-2020".to_string()),
relative: None,
};
assert!(e.validate().is_err());
}
#[test]
fn relative_date_days_valid() {
let e = DateSince {
fixed: None,
relative: Some(RelativeDate {
@@ -275,9 +305,102 @@ mod test {
value: 1,
}),
};
assert!(e.validate().is_ok());
}
e.validate().unwrap();
#[test]
fn relative_date_months_valid() {
let e = DateSince {
fixed: None,
relative: Some(RelativeDate {
unit: Unit::Months,
value: 3,
}),
};
assert!(e.validate().is_ok());
}
println!("{}", e.since_date().unwrap());
#[test]
fn relative_date_years_valid() {
let e = DateSince {
fixed: None,
relative: Some(RelativeDate {
unit: Unit::Years,
value: 1,
}),
};
assert!(e.validate().is_ok());
}
#[test]
fn relative_date_zero_value_fails() {
let e = DateSince {
fixed: None,
relative: Some(RelativeDate {
unit: Unit::Days,
value: 0,
}),
};
assert!(e.validate().is_err());
}
#[test]
fn both_fixed_and_relative_fails() {
let e = DateSince {
fixed: Some("2014-09-12".to_string()),
relative: Some(RelativeDate {
unit: Unit::Days,
value: 1,
}),
};
assert!(e.validate().is_err());
}
#[test]
fn neither_fixed_nor_relative_fails() {
let e = DateSince {
fixed: None,
relative: None,
};
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");
}
}
+283
View File
@@ -0,0 +1,283 @@
//
// 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::{
database::{delete_impl, find_impl, manager::DB_MANAGER, update_impl, upsert_impl, MemDbModel},
error::BichonResult,
utc_now,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum DownloadStatus {
Running,
Success,
Failed,
#[default]
Cancelled,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum TriggerType {
Manual,
#[default]
Scheduled,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum FolderStatus {
#[default]
Pending,
Downloading,
Success,
Failed,
Cancelled,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct FolderProgress {
pub folder_name: String,
pub planned: u64,
pub current: u64,
pub status: FolderStatus,
pub message: Option<String>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct DownloadSession {
pub start_time: i64,
pub end_time: Option<i64>,
pub status: DownloadStatus,
pub message: Option<String>,
pub trigger: TriggerType,
pub folder_details: BTreeMap<String, FolderProgress>,
pub current_folder: Option<String>,
pub errors: Vec<AccountError>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct DownloadState {
pub account_id: u64,
pub active_session: Option<DownloadSession>,
pub history: Vec<DownloadSession>,
pub last_trigger_at: i64,
pub last_finished_at: Option<i64>,
}
impl MemDbModel for DownloadState {
fn collection() -> &'static str {
"download_states"
}
fn key(&self) -> String {
self.account_id.to_string()
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct AccountError {
pub error: String,
pub at: i64,
}
impl DownloadState {
pub fn empty(account_id: u64) -> Self {
DownloadState {
account_id,
..Default::default()
}
}
pub async fn init(account_id: u64) -> BichonResult<()> {
let now = utc_now!();
let state = DownloadState {
account_id,
last_trigger_at: now,
active_session: Some(DownloadSession {
start_time: now,
status: DownloadStatus::Running,
trigger: TriggerType::Scheduled,
..Default::default()
}),
history: Default::default(),
last_finished_at: Default::default(),
};
upsert_impl(DB_MANAGER.db(), state)
}
pub fn get(account_id: u64) -> BichonResult<Option<DownloadState>> {
find_impl::<DownloadState>(DB_MANAGER.db(), &account_id.to_string())
}
pub fn start_new_session(account_id: u64, trigger: TriggerType) -> BichonResult<()> {
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
updated.last_trigger_at = utc_now!();
if let Some(mut old_session) = updated.active_session.take() {
if old_session.status == DownloadStatus::Running {
old_session.status = DownloadStatus::Cancelled;
old_session.end_time = Some(utc_now!());
old_session.message = Some("Interrupted by a new download session.".into());
}
updated.history.push(old_session);
if updated.history.len() > 30 {
updated.history.remove(0);
}
}
let new_session = DownloadSession {
start_time: utc_now!(),
status: DownloadStatus::Running,
trigger,
..Default::default()
};
updated.active_session = Some(new_session);
Ok(updated)
})
}
pub fn update_session_status(
account_id: u64,
status: DownloadStatus,
message: Option<String>,
) -> BichonResult<()> {
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
if let Some(mut session) = updated.active_session.take() {
session.status = status.clone();
if message.is_some() {
session.message = message;
}
if status == DownloadStatus::Running {
updated.active_session = Some(session);
} else {
let now = utc_now!();
session.end_time = Some(now);
updated.last_finished_at = Some(now);
updated.history.push(session);
let to_remove = updated.history.len().saturating_sub(10);
if to_remove > 0 {
updated.history.drain(0..to_remove);
}
}
}
Ok(updated)
})
}
pub fn update_folder_progress(
account_id: u64,
folder_name: String,
planned: u64,
current: u64,
status: FolderStatus,
message: Option<String>,
) -> BichonResult<()> {
Self::update_state(account_id, move |state| {
let mut updated = state.clone();
if let Some(ref mut session) = updated.active_session {
session.current_folder = Some(folder_name.clone());
let progress =
session
.folder_details
.entry(folder_name.clone())
.or_insert(FolderProgress {
folder_name,
..Default::default()
});
progress.planned = planned;
progress.current = current;
progress.status = status;
progress.message = message;
}
Ok(updated)
})
}
pub fn init_folder_details(account_id: u64, folders: Vec<String>) -> BichonResult<()> {
Self::update_state(account_id, move |state| {
let mut updated = state.clone();
if let Some(ref mut session) = updated.active_session {
for name in folders {
session.folder_details.insert(
name.clone(),
FolderProgress {
folder_name: name,
planned: 0,
current: 0,
status: FolderStatus::Pending,
message: None,
},
);
}
}
Ok(updated)
})
}
pub fn append_session_error(account_id: u64, error: String) -> BichonResult<()> {
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
let new_error = AccountError {
error,
at: utc_now!(),
};
let target = updated
.active_session
.as_mut()
.or_else(|| updated.history.last_mut());
if let Some(session) = target {
session.errors.push(new_error);
let to_remove = session.errors.len().saturating_sub(30);
if to_remove > 0 {
session.errors.drain(0..to_remove);
}
}
Ok(updated)
})
}
fn update_state(
account_id: u64,
updater: impl FnOnce(DownloadState) -> BichonResult<DownloadState> + Send + 'static,
) -> BichonResult<()> {
if Self::get(account_id)?.is_some() {
update_impl(DB_MANAGER.db(), &account_id.to_string(), updater)?;
}
Ok(())
}
pub fn delete(account_id: u64) -> BichonResult<()> {
if Self::get(account_id)?.is_none() {
return Ok(());
}
delete_impl::<DownloadState>(DB_MANAGER.db(), &account_id.to_string())
}
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,9 +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 serde::{Deserialize, Serialize};
#[tokio::test]
async fn test() {
let config = autoconfig::from_addr("test@gmail.com").await.unwrap();
println!("{:#?}", config);
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct AccountStats {
pub total_size: u64,
pub total_count: u64,
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -17,34 +17,34 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::{BTreeSet, HashMap};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::{
use crate::{
account::{
entity::ImapConfig,
migration::{AccountModel, AccountType},
migration::{AccountModel, AccountType, QuotaWindow},
since::{DateSince, RelativeDate},
},
users::UserModel,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct AccountResp {
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub account_name: Option<String>,
pub login_name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub download_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub sync_batch_size: Option<u32>,
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,
@@ -54,6 +54,10 @@ pub struct AccountResp {
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>,
}
impl AccountResp {
@@ -64,15 +68,16 @@ impl AccountResp {
imap: account.imap,
enabled: account.enabled,
email: account.email,
name: account.name,
account_name: account.account_name,
login_name: account.login_name,
capabilities: account.capabilities,
date_since: account.date_since,
date_before: account.date_before,
folder_limit: account.folder_limit,
sync_folders: account.sync_folders,
download_folders: account.download_folders,
account_type: account.account_type,
sync_interval_min: account.sync_interval_min,
sync_batch_size: account.sync_batch_size,
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,
@@ -86,6 +91,10 @@ impl AccountResp {
use_proxy: account.use_proxy,
use_dangerous: account.use_dangerous,
pgp_key: account.pgp_key,
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,
}
}
}
+68
View File
@@ -0,0 +1,68 @@
//
// 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 std::path::Path;
use memdb::{Durability, MemDb};
use crate::{
database::MemDbModel,
error::{code::ErrorCode, BichonResult},
raise_error,
users::{UserModel, DEFAULT_ADMIN_USER_ID},
utils::encrypt::internal_encrypt_string,
};
pub fn open_database(path: impl AsRef<Path>) -> BichonResult<MemDb> {
MemDb::open_with(path, Durability::Full).map_err(|e| {
raise_error!(
format!("Failed to open database: {:?}", e),
ErrorCode::InternalError
)
})
}
pub fn find_admin(db: &MemDb) -> BichonResult<Option<UserModel>> {
let key = DEFAULT_ADMIN_USER_ID.to_string();
let coll = db.collection(UserModel::collection());
coll.get(&key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub fn update_admin_password(
db: &MemDb,
password: String,
encrypt_key: &str,
) -> BichonResult<()> {
let key = DEFAULT_ADMIN_USER_ID.to_string();
let coll = db.collection(UserModel::collection());
let entity: UserModel = coll
.get_required(&key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut updated = entity.clone();
updated.password = Some(
internal_encrypt_string(encrypt_key, &password)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?,
);
coll.upsert(&key, &updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}
+20
View File
@@ -0,0 +1,20 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod meta;
+339
View File
@@ -0,0 +1,339 @@
//
// 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 hickory_resolver::name_server::TokioConnectionProvider;
use hickory_resolver::proto::rr::RData;
use hickory_resolver::proto::rr::RecordType;
use hickory_resolver::TokioResolver;
use quick_xml::de::from_str;
use reqwest::Client;
use serde::Deserialize;
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::raise_error;
/// Parsed result from Thunderbird-style autoconfig XML or DNS SRV fallback.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MailConfig {
pub incoming: Vec<IncomingServer>,
pub outgoing: Vec<OutgoingServer>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct IncomingServer {
#[serde(rename = "@type")]
pub protocol: String,
pub hostname: String,
#[serde(default)]
pub port: u16,
#[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)]
pub struct OutgoingServer {
#[serde(rename = "@type")]
pub protocol: String,
pub hostname: String,
#[serde(default)]
pub port: u16,
#[serde(rename = "socketType")]
pub socket_type: String,
pub username: String,
}
// ---------------------------------------------------------------------------
// Internal XML wrapper structs matching the Thunderbird config-v1.1 schema:
// <clientConfig> → <emailProvider> → <incomingServer> / <outgoingServer>
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
#[serde(rename = "clientConfig")]
struct ClientConfig {
#[serde(rename = "emailProvider", default)]
email_providers: Vec<EmailProvider>,
}
#[derive(Debug, Deserialize)]
struct EmailProvider {
#[serde(rename = "incomingServer", default)]
incoming_servers: Vec<IncomingServer>,
#[serde(rename = "outgoingServer", default)]
outgoing_servers: Vec<OutgoingServer>,
}
/// Parse Thunderbird autoconfig XML into a `MailConfig`.
/// Exposed for unit testing.
pub(crate) fn parse_autoconfig_xml(xml: &str) -> Option<MailConfig> {
let client_config: ClientConfig = from_str(xml).ok()?;
let provider = client_config.email_providers.into_iter().next()?;
Some(MailConfig {
incoming: provider.incoming_servers,
outgoing: provider.outgoing_servers,
})
}
// ---------------------------------------------------------------------------
// Network helpers
// ---------------------------------------------------------------------------
async fn fetch_xml(client: &Client, url: &str) -> Option<MailConfig> {
let resp = client.get(url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let text = resp.text().await.ok()?;
parse_autoconfig_xml(&text)
}
async fn lookup_srv(domain: &str) -> Option<MailConfig> {
let resolver = TokioResolver::builder(TokioConnectionProvider::default())
.ok()?
.build();
let imap_srv = format!("_imaps._tcp.{}.", domain);
let imap_lookup = resolver.lookup(imap_srv, RecordType::SRV).await.ok()?;
let imap_record = imap_lookup.iter().next()?;
let (imap_host, imap_port) = match imap_record {
RData::SRV(srv) => {
let host = srv.target().to_string().trim_end_matches('.').to_string();
(host, srv.port())
}
_ => return None,
};
let smtp_srv = format!("_submission._tcp.{}.", domain);
let smtp_lookup = resolver.lookup(smtp_srv, RecordType::SRV).await.ok()?;
let smtp_record = smtp_lookup.iter().next()?;
let (smtp_host, smtp_port) = match smtp_record {
RData::SRV(srv) => {
let host = srv.target().to_string().trim_end_matches('.').to_string();
(host, srv.port())
}
_ => return None,
};
Some(MailConfig {
incoming: vec![IncomingServer {
protocol: "imap".to_string(),
hostname: imap_host,
port: imap_port,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![OutgoingServer {
protocol: "smtp".to_string(),
hostname: smtp_host,
port: smtp_port,
socket_type: "STARTTLS".to_string(),
username: "%EMAILADDRESS%".to_string(),
}],
})
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Discover mail server configuration for a domain using the Thunderbird
/// autoconfig protocol (ISPDB), DNS SRV, MX fallback, and finally guessing.
///
/// Probe order:
/// 1. `https://autoconfig.{domain}/mail/config-v1.1.xml`
/// 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))?;
// ── 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);
}
// ── Well-known path (HTTPS, then HTTP) ─────────────────────────
if let Some(config) = fetch_xml(
&client,
&format!("https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
)
.await
{
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);
}
// ── DNS SRV records ────────────────────────────────────────────
if let Some(config) = lookup_srv(domain).await {
return Ok(config);
}
// ── 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::*;
#[tokio::test]
async fn test_fetch_valid_domain() {
let domains = vec![
// North America
("gmail.com", "Google Gmail"),
("outlook.com", "Microsoft Outlook"),
("hotmail.com", "Microsoft Hotmail"),
("yahoo.com", "Yahoo Mail"),
("icloud.com", "Apple iCloud"),
("aol.com", "AOL Mail"),
("protonmail.com", "ProtonMail"),
("zoho.com", "Zoho Mail"),
("fastmail.com", "FastMail"),
// Europe
("gmx.de", "GMX Germany"),
("gmx.net", "GMX International"),
("web.de", "Web.de Germany"),
("freenet.de", "Freenet Germany"),
("mail.ru", "Mail.ru Russia"),
("yandex.ru", "Yandex Russia"),
("orange.fr", "Orange France"),
("laposte.net", "La Poste France"),
("libero.it", "Libero Italy"),
("tiscali.it", "Tiscali Italy"),
("telenet.be", "Telenet Belgium"),
// Asia Pacific
("qq.com", "Tencent QQ"),
("163.com", "NetEase 163"),
("126.com", "NetEase 126"),
("sina.com", "Sina Mail"),
("naver.com", "Naver Korea"),
];
for (domain, label) in &domains {
let result = fetch(domain).await;
match result {
Ok(config) => println!("✅ [{label}] {domain}: {config:#?}"),
Err(e) => println!("⚠️ [{label}] {domain}: {e:?}"),
}
}
}
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,14 +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 autoconfig::config::OAuth2Config as XOAuth2Config;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::account::entity::Encryption;
use crate::account::entity::Encryption;
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct ServerConfig {
/// server hostname or IP address
pub host: String,
@@ -43,7 +41,8 @@ impl ServerConfig {
}
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct OAuth2Config {
/// The authorization server's issuer identifier URL
pub issuer: String,
@@ -54,19 +53,8 @@ pub struct OAuth2Config {
/// URL of the authorization server's token endpoint
pub token_url: String,
}
impl From<&XOAuth2Config> for OAuth2Config {
fn from(value: &XOAuth2Config) -> Self {
Self {
issuer: value.issuer().into(),
scope: value.scope().into_iter().map(Into::into).collect(),
auth_url: value.auth_url().into(),
token_url: value.token_url().into(),
}
}
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct MailServerConfig {
/// IMAP server configuration
pub imap: ServerConfig,
+105
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
}
+116
View File
@@ -0,0 +1,116 @@
//
// 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::{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;
use crate::raise_error;
use email_address::EmailAddress;
use std::str::FromStr;
use tracing::error;
/// Map an autoconfig XML `socketType` value to our `Encryption` enum.
pub(crate) fn socket_type_to_encryption(raw: &str) -> Encryption {
match raw.to_ascii_uppercase().as_str() {
"SSL" | "TLS" => Encryption::Ssl,
"STARTTLS" => Encryption::StartTls,
_ => Encryption::None,
}
}
/// Convert the raw `MailConfig` discovered by `client::fetch` into a
/// `MailServerConfig` suitable for account provisioning.
pub(crate) fn mail_config_to_server_config(config: &MailConfig) -> Option<MailServerConfig> {
let imap = config.incoming.iter().find(|s| {
let p = s.protocol.to_ascii_lowercase();
p == "imap" || p == "imaps"
})?;
let encryption = socket_type_to_encryption(&imap.socket_type);
let port = if imap.port != 0 {
imap.port
} else {
match encryption {
Encryption::Ssl => 993,
_ => 143,
}
};
// 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,
})
}
pub async fn resolve_autoconfig(email: impl AsRef<str>) -> BichonResult<Option<MailServerConfig>> {
let email = email.as_ref();
let email_address = EmailAddress::from_str(email).map_err(|error| {
raise_error!(
format!("Invalid email address: {email:#?}. {error:#?}"),
ErrorCode::InvalidParameter
)
})?;
let domain = email_address.domain();
// Try local cache first
if let Some(cached_entity) = CachedMailSettings::get(domain)? {
return Ok(Some(cached_entity.config));
}
let config = client::fetch(domain).await.map_err(|e| {
error!(
email = %email,
domain = %domain,
error = ?e,
"Autoconfig fetch failed"
);
raise_error!(
format!(
"Failed to fetch autoconfig for email '{}': {:#?}",
email_address.email(),
e
),
ErrorCode::AutoconfigFetchFailed
)
})?;
let result = mail_config_to_server_config(&config).ok_or_else(|| {
raise_error!(
format!(
"No IMAP server found in autoconfig for email: {}",
email_address.email()
),
ErrorCode::ResourceNotFound
)
})?;
CachedMailSettings::add(domain.into(), result.clone())?;
Ok(Some(result))
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,65 +16,56 @@
// 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::modules::database::manager::DB_MANAGER;
use crate::modules::database::{delete_impl, async_find_impl, upsert_impl};
use crate::modules::error::code::ErrorCode;
use crate::raise_error;
use crate::{
modules::autoconfig::entity::MailServerConfig, modules::error::BichonResult, utc_now,
};
use native_db::*;
use native_model::{native_model, Model};
use crate::database::manager::DB_MANAGER;
use crate::database::{delete_impl, upsert_impl};
use crate::database::{find_impl, MemDbModel};
use crate::{autoconfig::entity::MailServerConfig, error::BichonResult, utc_now};
use serde::{Deserialize, Serialize};
pub mod client;
pub mod entity;
pub mod guess;
pub mod load;
mod oauth2_providers;
#[cfg(test)]
mod tests;
const EXPIRE_TIME_MS: i64 = 30 * 24 * 60 * 60 * 1000;
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[native_model(id = 3, version = 1)]
#[native_db]
pub struct CachedMailSettings {
#[primary_key]
pub domain: String,
pub config: MailServerConfig,
pub created_at: i64,
}
impl MemDbModel for CachedMailSettings {
fn collection() -> &'static str {
"autoconfig"
}
fn key(&self) -> String {
self.domain.clone()
}
}
impl CachedMailSettings {
pub async fn add(domain: String, config: MailServerConfig) -> BichonResult<()> {
pub fn add(domain: String, config: MailServerConfig) -> BichonResult<()> {
Self {
domain,
config,
created_at: utc_now!(),
}
.save()
.await
}
async fn save(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
fn save(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.db(), self.to_owned())
}
pub async fn get(domain: &str) -> BichonResult<Option<CachedMailSettings>> {
if let Some(found) =
async_find_impl::<CachedMailSettings>(DB_MANAGER.meta_db(), domain.to_string()).await?
{
pub fn get(domain: &str) -> BichonResult<Option<CachedMailSettings>> {
if let Some(found) = find_impl::<CachedMailSettings>(DB_MANAGER.db(), domain)? {
if (utc_now!() - found.created_at) > EXPIRE_TIME_MS {
let domain = domain.to_string();
delete_impl(DB_MANAGER.meta_db(), |rw| {
rw.get()
.primary::<CachedMailSettings>(domain)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!("auto config cache miss".into(), ErrorCode::InternalError)
})
})
.await?;
delete_impl::<CachedMailSettings>(DB_MANAGER.db(), domain)?;
Ok(None)
} else {
Ok(Some(found))
@@ -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());
}
}
+368
View File
@@ -0,0 +1,368 @@
//
// 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::{self, IncomingServer, MailConfig};
use crate::autoconfig::load::{mail_config_to_server_config, socket_type_to_encryption};
// ---------------------------------------------------------------------------
// XML parsing tests
// ---------------------------------------------------------------------------
fn make_valid_xml() -> String {
r#"<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="example.com">
<domain>example.com</domain>
<displayName>Example Mail</displayName>
<incomingServer type="imap">
<hostname>imap.example.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.example.com</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<username>%EMAILADDRESS%</username>
</outgoingServer>
</emailProvider>
</clientConfig>"#
.to_string()
}
#[test]
fn parse_valid_xml() {
let xml = make_valid_xml();
let config = client::parse_autoconfig_xml(&xml).expect("should parse valid XML");
assert_eq!(config.incoming.len(), 1);
let imap = &config.incoming[0];
assert_eq!(imap.protocol, "imap");
assert_eq!(imap.hostname, "imap.example.com");
assert_eq!(imap.port, 993);
assert_eq!(imap.socket_type, "SSL");
assert_eq!(imap.username, "%EMAILADDRESS%");
assert_eq!(config.outgoing.len(), 1);
let smtp = &config.outgoing[0];
assert_eq!(smtp.protocol, "smtp");
assert_eq!(smtp.hostname, "smtp.example.com");
assert_eq!(smtp.port, 587);
assert_eq!(smtp.socket_type, "STARTTLS");
}
#[test]
fn parse_xml_empty_body() {
let xml = r#"<?xml version="1.0"?><clientConfig></clientConfig>"#;
let config = client::parse_autoconfig_xml(xml);
assert!(config.is_none(), "no emailProvider → None");
}
#[test]
fn parse_xml_no_incoming_servers() {
let xml = r#"<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="example.com">
<domain>example.com</domain>
</emailProvider>
</clientConfig>"#;
let config = client::parse_autoconfig_xml(xml).expect("should parse");
assert!(config.incoming.is_empty());
assert!(config.outgoing.is_empty());
}
#[test]
fn parse_xml_garbage() {
let config = client::parse_autoconfig_xml("not xml at all");
assert!(config.is_none());
}
#[test]
fn parse_xml_missing_port_defaults_to_zero() {
let xml = r#"<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="example.com">
<incomingServer type="imap">
<hostname>imap.example.com</hostname>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>"#;
let config = client::parse_autoconfig_xml(xml).expect("should parse");
assert_eq!(config.incoming[0].port, 0);
}
#[test]
fn parse_xml_multiple_providers_picks_first() {
let xml = r#"<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="first.example.com">
<incomingServer type="imap">
<hostname>imap.first.example.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
<emailProvider id="second.example.com">
<incomingServer type="imap">
<hostname>imap.second.example.com</hostname>
<port>143</port>
<socketType>STARTTLS</socketType>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>"#;
let config = client::parse_autoconfig_xml(xml).expect("should parse");
assert_eq!(config.incoming[0].hostname, "imap.first.example.com");
}
// ---------------------------------------------------------------------------
// socket_type → Encryption mapping tests
// ---------------------------------------------------------------------------
#[test]
fn encryption_ssl_uppercase() {
assert_eq!(socket_type_to_encryption("SSL"), Encryption::Ssl);
}
#[test]
fn encryption_ssl_lowercase() {
assert_eq!(socket_type_to_encryption("ssl"), Encryption::Ssl);
}
#[test]
fn encryption_tls() {
assert_eq!(socket_type_to_encryption("TLS"), Encryption::Ssl);
}
#[test]
fn encryption_starttls() {
assert_eq!(socket_type_to_encryption("STARTTLS"), Encryption::StartTls);
}
#[test]
fn encryption_starttls_lowercase() {
assert_eq!(socket_type_to_encryption("starttls"), Encryption::StartTls);
}
#[test]
fn encryption_starttls_mixed_case() {
assert_eq!(socket_type_to_encryption("StartTls"), Encryption::StartTls);
}
#[test]
fn encryption_plain() {
assert_eq!(socket_type_to_encryption("plain"), Encryption::None);
}
#[test]
fn encryption_empty_string() {
assert_eq!(socket_type_to_encryption(""), Encryption::None);
}
#[test]
fn encryption_unknown_value() {
assert_eq!(socket_type_to_encryption("WPA2-ENTERPRISE"), Encryption::None);
}
// ---------------------------------------------------------------------------
// MailConfig → MailServerConfig conversion tests
// ---------------------------------------------------------------------------
fn make_imap_server(host: &str, port: u16, socket_type: &str) -> IncomingServer {
IncomingServer {
protocol: "imap".to_string(),
hostname: host.to_string(),
port,
socket_type: socket_type.to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}
}
#[test]
fn convert_basic_imap_ssl() {
let config = MailConfig {
incoming: vec![make_imap_server("imap.example.com", 993, "SSL")],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should convert");
assert_eq!(result.imap.host, "imap.example.com");
assert_eq!(result.imap.port, 993);
assert_eq!(result.imap.encryption, Encryption::Ssl);
assert!(result.oauth2.is_none());
}
#[test]
fn convert_imap_starttls_with_default_port() {
let config = MailConfig {
incoming: vec![make_imap_server("imap.example.com", 0, "STARTTLS")],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should convert");
assert_eq!(result.imap.port, 143, "default port for STARTTLS → 143");
assert_eq!(result.imap.encryption, Encryption::StartTls);
}
#[test]
fn convert_imap_ssl_with_default_port() {
let config = MailConfig {
incoming: vec![make_imap_server("imap.example.com", 0, "SSL")],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should convert");
assert_eq!(result.imap.port, 993, "default port for SSL → 993");
}
#[test]
fn convert_no_imap_only_pop3() {
let config = MailConfig {
incoming: vec![IncomingServer {
protocol: "pop3".to_string(),
hostname: "pop.example.com".to_string(),
port: 995,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![],
};
assert!(mail_config_to_server_config(&config).is_none());
}
#[test]
fn convert_empty_incoming() {
let config = MailConfig {
incoming: vec![],
outgoing: vec![],
};
assert!(mail_config_to_server_config(&config).is_none());
}
#[test]
fn convert_picks_imap_over_pop3() {
let config = MailConfig {
incoming: vec![
IncomingServer {
protocol: "pop3".to_string(),
hostname: "pop.example.com".to_string(),
port: 995,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
},
make_imap_server("imap.example.com", 993, "SSL"),
],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should find IMAP");
assert_eq!(result.imap.host, "imap.example.com");
}
#[test]
fn convert_imaps_protocol_variant() {
let config = MailConfig {
incoming: vec![IncomingServer {
protocol: "imaps".to_string(),
hostname: "imap.example.com".to_string(),
port: 993,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should recognize 'imaps'");
assert_eq!(result.imap.host, "imap.example.com");
}
#[test]
fn convert_case_insensitive_protocol() {
let config = MailConfig {
incoming: vec![IncomingServer {
protocol: "IMAP".to_string(),
hostname: "imap.example.com".to_string(),
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");
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,27 +16,28 @@
// 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::collections::BTreeSet;
use crate::{
decode_mailbox_name,
modules::{
decode_mailbox_name, raise_error,
{
account::migration::{AccountModel, AccountType},
cache::imap::mailbox::{AttributeEnum, MailBox},
context::executors::MAIL_CONTEXT,
cache::imap::mailbox_cache,
error::{code::ErrorCode, BichonResult},
imap::{executor::ImapExecutor, session::SessionStream},
mailbox::list::convert_names_to_mailboxes,
},
raise_error,
};
use async_imap::types::Name;
use async_imap::{types::Name, Session};
use tracing::{debug, info, warn};
pub async fn get_sync_folders(account: &AccountModel) -> BichonResult<Vec<MailBox>> {
pub async fn get_download_folders(
account: &AccountModel,
session: &mut Session<Box<dyn SessionStream>>,
) -> BichonResult<Vec<MailBox>> {
assert_eq!(account.account_type, AccountType::IMAP);
let executor = MAIL_CONTEXT.imap(account.id).await?;
let names = executor.list_all_mailboxes().await?;
let names = ImapExecutor::list_all_mailboxes(session).await?;
if names.is_empty() {
warn!(
"Account {}: No mailboxes returned from IMAP server.",
@@ -61,8 +62,8 @@ pub async fn get_sync_folders(account: &AccountModel) -> BichonResult<Vec<MailBo
mailboxes.iter().map(|(m, _)| m.name.clone()).collect(),
)
.await?;
let account = AccountModel::get(account.id).await?;
let subscribed = &account.sync_folders.unwrap_or_default();
let account = AccountModel::get(account.id)?;
let subscribed = &account.download_folders.unwrap_or_default();
let is_noselect = |mailbox: &MailBox| {
mailbox
.attributes
@@ -109,7 +110,7 @@ pub async fn get_sync_folders(account: &AccountModel) -> BichonResult<Vec<MailBo
.iter()
.map(|n| decode_mailbox_name!(n.name().to_string()))
.collect();
AccountModel::update_sync_folders(account.id, sync_folders).await?;
AccountModel::update_download_folders(account.id, sync_folders)?;
} else {
warn!(
"Account {}: No subscribed mailboxes found. This is unexpected — IMAP server should at least provide INBOX.",
@@ -121,7 +122,7 @@ pub async fn get_sync_folders(account: &AccountModel) -> BichonResult<Vec<MailBo
), ErrorCode::ImapUnexpectedResult));
}
}
convert_names_to_mailboxes(account.id, matched_mailboxes).await
convert_names_to_mailboxes(account.id, session, matched_mailboxes).await
}
pub async fn detect_mailbox_changes(
@@ -130,7 +131,7 @@ pub async fn detect_mailbox_changes(
) -> BichonResult<()> {
if account.known_folders.is_none() {
// First time sync: just save without comparing
AccountModel::update_known_folders(account.id, all_names).await?;
AccountModel::update_known_folders(account.id, all_names)?;
return Ok(());
}
let known_folders = account.known_folders.clone().unwrap_or_default();
@@ -139,19 +140,19 @@ pub async fn detect_mailbox_changes(
let deleted_folders: Vec<String> = known_folders.difference(&all_names).cloned().collect();
let has_changes = !new_folders.is_empty() || !deleted_folders.is_empty();
let sync_folders = account.sync_folders.as_deref().unwrap_or_default();
let download_folders = account.download_folders.as_deref().unwrap_or_default();
// Handle deleted folders in sync_folders
if !deleted_folders.is_empty() {
// Check if any deleted folders are in sync_folders
let remaining_sync_folders: Vec<String> = sync_folders
let remaining_sync_folders: Vec<String> = download_folders
.iter()
.filter(|folder| !deleted_folders.contains(folder))
.cloned()
.collect();
// If sync_folders changed, update them
if remaining_sync_folders.len() != sync_folders.len() {
let removed_count = sync_folders.len() - remaining_sync_folders.len();
if remaining_sync_folders.len() != download_folders.len() {
let removed_count = download_folders.len() - remaining_sync_folders.len();
info!(
"Account {}: Removed {} deleted folders from sync_folders",
account.id, removed_count
@@ -159,7 +160,7 @@ pub async fn detect_mailbox_changes(
// Note: When all subscribed folders are deleted (remaining_sync_folders empty),
// the system's default behavior is to automatically fall back to syncing
// only the default folders (INBOX and Sent) in subsequent operations
AccountModel::update_sync_folders(account.id, remaining_sync_folders).await?;
AccountModel::update_download_folders(account.id, remaining_sync_folders)?;
}
info!(
@@ -174,11 +175,22 @@ pub async fn detect_mailbox_changes(
"Account {}: New folders detected: {:?}",
account.id, new_folders
);
if account.auto_download_new_mailboxes.unwrap_or(false) {
let mut updated: Vec<String> = download_folders.to_vec();
updated.extend(new_folders.iter().cloned());
AccountModel::update_download_folders(account.id, updated)?;
info!(
"Account {}: Auto-added {} new folders to download list",
account.id,
new_folders.len()
);
}
}
// Update known folders only if there were changes
if has_changes {
AccountModel::update_known_folders(account.id, all_names).await?;
AccountModel::update_known_folders(account.id, all_names)?;
mailbox_cache::invalidate(account.id).await;
}
Ok(())
}
+156
View File
@@ -0,0 +1,156 @@
//
// 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 std::str::FromStr;
use chrono::{DateTime, Local, TimeZone, Utc};
use cron::Schedule;
use crate::{
utc_now,
{
account::{
migration::AccountModel,
state::{DownloadState, TriggerType},
},
error::BichonResult,
},
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DownloadTask {
FullFetch,
TraceFetch,
Idle,
}
pub async fn decide_next_download_task(
account: &AccountModel,
trigger_type: TriggerType,
) -> BichonResult<DownloadTask> {
let state = match DownloadState::get(account.id)? {
None => {
DownloadState::init(account.id).await?;
return Ok(DownloadTask::FullFetch);
}
Some(s) => s,
};
let should_start = match trigger_type {
TriggerType::Manual => true,
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 {
DownloadState::start_new_session(account.id, trigger_type)?;
Ok(DownloadTask::TraceFetch)
} else {
Ok(DownloadTask::Idle)
}
}
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)
}
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));
}
}
+662
View File
@@ -0,0 +1,662 @@
//
// 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::{
raise_error,
{
account::{
migration::AccountModel,
state::{DownloadState, DownloadStatus, FolderStatus},
},
cache::{
imap::{
download::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_by_date},
find_intersecting_mailboxes, find_missing_mailboxes,
mailbox::MailBox,
},
SEMAPHORE,
},
error::{code::ErrorCode, BichonResult},
imap::executor::{
generate_uid_sequence_hashset, ImapExecutor, DEFAULT_BATCH_SIZE,
},
store::tantivy::envelope::ENVELOPE_MANAGER,
},
};
use std::time::Instant;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum FetchDirection {
Since,
Before,
}
pub async fn fetch_and_save_by_date(
account: &AccountModel,
date: &str,
mailbox: &MailBox,
direction: FetchDirection,
token: CancellationToken,
) -> BichonResult<Option<u32>> {
let account_id = account.id;
let mut session = match ImapExecutor::create_connection(account_id).await {
Ok(session) => session,
Err(e) => {
let err_msg = format!("Connection failed for this folder: {:#?}", 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);
}
};
let search_criteria = match direction {
FetchDirection::Since => format!("SINCE {date}"),
FetchDirection::Before => format!("BEFORE {date}"),
};
let uid_list =
match ImapExecutor::uid_search(&mut session, &mailbox.encoded_name(), &search_criteria)
.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);
}
};
let len = uid_list.len();
if len == 0 {
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
None,
)?;
return Ok(None);
}
// sort small -> bigger
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
uid_vec.sort();
let max_uid = uid_vec.last().copied();
let planned = uid_vec.len() as u64;
let uid_batches = generate_uid_sequence_hashset(
uid_vec,
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
);
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
planned,
0,
FolderStatus::Pending,
None,
)?;
let mut current_processed = 0u64;
let mut has_error_or_cancel = false;
for (index, batch) in uid_batches.into_iter().enumerate() {
if token.is_cancelled() {
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,
current_processed,
FolderStatus::Cancelled,
None,
)?;
has_error_or_cancel = true;
break;
}
// Fetch metadata for the current batch of UIDs
match ImapExecutor::uid_batch_retrieve_emails(
&mut session,
account_id,
mailbox.id,
&batch.0,
account.max_email_size_bytes,
token.clone(),
)
.await
{
Ok(processed) => {
current_processed += processed;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
planned,
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(),
planned,
current_processed,
FolderStatus::Failed,
Some(err_msg),
)?;
has_error_or_cancel = true;
break;
}
}
}
if !has_error_or_cancel {
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
planned,
current_processed,
FolderStatus::Success,
None,
)?;
}
session.logout().await.ok();
Ok(max_uid)
}
/// Fetches all messages from a mailbox.
/// Returns `Ok(Some(max_uid))` with the highest UID stored, or `Ok(None)` if empty.
pub async fn fetch_and_save_full_mailbox(
account: &AccountModel,
mailbox: &MailBox,
token: CancellationToken,
) -> BichonResult<Option<u32>> {
let mailbox_id = mailbox.id;
let account_id = account.id;
let mut session = match ImapExecutor::create_connection(account_id).await {
Ok(session) => session,
Err(e) => {
let err_msg = format!("Connection failed for this folder: {:#?}", 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);
}
};
let total = match session.examine(&mailbox.encoded_name()).await {
Ok(mailbox) => mailbox.exists as u64,
Err(e) => {
let err_msg = format!("Failed to examine folder [{}]: {:#?}", mailbox.name, e);
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
mailbox.exists as u64,
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)?;
DownloadState::append_session_error(account_id, err_msg)?;
session.logout().await.ok();
return Err(raise_error!(
format!("{:#?}", e),
ErrorCode::ImapCommandFailed
));
}
};
let page_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
let total_batches = total.div_ceil(page_size as u64);
info!(
"Starting full mailbox download for '{}', total={}, batches={}",
mailbox.name, total, total_batches
);
let mut current_processed = 0u64;
let mut has_error_or_cancel = false;
let mut max_uid: Option<u32> = None;
for page in 1..=total_batches {
if token.is_cancelled() {
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(),
total,
current_processed,
FolderStatus::Cancelled,
None,
)?;
has_error_or_cancel = true;
break;
}
match ImapExecutor::batch_retrieve_emails(
&mut session,
account_id,
mailbox_id,
total,
page as u64,
page_size as u64,
&mailbox.encoded_name(),
account.max_email_size_bytes,
token.clone(),
&mut max_uid,
)
.await
{
Ok(count) => {
current_processed += count as u64;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total,
current_processed,
FolderStatus::Downloading,
None,
)?;
}
Err(e) => {
let err_msg = format!("Batch {} failed: {:#?}", page, e);
DownloadState::append_session_error(account_id, err_msg.clone())?;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total,
current_processed,
FolderStatus::Failed,
Some(err_msg),
)?;
has_error_or_cancel = true;
break;
}
};
}
if !has_error_or_cancel {
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total,
current_processed,
FolderStatus::Success,
None,
)?;
}
session.logout().await.ok();
Ok(max_uid)
}
/// Generates a synthetic UIDVALIDITY for IMAP servers that don't provide it.
/// Uses a stable hash of the mailbox name to ensure consistent IDs across sessions.
fn generate_synthetic_uidvalidity(mailbox_name: &str) -> u32 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
mailbox_name.hash(&mut hasher);
(hasher.finish() as u32).wrapping_add(1) // Avoid 0, which might be reserved
}
pub async fn reconcile_mailboxes(
account: &AccountModel,
remote_mailboxes: &[MailBox],
local_mailboxes: &[MailBox],
token: CancellationToken,
) -> BichonResult<()> {
let start_time = Instant::now();
let existing_mailboxes = find_intersecting_mailboxes(local_mailboxes, remote_mailboxes);
let account_id = account.id;
if !existing_mailboxes.is_empty() {
let mut mailboxes_to_update = Vec::with_capacity(existing_mailboxes.len());
DownloadState::init_folder_details(
account.id,
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
)?;
for (local_mailbox, remote_mailbox) in &existing_mailboxes {
if token.is_cancelled() {
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)?;
break;
}
// Handle missing UIDVALIDITY from non-compliant IMAP servers
// (e.g., Tencent Enterprise Mail, etc.)
let remote_uid_validity = match remote_mailbox.uid_validity {
Some(uid) => uid,
None => {
// Generate a synthetic UIDVALIDITY based on mailbox name
let synthetic_uid = generate_synthetic_uidvalidity(&remote_mailbox.name);
warn!(
"Account {}: Mailbox '{}' - Server did not provide UIDVALIDITY. \
Using synthetic UIDVALIDITY {} based on mailbox name. \
This mailbox will be synced but may require periodic rebuilds if the server's mailbox structure changes.",
account_id, remote_mailbox.name, synthetic_uid
);
synthetic_uid
}
};
let new_highest_uid = if local_mailbox.uid_validity != Some(remote_uid_validity) {
info!(
"Account {}: Mailbox '{}' detected with changed uid_validity (local: {:#?}, remote: {:#?}). \
The mailbox data may be invalid, resetting its envelopes and rebuilding the cache.",
account_id, local_mailbox.name, &local_mailbox.uid_validity, &remote_uid_validity
);
DownloadState::update_folder_progress(
account_id,
local_mailbox.name.clone(),
remote_mailbox.exists as u64,
0,
FolderStatus::Downloading,
Some("UID validity changed, rebuilding...".into()),
)?;
match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_by_date(
account,
local_mailbox.id,
&date_since.since_date()?,
remote_mailbox,
FetchDirection::Since,
token.clone(),
)
.await?
}
None => match &account.date_before {
Some(r) => {
rebuild_mailbox_cache_by_date(
account,
local_mailbox.id,
&r.calculate_date()?,
remote_mailbox,
FetchDirection::Before,
token.clone(),
)
.await?
}
None => {
rebuild_mailbox_cache(
account,
local_mailbox,
remote_mailbox,
token.clone(),
)
.await?
}
},
}
} else {
perform_incremental_sync(account, local_mailbox, remote_mailbox, token.clone())
.await?
};
let mut updated = remote_mailbox.clone();
updated.highest_uid = new_highest_uid;
// Update uid_validity with the resolved value (either from server or synthetic)
if updated.uid_validity.is_none() {
updated.uid_validity = Some(remote_uid_validity);
}
mailboxes_to_update.push(updated);
}
//The metadata of this mailbox must only be updated after a successful synchronization;
//otherwise, it may cause synchronization errors and result in missing emails in the local sync results.
MailBox::batch_upsert(&mailboxes_to_update)?;
}
debug!(
"Checked mailbox folders for account ID: {}. Compared local and server folders to identify changes. Elapsed time: {} seconds",
account.id,
start_time.elapsed().as_secs()
);
let missing_mailboxes = find_missing_mailboxes(local_mailboxes, remote_mailboxes);
//Mail folders that are not locally need to be downloaded.
if !missing_mailboxes.is_empty() {
MailBox::batch_insert(&missing_mailboxes)?;
let mut has_error = false;
let mut last_err = None;
for mailbox in &missing_mailboxes {
if token.is_cancelled() {
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)?;
break;
}
if mailbox.exists > 0 {
let account = account.clone();
let mailbox = mailbox.clone();
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => permit,
Err(err) => {
error!(
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
account.id, &mailbox.name, err
);
continue;
}
};
let result = match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_by_date(
&account,
mailbox.id,
&date_since.since_date()?,
&mailbox,
FetchDirection::Since,
token.clone(),
)
.await
}
None => match &account.date_before {
Some(r) => {
rebuild_mailbox_cache_by_date(
&account,
mailbox.id,
&r.calculate_date()?,
&mailbox,
FetchDirection::Before,
token.clone(),
)
.await
}
None => {
rebuild_mailbox_cache(&account, &mailbox, &mailbox, token.clone()).await
}
},
};
match result {
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);
last_err = Some(err);
}
}
}
}
if has_error {
if let Some(e) = last_err {
return Err(e);
}
return Err(raise_error!(
"Some tasks failed".into(),
ErrorCode::InternalError
));
}
}
Ok(())
}
//only check new emails and sync
/// Incrementally syncs a mailbox.
/// Returns the new highest UID after sync, or `None` if nothing changed.
async fn perform_incremental_sync(
account: &AccountModel,
local_mailbox: &MailBox,
remote_mailbox: &MailBox,
token: CancellationToken,
) -> BichonResult<Option<u32>> {
if remote_mailbox.exists > 0 {
// Use stored highest_uid if available; otherwise fall back to Tantivy
// query once (backward compatibility with pre-existing databases).
let start_uid = match local_mailbox.highest_uid {
Some(uid) => {
tracing::info!(
"[account {}][mailbox {}] perform_incremental_sync: stored highest_uid={}, remote.exists={}",
account.id,
local_mailbox.name,
uid,
remote_mailbox.exists
);
uid as u64 + 1
}
None => {
let local_max_uid =
ENVELOPE_MANAGER.get_max_uid(account.id, local_mailbox.id)?;
tracing::info!(
"[account {}][mailbox {}] perform_incremental_sync: highest_uid unset, Tantivy max_uid={:?}, remote.exists={}",
account.id,
local_mailbox.name,
local_max_uid,
remote_mailbox.exists
);
match local_max_uid {
Some(uid) => uid + 1,
None => {
info!(
"No maximum UID found in index for mailbox, assuming local storage is missing."
);
let result = match &account.date_since {
Some(date_since) => {
fetch_and_save_by_date(
account,
date_since.since_date()?.as_str(),
remote_mailbox,
FetchDirection::Since,
token,
)
.await?
}
None => match &account.date_before {
Some(r) => {
fetch_and_save_by_date(
account,
&r.calculate_date()?,
remote_mailbox,
FetchDirection::Before,
token,
)
.await?
}
None => {
fetch_and_save_full_mailbox(
account, remote_mailbox, token,
)
.await?
}
},
};
return Ok(result);
}
}
}
};
let mut session = ImapExecutor::create_connection(account.id).await?;
let before_date = account
.date_before
.as_ref()
.map(|r| r.calculate_date())
.transpose()?;
let new_max_uid = ImapExecutor::fetch_new_mail(
&mut session,
account,
local_mailbox,
start_uid,
before_date.as_deref(),
token,
)
.await?;
session.logout().await.ok();
// Keep existing highest_uid if no new mail was fetched.
Ok(new_max_uid.or(local_mailbox.highest_uid))
} else {
Ok(local_mailbox.highest_uid)
}
}
+143
View File
@@ -0,0 +1,143 @@
//
// 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::{
migration::{AccountModel, AccountType},
state::{DownloadState, DownloadStatus, TriggerType},
},
cache::imap::{download::flow::FetchDirection, mailbox::MailBox},
error::BichonResult,
imap::executor::ImapExecutor,
};
use download_folders::get_download_folders;
use download_type::{decide_next_download_task, DownloadTask};
use flow::reconcile_mailboxes;
use rebuild::{rebuild_cache, rebuild_cache_by_date};
use std::time::Instant;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
pub mod download_folders;
pub mod download_type;
pub mod flow;
pub mod rebuild;
pub async fn process_imap_download(
account: &AccountModel,
token: CancellationToken,
trigger_type: TriggerType,
) -> BichonResult<()> {
assert_eq!(account.account_type, AccountType::IMAP);
let start_time = Instant::now();
let account_id = account.id;
let download_task = decide_next_download_task(account, trigger_type).await?;
if matches!(download_task, DownloadTask::Idle) {
return Ok(());
}
let mut session = match ImapExecutor::create_connection(account_id).await {
Ok(session) => session,
Err(e) => {
let err_msg = format!("Failed to connect to IMAP server: {:#?}", e);
DownloadState::append_session_error(account_id, err_msg.clone())?;
DownloadState::update_session_status(
account_id,
DownloadStatus::Failed,
Some(err_msg),
)?;
return Err(e);
}
};
let remote_mailboxes = match get_download_folders(account, &mut session).await {
Ok(mailboxes) => mailboxes,
Err(err) => {
let err_msg = format!("Failed to fetch mailboxes: {:#?}", err);
warn!(account_id = account.id, error = %err, "{}", err_msg);
DownloadState::append_session_error(account_id, err_msg.clone())?;
DownloadState::update_session_status(
account_id,
DownloadStatus::Failed,
Some(err_msg),
)?;
return Ok(());
}
};
session.logout().await.ok();
if matches!(download_task, DownloadTask::FullFetch) {
let result = match &account.date_since {
Some(date_since) => {
rebuild_cache_by_date(
account,
&remote_mailboxes,
&date_since.since_date()?,
FetchDirection::Since,
token,
)
.await
}
None => match &account.date_before {
Some(r) => {
rebuild_cache_by_date(
account,
&remote_mailboxes,
&r.calculate_date()?,
FetchDirection::Before,
token,
)
.await
}
None => rebuild_cache(account, &remote_mailboxes, token).await,
},
};
match result {
Ok(_) => {
DownloadState::update_session_status(account_id, DownloadStatus::Success, None)?;
}
Err(e) => {
let err_msg = format!("Email Download interrupted: {:#?}", e);
DownloadState::append_session_error(account_id, err_msg.clone())?;
DownloadState::update_session_status(
account_id,
DownloadStatus::Failed,
Some(err_msg),
)?;
}
}
return Ok(());
}
let local_mailboxes = MailBox::list_all(account_id)?;
match reconcile_mailboxes(account, &remote_mailboxes, &local_mailboxes, token).await {
Ok(_) => DownloadState::update_session_status(account_id, DownloadStatus::Success, None)?,
Err(e) => {
let err_msg = format!("Email Download interrupted: {:#?}", e);
DownloadState::append_session_error(account_id, err_msg.clone())?;
DownloadState::update_session_status(
account_id,
DownloadStatus::Failed,
Some(err_msg),
)?;
}
}
let elapsed_time = start_time.elapsed().as_secs();
debug!(
"Account{{{}}} Incremental sync completed: {} seconds elapsed.",
account.email, elapsed_time
);
Ok(())
}
+268
View File
@@ -0,0 +1,268 @@
//
// 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::{
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;
use tracing::{error, info};
pub async fn rebuild_cache(
account: &AccountModel,
remote_mailboxes: &[MailBox],
token: CancellationToken,
) -> BichonResult<()> {
MailBox::batch_insert(remote_mailboxes)?;
DownloadState::init_folder_details(
account.id,
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
)?;
let mut has_error = false;
let mut last_err = None;
for mailbox in remote_mailboxes {
if token.is_cancelled() {
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)?;
break;
}
if mailbox.exists == 0 {
info!(
"Account {}: Mailbox '{}' on the remote server has no emails. Skipping fetch for this mailbox.",
account.id, &mailbox.name
);
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
None,
)?;
continue;
}
let account = account.clone();
let mailbox = mailbox.clone();
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => permit,
Err(err) => {
error!(
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
account.id, &mailbox.name, err
);
continue;
}
};
match fetch_and_save_full_mailbox(&account, &mailbox, token.clone()).await {
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);
last_err = Some(err);
}
}
}
if has_error {
if let Some(e) = last_err {
return Err(e);
}
return Err(raise_error!(
"Some tasks failed".into(),
ErrorCode::InternalError
));
}
Ok(())
}
pub async fn rebuild_cache_by_date(
account: &AccountModel,
remote_mailboxes: &[MailBox],
date: &str,
direction: FetchDirection,
token: CancellationToken,
) -> BichonResult<()> {
MailBox::batch_insert(remote_mailboxes)?;
DownloadState::init_folder_details(
account.id,
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
)?;
let mut has_error = false;
let mut last_err = None;
for mailbox in remote_mailboxes {
if token.is_cancelled() {
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)?;
break;
}
if mailbox.exists == 0 {
info!(
"Account {}: Mailbox '{}' on the remote server has no emails. Skipping fetch for this mailbox.",
account.id, &mailbox.name
);
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
None,
)?;
continue;
}
let account = account.clone();
let mailbox = mailbox.clone();
let date = date.to_string();
let direction = direction.clone();
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => permit,
Err(err) => {
error!(
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
account.id, &mailbox.name, err
);
continue;
}
};
match fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction, token.clone())
.await
{
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);
last_err = Some(err);
}
}
}
if has_error {
if let Some(e) = last_err {
return Err(e);
}
return Err(raise_error!(
"Some tasks failed".into(),
ErrorCode::InternalError
));
}
Ok(())
}
pub async fn rebuild_mailbox_cache(
account: &AccountModel,
local_mailbox: &MailBox,
remote_mailbox: &MailBox,
token: CancellationToken,
) -> 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.",
account.id,
&local_mailbox.name
);
DownloadState::update_folder_progress(
account.id,
remote_mailbox.name.clone(),
0,
0,
FolderStatus::Success,
None,
)?;
return Ok(None);
}
let result = fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
Ok(result)
}
pub async fn rebuild_mailbox_cache_by_date(
account: &AccountModel,
local_mailbox_id: u64,
date: &str,
remote: &MailBox,
direction: FetchDirection,
token: CancellationToken,
) -> 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.",
account.id,
&remote.name
);
DownloadState::update_folder_progress(
account.id,
remote.name.clone(),
0,
0,
FolderStatus::Success,
None,
)?;
return Ok(None);
}
let result = fetch_and_save_by_date(account, date, remote, direction, token).await?;
Ok(result)
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -17,32 +17,24 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
decode_mailbox_name, encode_mailbox_name,
modules::{
decode_mailbox_name, encode_mailbox_name, raise_error,
{
database::{
async_find_impl, batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl,
filter_by_secondary_key_impl, manager::DB_MANAGER,
batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl, filter_impl,
find_impl, manager::DB_MANAGER, MemDbModel,
},
error::{code::ErrorCode, BichonResult},
},
raise_error,
};
use async_imap::types::{Name, NameAttribute};
use itertools::Itertools;
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 1, version = 1)]
#[native_db]
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct MailBox {
/// The unique identifier for the mailbox
#[primary_key]
pub id: u64,
/// The ID of the account associated with the mailbox
#[secondary_key]
pub account_id: u64,
/// The unique, decoded, human-readable name of the mailbox (e.g., "INBOX", "Sent Items").
/// This is the decoded name as presented to users, derived from the IMAP server's mailbox name
@@ -64,6 +56,19 @@ 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 {
fn collection() -> &'static str {
"mailboxes"
}
fn key(&self) -> String {
self.id.to_string()
}
}
impl MailBox {
@@ -71,26 +76,8 @@ impl MailBox {
encode_mailbox_name!(&self.name)
}
// pub async fn batch_delete(mailboxes: Vec<MailBox>) -> BichonResult<()> {
// batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| {
// let mut to_deleted = Vec::new();
// for mailbox in mailboxes {
// let retrived = rw
// .get()
// .primary::<MailBox>(mailbox.id)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// if let Some(retrived) = retrived {
// to_deleted.push(retrived);
// }
// }
// Ok(to_deleted)
// })
// .await?;
// Ok(())
// }
pub async fn get(id: u64) -> BichonResult<MailBox> {
let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
pub fn get(id: u64) -> BichonResult<MailBox> {
let result = find_impl::<MailBox>(DB_MANAGER.db(), &id.to_string())?;
Ok(result.ok_or_else(|| {
raise_error!(
format!("mailbox {} not found", id),
@@ -99,47 +86,40 @@ impl MailBox {
})?)
}
pub async fn delete(id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.envelope_db(), move |rw| {
rw.get()
.primary::<MailBox>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
})
.await
pub fn delete(id: u64) -> BichonResult<()> {
delete_impl::<MailBox>(DB_MANAGER.db(), &id.to_string())
}
pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
filter_by_secondary_key_impl(DB_MANAGER.envelope_db(), MailBoxKey::account_id, account_id)
.await
pub fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)
}
pub async fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_insert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
pub fn find_mailbox(account_id: u64, mailbox_id: u64) -> BichonResult<Option<MailBox>> {
let all = filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)?;
Ok(all.into_iter().find(|m| m.id == mailbox_id))
}
pub async fn batch_upsert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_upsert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
pub fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_insert_impl(DB_MANAGER.db(), mailboxes.to_vec())
}
pub async fn clean(account_id: u64) -> BichonResult<()> {
batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| {
let mailboxes: Vec<MailBox> = rw
.scan()
.secondary::<MailBox>(MailBoxKey::account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(mailboxes)
})
.await?;
pub fn batch_upsert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_upsert_impl(DB_MANAGER.db(), mailboxes.to_vec())
}
pub fn clean(account_id: u64) -> BichonResult<()> {
let mailboxes =
filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)?;
let keys: Vec<String> = mailboxes.iter().map(|m| m.id.to_string()).collect();
if !keys.is_empty() {
batch_delete_impl::<MailBox>(DB_MANAGER.db(), keys)?;
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, Object)]
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct Attribute {
pub attr: AttributeEnum,
pub extension: Option<String>,
@@ -151,7 +131,8 @@ impl Attribute {
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, Enum)]
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum AttributeEnum {
NoInferiors,
NoSelect,
+114
View File
@@ -0,0 +1,114 @@
//
// 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::cache::imap::mailbox::MailBox;
use crate::utc_now;
use lru::LruCache;
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::LazyLock;
use tokio::sync::Mutex;
struct CacheEntry {
mailboxes: Vec<MailBox>,
fetched_at: i64,
}
static CACHE: LazyLock<Mutex<LruCache<u64, CacheEntry>>> = LazyLock::new(|| {
Mutex::new(LruCache::new(NonZeroUsize::new(64).unwrap()))
});
const TTL_MS: i64 = 10 * 60 * 1000; // 10 minutes
pub async fn get(account_id: u64) -> Option<Vec<MailBox>> {
let mut guard = CACHE.lock().await;
if let Some(entry) = guard.get(&account_id) {
if utc_now!() - entry.fetched_at < TTL_MS {
return Some(entry.mailboxes.clone());
}
guard.pop(&account_id);
}
None
}
pub async fn set(account_id: u64, mailboxes: Vec<MailBox>) {
let mut guard = CACHE.lock().await;
guard.put(
account_id,
CacheEntry {
mailboxes,
fetched_at: utc_now!(),
},
);
}
pub async fn invalidate(account_id: u64) {
let mut guard = CACHE.lock().await;
guard.pop(&account_id);
}
// Background fetch state tracking
#[derive(Clone, Debug)]
pub enum FetchStatus {
Fetching { examined: usize, total: usize },
Ready,
Error(String),
}
static FETCH_STATES: LazyLock<Mutex<HashMap<u64, FetchStatus>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub async fn fetch_status(account_id: u64) -> Option<FetchStatus> {
FETCH_STATES.lock().await.get(&account_id).cloned()
}
pub async fn set_fetching(account_id: u64) {
FETCH_STATES.lock().await.insert(
account_id,
FetchStatus::Fetching {
examined: 0,
total: 0,
},
);
}
pub async fn update_fetch_progress(account_id: u64, examined: usize, total: usize) {
let mut guard = FETCH_STATES.lock().await;
guard.insert(
account_id,
FetchStatus::Fetching { examined, total },
);
}
pub async fn set_fetch_ready(account_id: u64) {
FETCH_STATES
.lock()
.await
.insert(account_id, FetchStatus::Ready);
}
pub async fn set_fetch_error(account_id: u64, error: String) {
FETCH_STATES
.lock()
.await
.insert(account_id, FetchStatus::Error(error));
}
pub async fn clear_fetch_state(account_id: u64) {
FETCH_STATES.lock().await.remove(&account_id);
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,30 +16,20 @@
// 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::collections::{HashMap, HashSet};
use std::sync::LazyLock;
use crate::modules::{account::state::AccountRunningState, database::ModelsAdapter};
use ahash::{AHashMap, AHashSet};
use mailbox::MailBox;
use native_db::Models;
pub mod download;
pub mod mailbox;
pub mod sync;
pub mod mailbox_cache;
pub mod task;
pub static MAILBOX_MODELS: LazyLock<Models> = LazyLock::new(|| {
let mut adapter = ModelsAdapter::new();
adapter.register_model::<MailBox>();
adapter.register_model::<AccountRunningState>();
adapter.models
});
pub fn find_missing_mailboxes(
local_mailboxes: &[MailBox],
server_mailboxes: &[MailBox],
) -> Vec<MailBox> {
let local_names: AHashSet<_> = local_mailboxes.iter().map(|m| &m.name).collect();
let local_names: HashSet<_> = local_mailboxes.iter().map(|m| &m.name).collect();
server_mailboxes
.iter()
.filter(|m| !local_names.contains(&m.name))
@@ -51,7 +41,7 @@ pub fn find_intersecting_mailboxes(
local_mailboxes: &[MailBox],
remote_mailboxes: &[MailBox],
) -> Vec<(MailBox, MailBox)> {
let local_map: AHashMap<_, _> = local_mailboxes
let local_map: HashMap<_, _> = local_mailboxes
.iter()
.map(|m| (m.name.clone(), m.clone()))
.collect();
+276
View File
@@ -0,0 +1,276 @@
//
// 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::AuthType;
use crate::account::state::{DownloadState, TriggerType};
use crate::cache::imap::download::process_imap_download;
use crate::common::periodic::{PeriodicTask, TaskHandle};
use crate::error::code::ErrorCode;
use crate::oauth2::token::OAuth2AccessToken;
use crate::{account::migration::AccountModel, error::BichonResult};
use crate::{raise_error, utc_now};
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicI64, Ordering};
use std::{sync::LazyLock, time::Duration};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
static _DESCRIPTION: &str = "This task periodically synchronizes mailbox data for a specified account, ensuring that all local data is up-to-date.";
const TASK_INTERVAL: Duration = Duration::from_secs(10);
pub static SYNC_TASKS: LazyLock<AccountDownTask> = LazyLock::new(AccountDownTask::new);
static LAST_WARN_TIME: AtomicI64 = AtomicI64::new(0);
const WARN_INTERVAL_MS: i64 = 600_000;
pub struct AccountDownTask {
tasks: Mutex<Option<HashMap<u64, (TaskHandle, CancellationToken)>>>,
manual_tasks: Mutex<HashMap<u64, (JoinHandle<()>, CancellationToken)>>,
busy_accounts: Mutex<HashSet<u64>>,
}
impl AccountDownTask {
pub fn new() -> Self {
Self {
tasks: Mutex::new(Some(HashMap::new())),
manual_tasks: Mutex::new(HashMap::new()),
busy_accounts: Mutex::new(HashSet::new()),
}
}
async fn set_busy(&self, account_id: u64, is_busy: bool) {
let mut guard = self.busy_accounts.lock().await;
if is_busy {
guard.insert(account_id);
} else {
guard.remove(&account_id);
}
}
/// Atomically check and set busy. Returns true if we claimed the slot,
/// false if another task is already busy on this account.
async fn try_set_busy(&self, account_id: u64) -> bool {
let mut guard = self.busy_accounts.lock().await;
if guard.contains(&account_id) {
false
} else {
guard.insert(account_id);
true
}
}
// async fn is_busy(&self, account_id: u64) -> bool {
// self.busy_accounts.lock().await.contains(&account_id)
// }
pub async fn start_download_task(&self, account_id: u64, email: String) {
let task_name = format!("account-download-task-{}-{}", account_id, &email);
let periodic_task = PeriodicTask::new(&task_name);
let cancel_token = CancellationToken::new();
let task_token = cancel_token.clone();
let task = move |param: Option<u64>| {
let account_id = param.unwrap();
let internal_token = task_token.clone();
Box::pin(async move {
if SYNC_TASKS.is_manual_running(account_id).await {
info!(
"Account {}: Scheduled task skipped (Manual task is running).",
account_id
);
return Ok(());
}
if !SYNC_TASKS.try_set_busy(account_id).await {
warn!(
"Account {}: Scheduled task skipped (Previous sync still active).",
account_id
);
return Ok(());
}
let _busy_guard = scopeguard::guard(account_id, |id| {
tokio::spawn(async move {
SYNC_TASKS.set_busy(id, false).await;
});
});
let account = AccountModel::get(account_id).ok();
match account {
Some(account) => {
if !account.enabled {
let last = LAST_WARN_TIME.load(Ordering::Relaxed);
let now = utc_now!();
if now - last >= WARN_INTERVAL_MS {
LAST_WARN_TIME.store(now, Ordering::Relaxed);
warn!(
"Account {}: download aborted. Account is currently disabled.",
account_id
);
}
} else {
if let Some(imap) = &account.imap {
if let AuthType::OAuth2 = imap.auth.auth_type {
if OAuth2AccessToken::get(account.id)?.is_none() {
if utc_now!() % 300_000 == 0 {
warn!("Account {}: download aborted. OAuth2 authorization not completed. Please visit the rustmailer admin page to authorize this account.", account_id);
}
return Ok(());
}
}
}
if let Err(e) = process_imap_download(
&account,
internal_token,
TriggerType::Scheduled,
)
.await
{
DownloadState::append_session_error(
account.id,
format!("error in account download task: {:#?}", e),
)?;
error!(
"Failed to download mailbox data for '{}': {:?}",
account_id, e
)
}
}
}
None => {
error!(
"Account {}: download aborted. Account entity not found.",
account_id
);
}
}
Ok(())
})
};
let handler = periodic_task.start(task, Some(account_id), TASK_INTERVAL, true, true);
self.add_task(account_id, (handler, cancel_token)).await;
}
pub async fn add_task(&self, account_id: u64, handler: (TaskHandle, CancellationToken)) {
let mut guard = self.tasks.lock().await;
if let Some(map) = guard.as_mut() {
map.insert(account_id, handler);
} else {
tracing::error!("Failed to add task: HashMap has been taken during shutdown.");
}
}
pub async fn stop(&self, account_id: u64) -> BichonResult<()> {
let mut guard = self.tasks.lock().await;
if let Some(map) = guard.as_mut() {
if let Some((handler, token)) = map.remove(&account_id) {
drop(guard);
token.cancel();
handler.cancel().await;
}
}
Ok(())
}
pub async fn shutdown(&self) {
let mut guard = self.tasks.lock().await;
if let Some(map) = guard.take() {
drop(guard);
for (account_id, (handler, token)) in map {
info!(
"Shutdown: Sending cancel signal to account {}...",
account_id
);
token.cancel();
if let Err(_) = tokio::time::timeout(Duration::from_secs(5), handler.stop()).await {
error!(
"Shutdown: Account {} download task forced timeout.",
account_id
);
}
}
info!("Shutdown: All download tasks processed.");
}
}
pub async fn start_manual_task(&self, account_id: u64) -> BichonResult<()> {
{
if self.is_manual_running(account_id).await {
return Err(raise_error!(
"Manual task already running.".into(),
ErrorCode::Forbidden
));
}
if !self.try_set_busy(account_id).await {
return Err(raise_error!(
"The background synchronization is currently active. Please try again in a few seconds.".into(),
ErrorCode::Forbidden
));
}
}
let cancel_token = CancellationToken::new();
let token_clone = cancel_token.clone();
let handle = tokio::spawn(async move {
// busy already claimed by caller via try_set_busy
let _cleanup = scopeguard::guard(account_id, |id| {
tokio::spawn(async move {
SYNC_TASKS.set_busy(id, false).await;
let mut guard = SYNC_TASKS.manual_tasks.lock().await;
guard.remove(&id);
});
});
if token_clone.is_cancelled() {
return;
}
let account = match AccountModel::get(account_id) {
Ok(acc) => acc,
Err(e) => {
error!("Failed to fetch account {}: {:?}", account_id, e);
return;
}
};
if let Err(e) = process_imap_download(&account, token_clone, TriggerType::Manual).await
{
error!("Manual download failed for {}: {:?}", account_id, e);
let error_msg = format!("error in account download task: {:#?}", e);
let _ = DownloadState::append_session_error(account.id, error_msg);
}
});
{
let mut guard = self.manual_tasks.lock().await;
guard.insert(account_id, (handle, cancel_token));
}
Ok(())
}
pub async fn cancel_manual_task(&self, account_id: u64) {
let mut guard = self.manual_tasks.lock().await;
if let Some((handle, token)) = guard.remove(&account_id) {
token.cancel();
let _ = handle.await;
}
}
pub async fn is_manual_running(&self, account_id: u64) -> bool {
let guard = self.manual_tasks.lock().await;
guard.contains_key(&account_id)
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -17,7 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::settings::cli::SETTINGS;
use crate::settings::cli::SETTINGS;
use std::sync::{Arc, LazyLock};
use tokio::sync::Semaphore;
+143
View File
@@ -0,0 +1,143 @@
use std::{
collections::{BTreeSet, HashSet},
net::IpAddr,
};
use crate::{
error::{code::ErrorCode, BichonResult},
raise_error,
users::{permissions::Permission, role::UserRole, UserModel},
};
#[derive(Clone, Debug)]
pub struct ClientContext {
pub ip_addr: Option<IpAddr>,
pub user: UserModel,
}
impl ClientContext {
pub fn require_any_permission(
&self,
requirements: Vec<(Option<u64>, &str)>,
) -> BichonResult<()> {
for (account_id, permission) in requirements {
if self.has_permission(account_id, permission) {
return Ok(());
}
}
Err(raise_error!(
"Access denied: Insufficient permissions to perform this action.".into(),
ErrorCode::Forbidden
))
}
pub fn check_has_permission(
user: &UserModel,
account_id: Option<u64>,
permission: &str,
) -> bool {
if user.is_admin() {
return true;
}
let mut global_perms = HashSet::new();
for rid in &user.global_roles {
if let Some(role) = UserRole::find(*rid).ok().flatten() {
global_perms.extend(role.permissions);
}
}
if Self::check_global_logic(&global_perms, permission) {
return true;
}
if let Some(aid) = account_id {
if let Some(role_id) = user.account_access_map.get(&aid) {
if let Some(role) = UserRole::find(*role_id).ok().flatten() {
if role.permissions.contains(&permission.to_string())
|| Self::check_account_logic(&role.permissions, permission)
{
return true;
}
}
}
}
false
}
pub fn has_permission(&self, account_id: Option<u64>, permission: &str) -> bool {
if self.user.is_admin() {
return true;
}
let mut global_perms = HashSet::new();
for rid in &self.user.global_roles {
if let Some(role) = UserRole::find(*rid).ok().flatten() {
global_perms.extend(role.permissions);
}
}
if Self::check_global_logic(&global_perms, permission) {
return true;
}
if let Some(aid) = account_id {
if let Some(role_id) = self.user.account_access_map.get(&aid) {
if let Some(role) = UserRole::find(*role_id).ok().flatten() {
if role.permissions.contains(&permission.to_string())
|| Self::check_account_logic(&role.permissions, permission)
{
return true;
}
}
}
}
false
}
fn check_global_logic(global: &HashSet<String>, perm: &str) -> bool {
if global.contains(perm) {
return true;
}
match perm {
Permission::DATA_READ => global.contains(Permission::DATA_READ_ALL),
Permission::DATA_DELETE => global.contains(Permission::DATA_DELETE_ALL),
Permission::DATA_RAW_DOWNLOAD => global.contains(Permission::DATA_RAW_DOWNLOAD_ALL),
Permission::DATA_EXPORT_BATCH => global.contains(Permission::DATA_EXPORT_BATCH_ALL),
Permission::ACCOUNT_MANAGE | Permission::ACCOUNT_READ_DETAILS => {
global.contains(Permission::ACCOUNT_MANAGE_ALL)
}
_ => false,
}
}
fn check_account_logic(scoped_perms: &BTreeSet<String>, perm: &str) -> bool {
if scoped_perms.contains(perm) {
return true;
}
match perm {
Permission::DATA_READ | Permission::ACCOUNT_READ_DETAILS => {
scoped_perms.contains(Permission::ACCOUNT_MANAGE)
}
_ => false,
}
}
pub fn require_permission(
&self,
account_id: Option<u64>,
permission: &str,
) -> BichonResult<()> {
if self.has_permission(account_id, permission) {
Ok(())
} else {
Err(raise_error!(
format!("Access Denied: Missing permission '{}'", permission),
ErrorCode::Forbidden
))
}
}
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,29 +16,19 @@
// 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 super::error::code::ErrorCode;
use super::error::BichonError;
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
use poem::error::ResponseError;
use poem::Body;
use poem::{http::StatusCode, Error, Response};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::ops::Deref;
use tracing::error;
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
use serde::{Deserialize, Serialize};
pub mod auth;
pub mod error;
pub mod log;
pub mod paginated;
pub mod periodic;
pub mod rustls;
pub mod signal;
pub mod timeout;
pub mod tls;
#[cfg(feature = "web-api")]
pub mod validator;
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Object)]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct Addr {
/// The optional display name associated with the email address (e.g., "John Doe").
/// If `None`, no display name is specified.
@@ -91,62 +81,3 @@ impl<'x> From<&ImapAddress<'x>> for AddrVec {
AddrVec(vec)
}
}
// #[derive(Serialize)]
// pub struct ErrorResponse {
// pub message: String,
// }
#[inline]
fn create_rust_mailer_error(message: &str, code: ErrorCode) -> BichonError {
BichonError::Generic {
message: message.into(),
location: snafu::Location::default(),
code,
}
}
#[inline]
pub fn create_api_error_response(message: &str, code: ErrorCode) -> Error {
let rust_mailer_error = create_rust_mailer_error(message, code);
rust_mailer_error.into()
}
impl ResponseError for BichonError {
fn status(&self) -> StatusCode {
match self {
BichonError::Generic {
message: _,
location: _,
code,
} => code.status(),
}
}
fn as_response(&self) -> Response
where
Self: std::error::Error + Send + Sync + 'static,
{
match self {
BichonError::Generic {
message,
location,
code,
} => {
error!(
error_code = *code as u32,
error_message = %message,
error_location = ?location
);
let body = Body::from_json(serde_json::json!({
"code": *code as u32,
"message": message.to_string(),
}))
.unwrap();
Response::builder().status(self.status()).body(body)
}
}
}
}
+260
View File
@@ -0,0 +1,260 @@
//
// 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::{
error::{code::ErrorCode, BichonResult},
raise_error,
};
use serde::{Deserialize, Serialize};
use std::cmp::min;
pub fn paginate_vec<T: Clone>(
items: &Vec<T>,
page: Option<u64>,
page_size: Option<u64>,
) -> BichonResult<Paginated<T>> {
let total_items = items.len() as u64;
let (offset, total_pages) = match (page, page_size) {
(Some(p), Some(s)) if p > 0 && s > 0 => {
let offset = (p - 1) * s;
let total_pages = if total_items > 0 {
(total_items + s - 1) / s
} else {
0
};
(Some(offset), Some(total_pages))
}
(Some(0), _) | (_, Some(0)) => {
return Err(raise_error!(
"'page' and 'page_size' must be greater than 0.".into(),
ErrorCode::InvalidParameter
));
}
_ => (None, None),
};
let data = match offset {
Some(offset) if offset >= total_items => vec![],
Some(offset) => {
let end = min(offset + page_size.unwrap_or(total_items), total_items) as usize;
items[offset as usize..end].to_vec()
}
None => items.clone(),
};
Ok(Paginated::new(
page,
page_size,
total_items,
total_pages,
data,
))
}
#[cfg(not(feature = "web-api"))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DataPage<S>
where
S: Serialize + std::fmt::Debug + std::marker::Unpin + Send + Sync,
{
/// The current page number (starting from 1).
pub current_page: Option<u64>,
/// The number of items per page.
pub page_size: Option<u64>,
/// The total number of items across all pages.
pub total_items: u64,
/// The list of items returned on the current page.
pub items: Vec<S>,
/// The total number of pages. This is optional and may not be set if not calculated.
pub total_pages: Option<u64>,
}
#[cfg(not(feature = "web-api"))]
impl<S: Serialize + std::fmt::Debug + std::marker::Unpin + Send + Sync> From<Paginated<S>>
for DataPage<S>
{
fn from(paginated: Paginated<S>) -> Self {
DataPage {
current_page: paginated.page,
page_size: paginated.page_size,
total_items: paginated.total_items,
total_pages: paginated.total_pages,
items: paginated.items,
}
}
}
#[cfg(feature = "web-api")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, poem_openapi::Object)]
pub struct DataPage<S>
where
S: Serialize
+ std::fmt::Debug
+ std::marker::Unpin
+ Send
+ Sync
+ poem_openapi::types::Type
+ poem_openapi::types::ParseFromJSON
+ poem_openapi::types::ToJSON,
{
/// The current page number (starting from 1).
pub current_page: Option<u64>,
/// The number of items per page.
pub page_size: Option<u64>,
/// The total number of items across all pages.
pub total_items: u64,
/// The list of items returned on the current page.
pub items: Vec<S>,
/// The total number of pages. This is optional and may not be set if not calculated.
pub total_pages: Option<u64>,
}
#[cfg(feature = "web-api")]
impl<
S: Serialize
+ std::fmt::Debug
+ std::marker::Unpin
+ Send
+ Sync
+ poem_openapi::types::Type
+ poem_openapi::types::ParseFromJSON
+ poem_openapi::types::ToJSON,
> From<Paginated<S>> for DataPage<S>
{
fn from(paginated: Paginated<S>) -> Self {
DataPage {
current_page: paginated.page,
page_size: paginated.page_size,
total_items: paginated.total_items,
total_pages: paginated.total_pages,
items: paginated.items,
}
}
}
#[derive(Debug)]
pub struct Paginated<T> {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub total_items: u64,
pub total_pages: Option<u64>,
pub items: Vec<T>,
}
impl<T> Paginated<T> {
pub fn new(
page: Option<u64>,
page_size: Option<u64>,
total_items: u64,
total_pages: Option<u64>,
items: Vec<T>,
) -> Self {
Paginated {
page,
page_size,
total_items,
total_pages,
items,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paginate_vec_full_list_without_pagination() {
let items: Vec<i32> = (1..=10).collect();
let result = paginate_vec(&items, None, None).unwrap();
assert_eq!(result.items.len(), 10);
assert_eq!(result.total_items, 10);
assert_eq!(result.page, None);
assert_eq!(result.total_pages, None);
}
#[test]
fn paginate_vec_first_page() {
let items: Vec<i32> = (1..=25).collect();
let result = paginate_vec(&items, Some(1), Some(10)).unwrap();
assert_eq!(result.items, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
assert_eq!(result.total_items, 25);
assert_eq!(result.total_pages, Some(3));
assert_eq!(result.page, Some(1));
}
#[test]
fn paginate_vec_last_partial_page() {
let items: Vec<i32> = (1..=25).collect();
let result = paginate_vec(&items, Some(3), Some(10)).unwrap();
assert_eq!(result.items, vec![21, 22, 23, 24, 25]);
assert_eq!(result.total_items, 25);
assert_eq!(result.total_pages, Some(3));
}
#[test]
fn paginate_vec_page_beyond_range_returns_empty() {
let items: Vec<i32> = (1..=10).collect();
let result = paginate_vec(&items, Some(5), Some(10)).unwrap();
assert_eq!(result.items.len(), 0);
assert_eq!(result.total_items, 10);
}
#[test]
fn paginate_vec_empty_list() {
let items: Vec<i32> = vec![];
let result = paginate_vec(&items, Some(1), Some(10)).unwrap();
assert_eq!(result.items.len(), 0);
assert_eq!(result.total_items, 0);
assert_eq!(result.total_pages, Some(0));
}
#[test]
fn paginate_vec_zero_page_returns_error() {
let items: Vec<i32> = (1..=10).collect();
assert!(paginate_vec(&items, Some(0), Some(10)).is_err());
}
#[test]
fn paginate_vec_zero_page_size_returns_error() {
let items: Vec<i32> = (1..=10).collect();
assert!(paginate_vec(&items, Some(1), Some(0)).is_err());
}
#[test]
fn paginate_vec_single_item() {
let items = vec![42];
let result = paginate_vec(&items, Some(1), Some(10)).unwrap();
assert_eq!(result.items, vec![42]);
assert_eq!(result.total_items, 1);
assert_eq!(result.total_pages, Some(1));
}
#[test]
fn paginate_vec_exact_page_boundary() {
let items: Vec<i32> = (1..=20).collect();
let result = paginate_vec(&items, Some(2), Some(10)).unwrap();
assert_eq!(result.items, vec![11, 12, 13, 14, 15, 16, 17, 18, 19, 20]);
assert_eq!(result.total_pages, Some(2));
}
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,10 +16,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::{common::signal::SIGNAL_MANAGER, error::BichonResult};
use crate::{common::signal::SIGNAL_MANAGER, error::BichonResult};
use std::{future::Future, time::Duration};
use tokio::{sync::oneshot, time::MissedTickBehavior};
use tokio::{sync::oneshot, task::JoinHandle, time::MissedTickBehavior};
use tracing::{info, warn};
pub struct PeriodicTask {
@@ -28,7 +27,7 @@ pub struct PeriodicTask {
pub struct TaskHandle {
cancel_sender: Option<oneshot::Sender<()>>,
join_handle: tokio::task::JoinHandle<()>,
join_handle: JoinHandle<()>,
}
impl TaskHandle {
@@ -38,6 +37,10 @@ impl TaskHandle {
}
let _ = self.join_handle.await;
}
pub async fn stop(self) {
let _ = self.join_handle.await;
}
}
impl PeriodicTask {
@@ -82,6 +85,14 @@ impl PeriodicTask {
let mut cancel_receiver = cancel_receiver_opt;
loop {
let cancel_fut = async {
if let Some(ref mut rx) = cancel_receiver {
rx.await.ok();
} else {
std::future::pending::<()>().await;
}
};
tokio::select! {
_ = interval.tick() => {
match task(param).await {
@@ -92,13 +103,7 @@ impl PeriodicTask {
}
}
// only enabled if cancel_receiver is Some
_ = async {
if let Some(ref mut rx) = cancel_receiver {
rx.await.ok()
} else {
futures::future::pending().await
}
} => {
_ = cancel_fut => {
info!("Task '{}' received cancellation signal", name_clone);
break;
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,18 +16,17 @@
// 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::{
modules::{
{
context::Initialize,
error::{code::ErrorCode, BichonResult},
},
raise_error,
};
pub struct RustMailerTls;
pub struct BichonTls;
impl Initialize for RustMailerTls {
impl Initialize for BichonTls {
async fn initialize() -> BichonResult<()> {
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
.map_err(|_| {
@@ -38,4 +37,3 @@ impl Initialize for RustMailerTls {
})
}
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,12 +16,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::sync::LazyLock;
use crate::modules::{
context::Initialize, error::BichonResult, utils::shutdown::shutdown_signal,
};
use crate::{context::Initialize, error::BichonResult, utils::shutdown::shutdown_signal};
use tokio::sync::broadcast;
pub static SIGNAL_MANAGER: LazyLock<SignalManager> = LazyLock::new(SignalManager::new);
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -23,6 +23,7 @@ use std::{
};
use email_address::EmailAddress;
use poem_openapi::Validator;
pub struct EmailValidator;
@@ -33,6 +34,7 @@ impl Display for EmailValidator {
}
}
impl Validator<String> for EmailValidator {
fn check(&self, value: &String) -> bool {
match EmailAddress::from_str(value) {
@@ -40,4 +42,4 @@ impl Validator<String> for EmailValidator {
Err(_) => false,
}
}
}
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,30 +16,29 @@
// 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::modules::{cache::imap::task::SYNC_TASKS, error::BichonResult};
use crate::{cache::imap::task::SYNC_TASKS, error::BichonResult};
use std::{sync::LazyLock, time::Duration};
use tokio::sync::mpsc;
use tracing::{error, info};
pub static SYNC_CONTROLLER: LazyLock<SyncController> = LazyLock::new(SyncController::new);
pub static DOWNLOAD_CONTROLLER: LazyLock<DownloadController> =
LazyLock::new(DownloadController::new);
pub struct SyncController {
channel: mpsc::Sender<(u64, String)>, // Channel to trigger account sync by account ID
pub struct DownloadController {
channel: mpsc::Sender<(u64, String)>, // Channel to trigger account download by account ID
}
impl SyncController {
impl DownloadController {
pub fn new() -> Self {
let (tx, mut rx) = mpsc::channel::<(u64, String)>(100);
tokio::spawn(async move {
while let Some((account_id, email)) = rx.recv().await {
match Self::start_syncer(account_id, email.clone()).await {
Ok(Some(_)) => {}
Ok(None) => {}
match Self::start_download(account_id, email.clone()).await {
Ok(_) => {}
Err(err) => {
error!(
"Failed to prepare and start syncer of account {{{}-{}}}, error: {:#?}",
"Failed to prepare and start scheduled download of account {{{}-{}}}, error: {:#?}",
&account_id, &email, err
);
}
@@ -47,26 +46,26 @@ impl SyncController {
}
});
SyncController { channel: tx }
DownloadController { channel: tx }
}
/// Trigger synchronization for a specific account
pub async fn trigger_start(&self, account_id: u64, email: String) {
pub async fn trigger_schedule(&self, account_id: u64, email: String) {
if let Err(e) = self.channel.send((account_id, email)).await {
error!(
"Failed to trigger synchronization for account={{{}}}, error: {:?}",
"Failed to trigger download for account={{{}}}, error: {:?}",
account_id, e
);
}
}
async fn start_syncer(account_id: u64, email: String) -> BichonResult<Option<()>> {
async fn start_download(account_id: u64, email: String) -> BichonResult<()> {
info!(
"Account syncer starting for account: {}-{}.",
"Account download starting for account: {}-{}.",
account_id, email
);
SYNC_TASKS.start_account_sync_task(account_id, email).await;
SYNC_TASKS.start_download_task(account_id, email).await;
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(Some(()))
Ok(())
}
}
+75
View File
@@ -0,0 +1,75 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::account::migration::AccountType;
use crate::context::Initialize;
use crate::{
{
account::migration::AccountModel, context::controller::DOWNLOAD_CONTROLLER, error::BichonResult,
},
utc_now,
};
use std::sync::LazyLock;
use tracing::info;
pub static BICHON_CONTEXT: LazyLock<BichonContext> = LazyLock::new(BichonContext::new);
pub struct BichonContext {
start_at: i64,
}
impl Initialize for BichonContext {
async fn initialize() -> BichonResult<()> {
BICHON_CONTEXT.start_account_downloader().await
}
}
impl BichonContext {
pub fn new() -> Self {
Self {
start_at: utc_now!(),
}
}
pub fn uptime_ms(&self) -> i64 {
utc_now!() - self.start_at
}
pub async fn start_account_downloader(&self) -> BichonResult<()> {
let accounts = AccountModel::list_all()?;
let active_accounts: Vec<AccountModel> = accounts
.into_iter()
.filter(|a| a.enabled && matches!(a.account_type, AccountType::IMAP))
.collect();
if active_accounts.is_empty() {
info!("No active accounts found for account initialization.");
return Ok(());
}
info!(
"System has {} active IMAP accounts to initialize.",
active_accounts.len()
);
for account in active_accounts {
DOWNLOAD_CONTROLLER
.trigger_schedule(account.id, account.email)
.await
}
Ok(())
}
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,17 +16,16 @@
// 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::modules::error::BichonResult;
use crate::{common::periodic::TaskHandle, error::BichonResult};
pub mod controller;
pub mod executors;
pub mod status;
#[allow(async_fn_in_trait)]
pub trait Initialize {
async fn initialize() -> BichonResult<()>;
}
pub trait RustMailTask {
fn start();
pub trait BichonTask {
fn start() -> TaskHandle;
}
+227
View File
@@ -0,0 +1,227 @@
//
// 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::{
store::tantivy::{
attachment::ATTACHMENT_MANAGER,
envelope::ENVELOPE_MANAGER,
fields::{F_CONTENT_HASH, F_ID},
schema::SchemaTools,
},
users::permissions::Permission,
};
//use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tantivy::{schema::Value, TantivyDocument};
use crate::{
bichon_version, raise_error,
{
account::migration::AccountModel,
common::auth::ClientContext,
error::{code::ErrorCode, BichonResult},
settings::dir::DATA_DIR_MANAGER,
utils::get_total_size,
},
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct DashboardStats {
pub account_count: usize, // Number of accounts
pub email_count: u64, // Total number of emails
pub attachment_count: u64, // Total number of attachments
pub total_size_bytes: u64, // Total size of all emails (in bytes)
pub storage_usage_bytes: u64, // Actual storage used (in bytes)
pub index_usage_bytes: u64, // Index storage size (in bytes)
pub recent_activity: Vec<TimeBucket>, // Email activity over recent days
pub top_senders: Vec<Group>, // Top 10 senders
pub top_accounts: Vec<Group>, // Top 10 accounts
pub with_attachment_count: u64, // Emails with attachments
pub without_attachment_count: u64, // Emails without attachments
pub top_largest_emails: Vec<LargestEmail>, // Top 10 largest emails
pub top_largest_attachments: Vec<LargestAttachment>, // Top 10 largest attachments
pub system_version: String, // The semantic version string of the currently running backend service
}
impl DashboardStats {
pub async fn get(context: ClientContext) -> BichonResult<Self> {
let has_all_accounts = context.has_permission(None, Permission::ACCOUNT_MANAGE_ALL);
let authorized_ids: Option<HashSet<u64>> = if has_all_accounts {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
let mut stat = ENVELOPE_MANAGER.get_dashboard_stats(&authorized_ids)?;
stat.top_largest_emails = ENVELOPE_MANAGER.top_10_largest_emails(&authorized_ids)?;
stat.top_largest_attachments =
ATTACHMENT_MANAGER.top_10_largest_attachments(&authorized_ids)?;
stat.account_count = if has_all_accounts {
AccountModel::count()?
} else {
authorized_ids.as_ref().map(|ids| ids.len()).unwrap_or(0)
};
stat.email_count = ENVELOPE_MANAGER.total_emails(&authorized_ids)?;
stat.attachment_count = ATTACHMENT_MANAGER.total_attachments(&authorized_ids)?;
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))?;
stat.system_version = bichon_version!().to_string();
Ok(stat)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct TimeBucket {
pub timestamp_ms: i64, // Timestamp in milliseconds
pub count: u64, // Number of emails in this time bucket
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct Group {
pub key: String,
pub count: u64, // Number of emails from this sender
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct LargestEmail {
pub subject: String, // Email subject
pub size_bytes: u64, // Email size in bytes
pub id: String,
}
impl LargestEmail {
pub fn from_tantivy_doc(document: &TantivyDocument) -> BichonResult<Self> {
let fields = SchemaTools::email_fields();
let value = document.get_first(fields.f_size).ok_or_else(|| {
raise_error!(
"miss 'size' field in tantivy document".into(),
ErrorCode::InternalError
)
})?;
let size_bytes = value.as_u64().ok_or_else(|| {
raise_error!("'size' field is not a u64".into(), ErrorCode::InternalError)
})?;
let value = document.get_first(fields.f_subject).ok_or_else(|| {
raise_error!("'subject' field not found".into(), ErrorCode::InternalError)
})?;
let subject = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
raise_error!(
"'subject' field is not a string".into(),
ErrorCode::InternalError
)
})?;
let value = document.get_first(fields.f_id).ok_or_else(|| {
raise_error!(
format!("'{}' field not found", F_ID),
ErrorCode::InternalError
)
})?;
let id = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
raise_error!(
format!("'{}' field is not a string", F_ID),
ErrorCode::InternalError
)
})?;
let envelope = LargestEmail {
subject,
size_bytes,
id,
};
Ok(envelope)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct LargestAttachment {
pub name: String, // Attachment name
pub size_bytes: u64, // Attachment size in bytes
pub id: String,
pub content_hash: String,
}
impl LargestAttachment {
pub fn from_tantivy_doc(document: &TantivyDocument) -> BichonResult<Self> {
let fields = SchemaTools::attachment_fields();
let value = document.get_first(fields.f_size).ok_or_else(|| {
raise_error!(
"miss 'size' field in tantivy document".into(),
ErrorCode::InternalError
)
})?;
let size_bytes = value.as_u64().ok_or_else(|| {
raise_error!("'size' field is not a u64".into(), ErrorCode::InternalError)
})?;
let name = document
.get_first(fields.f_name_exact)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "Unknown".to_string());
let value = document.get_first(fields.f_id).ok_or_else(|| {
raise_error!(
format!("'{}' field not found", F_ID),
ErrorCode::InternalError
)
})?;
let id = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
raise_error!(
format!("'{}' field is not a string", F_ID),
ErrorCode::InternalError
)
})?;
let value = document.get_first(fields.f_content_hash).ok_or_else(|| {
raise_error!(
format!("'{}' field not found", F_CONTENT_HASH),
ErrorCode::InternalError
)
})?;
let content_hash = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
raise_error!(
format!("'{}' field is not a string", F_CONTENT_HASH),
ErrorCode::InternalError
)
})?;
let attachment = LargestAttachment {
name,
size_bytes,
id,
content_hash,
};
Ok(attachment)
}
}
+60
View File
@@ -0,0 +1,60 @@
//
// 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::settings::dir::DATA_DIR_MANAGER;
use memdb::{Durability, MemDb};
use std::sync::LazyLock;
use std::time::Duration;
pub static DB_MANAGER: LazyLock<DatabaseManager> = LazyLock::new(DatabaseManager::new);
pub struct DatabaseManager {
db: MemDb,
}
impl DatabaseManager {
fn new() -> Self {
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::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 }
}
/// Get a reference to the MemDb instance.
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}");
}
}
}
+225
View File
@@ -0,0 +1,225 @@
//
// 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::common::paginated::Paginated;
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::raise_error;
use memdb::{MemDb, Transaction};
use serde::de::DeserializeOwned;
use serde::Serialize;
pub mod manager;
/// Trait for models that can be stored in MemDb collections.
pub trait MemDbModel: Serialize + DeserializeOwned + Clone + Send + 'static {
/// The collection name this model is stored under.
fn collection() -> &'static str;
/// The primary key as a string for MemDb storage.
fn key(&self) -> String;
}
// ─── Insert ───────────────────────────────────────────────────────────────
pub fn insert_impl<M: MemDbModel>(db: &MemDb, item: M) -> BichonResult<()> {
let coll = db.collection(M::collection());
let key = item.key();
coll.insert(key, &item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub fn batch_insert_impl<M: MemDbModel>(db: &MemDb, items: Vec<M>) -> BichonResult<()> {
let txn = db.transaction();
let mut txn = txn;
for item in &items {
txn = txn
.insert(M::collection(), item.key(), item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
txn.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
// ─── Upsert ────────────────────────────────────────────────────────────────
pub fn upsert_impl<M: MemDbModel>(db: &MemDb, item: M) -> BichonResult<()> {
let coll = db.collection(M::collection());
coll.upsert(item.key(), &item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub fn batch_upsert_impl<M: MemDbModel>(db: &MemDb, items: Vec<M>) -> BichonResult<()> {
let txn = db.transaction();
let mut txn = txn;
for item in &items {
txn = txn
.upsert(M::collection(), item.key(), item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
txn.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
// ─── Find ──────────────────────────────────────────────────────────────────
pub fn find_impl<M: MemDbModel>(db: &MemDb, key: &str) -> BichonResult<Option<M>> {
let coll = db.collection(M::collection());
coll.get(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
// ─── Filter (replaces secondary key queries) ──────────────────────────────
pub fn filter_impl<M, F>(db: &MemDb, predicate: F) -> BichonResult<Vec<M>>
where
M: MemDbModel,
F: Fn(&M) -> bool + Send + 'static,
{
let coll = db.collection(M::collection());
coll.filter(predicate)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
// ─── Update (read-modify-write under a single spawn_blocking) ─────────────
pub fn update_impl<M: MemDbModel>(
db: &MemDb,
key: &str,
update_fn: impl FnOnce(M) -> BichonResult<M> + Send + 'static,
) -> BichonResult<M> {
let coll = db.collection(M::collection());
let current: M = coll
.get_required(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let updated = update_fn(current)?;
coll.upsert(key, &updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(updated)
}
// ─── Delete ────────────────────────────────────────────────────────────────
pub fn delete_impl<M: MemDbModel>(db: &MemDb, key: &str) -> BichonResult<()> {
let coll = db.collection(M::collection());
let existed = coll
.delete(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if !existed {
return Err(raise_error!(
format!("{} '{}' not found for deletion", M::collection(), key),
ErrorCode::ResourceNotFound
));
}
Ok(())
}
pub fn batch_delete_impl<M: MemDbModel>(db: &MemDb, keys: Vec<String>) -> BichonResult<usize> {
let txn = db.transaction();
let mut txn = txn;
let mut count = 0usize;
for key in &keys {
txn = txn.delete(M::collection(), key.clone());
count += 1;
}
txn.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(count)
}
// ─── List / Count ──────────────────────────────────────────────────────────
pub fn list_all_impl<M: MemDbModel>(db: &MemDb) -> BichonResult<Vec<M>> {
let coll = db.collection(M::collection());
coll.list_all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub fn count_impl<M: MemDbModel>(db: &MemDb) -> BichonResult<usize> {
let coll = db.collection(M::collection());
Ok(coll.count())
}
// ─── Paginate ──────────────────────────────────────────────────────────────
pub fn paginate_impl<M: MemDbModel>(
db: &MemDb,
page: Option<u64>,
page_size: Option<u64>,
desc: Option<bool>,
) -> BichonResult<Paginated<M>> {
let coll = db.collection(M::collection());
let total_items = coll.count() as u64;
let (offset, total_pages) = match (page, page_size) {
(Some(p), Some(s)) if p > 0 && s > 0 => {
let offset = (p - 1) * s;
let total_pages = if total_items > 0 {
(total_items as f64 / s as f64).ceil() as u64
} else {
0
};
(Some(offset), Some(total_pages))
}
(Some(0), _) | (_, Some(0)) => {
return Err(raise_error!(
"'page' and 'page_size' must be greater than 0.".into(),
ErrorCode::InvalidParameter
));
}
_ => (None, None),
};
let all: Vec<M> = coll
.list_all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let items: Vec<M> = match desc {
Some(true) => {
let iter: Vec<M> = all.into_iter().rev().collect();
let skip = offset.unwrap_or(0) as usize;
let take = page_size.unwrap_or(total_items) as usize;
iter.into_iter().skip(skip).take(take).collect()
}
_ => {
let skip = offset.unwrap_or(0) as usize;
let take = page_size.unwrap_or(total_items) as usize;
all.into_iter().skip(skip).take(take).collect()
}
};
Ok(Paginated::new(
page,
page_size,
total_items,
total_pages,
items,
))
}
// ─── Transaction ───────────────────────────────────────────────────────────
/// Execute operations within a single atomic transaction (one WAL entry).
pub fn with_transaction(
db: &MemDb,
f: impl FnOnce(Transaction) -> BichonResult<Transaction> + Send + 'static,
) -> BichonResult<()> {
let txn = db.transaction();
let txn = f(txn)?;
txn.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
+838
View File
@@ -0,0 +1,838 @@
//
// 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::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;
use crate::utils::{compute_content_hash, hex_hash};
use crate::{id, store::envelope::Envelope};
use crate::{raise_error, utc_now};
use async_imap::types::Fetch;
use bytes::Bytes;
use mail_parser::{Address, HeaderName, Message, MessageParser, MimeHeaders};
use tantivy::TantivyDocument;
use tantivy::schema::Facet;
use tracing::error;
use uuid::Uuid;
pub async fn extract_envelope_and_store_it(
fetch: Fetch,
account_id: u64,
mailbox_id: u64,
) -> BichonResult<()> {
let internal_date = fetch
.internal_date()
.map(|d| d.timestamp_millis())
.unwrap_or(0);
let uid = fetch.uid.unwrap_or(0);
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
}
pub async fn extract_envelope_from_eml(
body: &[u8],
account_id: u64,
mailbox_id: u64,
) -> BichonResult<()> {
extract_envelope_core(body, 0, body.len() as u32, 0, account_id, mailbox_id).await
}
pub async fn extract_envelope_from_smtp(
body: &[u8],
account_id: u64,
mailbox_id: u64,
) -> BichonResult<()> {
extract_envelope_core(
body,
0,
body.len() as u32,
utc_now!(),
account_id,
mailbox_id,
)
.await
}
async fn extract_envelope_core(
body: &[u8],
uid: u32,
size: u32,
internal_date: i64,
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(),
ErrorCode::InternalError
)
})?;
let preview_limit = 100;
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
text
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
extract_text(html)
} else {
String::new()
};
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
let preview = if text.chars().count() > preview_limit {
text.chars().take(preview_limit).collect::<String>() + "..."
} else {
text.clone()
};
let body_text = text;
let message_id = message
.message_id()
.map(String::from)
.unwrap_or_else(generate_message_id);
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let mut subject = message.subject().map(String::from).unwrap_or_default();
if subject.contains('\u{FFFD}') {
subject = normalize_subject(message.header_raw(HeaderName::Subject));
}
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let internal_date = if internal_date == 0 {
date
} else {
internal_date
};
let parse_addrs = |addrs: Option<&Address<'_>>| {
addrs
.map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
})
.unwrap_or_default()
};
let bcc = parse_addrs(message.bcc());
let cc = parse_addrs(message.cc());
let to = parse_addrs(message.to());
let from = message
.from()
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
.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 envelope_id = Uuid::new_v4().to_string();
let now = utc_now!();
let mut final_tags = Vec::new();
if let Some(meta_header) = message.header_raw("X-Bichon-Metadata") {
if let Some(bmd) = parse_bichon_metadata(meta_header) {
if let Some(tags) = bmd.tags {
let validated_tags: Result<Vec<String>, _> = tags
.iter()
.map(|tag| {
Facet::from_text(tag)
.map(|_| tag.clone())
.map_err(|e| e)
})
.collect();
match validated_tags {
Ok(valid_list) => {
final_tags = valid_list;
}
Err(e) => {
eprintln!(
"Tag validation failed, ignoring all tags: {:#?}",
e
);
}
}
}
}
}
let attachment_docs: Vec<TantivyDocument> = attachments
.iter()
.filter(|a| !a.inline || a.content_id.is_none())
.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 {
id: envelope_id,
message_id,
account_id,
mailbox_id,
uid,
subject,
preview,
from,
to,
cc,
bcc,
date,
internal_date,
ingest_at: now,
size,
thread_id,
attachment_count,
regular_attachment_count: attachment_docs.len(),
tags: (!final_tags.is_empty()).then_some(final_tags),
account_email: None,
mailbox_name: None,
content_hash: email_content_hash.clone(),
};
// 'attachments' contains both regular and inline attachments
let ea = EnvelopeWithAttachments {
envelope,
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;
}
Ok(())
}
pub fn extract_envelope_from_nested_message(
message: Message<'_>,
account_id: u64,
) -> BichonResult<Envelope> {
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
text
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
extract_text(html)
} else {
String::new()
};
let message_id = message
.message_id()
.map(String::from)
.unwrap_or_else(generate_message_id);
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let mut subject = message.subject().map(String::from).unwrap_or_default();
if subject.contains('\u{FFFD}') {
subject = normalize_subject(message.header_raw(HeaderName::Subject));
}
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let parse_addrs = |addrs: Option<&Address<'_>>| {
addrs
.map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
})
.unwrap_or_default()
};
let bcc = parse_addrs(message.bcc());
let cc = parse_addrs(message.cc());
let to = parse_addrs(message.to());
let from = message
.from()
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
.and_then(|add| add.address)
.unwrap_or_else(|| "unknown".to_string());
let envelope = Envelope {
id: Default::default(),
message_id,
account_id,
mailbox_id: Default::default(),
uid: Default::default(),
subject,
preview: text,
from,
to,
cc,
bcc,
date,
internal_date: Default::default(),
ingest_at: Default::default(),
size: Default::default(),
thread_id,
attachment_count: Default::default(),
regular_attachment_count: Default::default(),
tags: Default::default(),
account_email: Default::default(),
mailbox_name: Default::default(),
content_hash: Default::default(),
};
Ok(envelope)
}
pub fn compute_thread_id(
in_reply_to: Option<String>,
references: Option<Vec<String>>,
message_id: &str,
) -> String {
if in_reply_to.is_some() && references.as_ref().map_or(false, |r| !r.is_empty()) {
return hex_hash(&references.as_ref().unwrap()[0]);
}
hex_hash(message_id)
}
pub fn generate_message_id() -> String {
let ts = utc_now!();
let pid = std::process::id();
format!("<{:016x}.{}.{}@{}>", id!(128), ts, pid, "bichon")
}
pub fn extract_references(message: &Message<'_>) -> Option<Vec<String>> {
match message.references() {
mail_parser::HeaderValue::Text(cow) => Some(vec![cow.to_string()]),
mail_parser::HeaderValue::TextList(vec) => {
Some(vec.iter().map(|cow| cow.to_string()).collect())
}
_ => None,
}
}
pub async fn detach_and_store_attachments(
original_body: &[u8],
message: &Message<'_>,
eml_content_hash: &str,
) -> Vec<AttachmentInfo> {
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
let mut ranges: Vec<_> = message
.attachments()
.map(|att| {
(
att.raw_body_offset() as usize,
att.raw_end_offset() as usize,
att,
)
})
.collect();
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 {
// 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());
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)));
// Replace raw attachment content with a hash-based placeholder
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
stripped_eml.splice(raw_start..raw_end, placeholder.as_bytes().iter().cloned());
} else {
// Invalid range: store a zero-length blob so the consistency
// check passes; reattachment will log a warning for the missing
// blob data but won't panic.
attachments.push((content_hash.clone(), Bytes::new()));
}
let inline = att
.content_disposition()
.map(|d| d.is_inline())
.unwrap_or_else(|| att.content_id().is_some());
let file_type = att
.content_type()
.map(|ct| {
format!(
"{}/{}",
ct.c_type.as_ref(),
ct.c_subtype.as_deref().unwrap_or("")
)
})
.unwrap_or_else(|| "application/octet-stream".to_string());
let has_cid = att.content_id().is_some();
let ext = att
.attachment_name()
.and_then(|n| {
std::path::Path::new(&n)
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
})
.unwrap_or_default();
if !inline || !has_cid {
let decoded_len = att.contents().len();
if decoded_len <= crate::ext::text_extractor::MAX_EXTRACT_BYTES
&& crate::ext::text_extractor::should_try_extract(&file_type, &ext)
{
text_candidates.push(TextCandidate {
content_hash: content_hash.clone(),
file_type: file_type.clone(),
ext: ext.clone(),
bytes: att.contents().to_vec(),
});
}
}
let info = AttachmentInfo {
filename: att.attachment_name().map(|n| n.to_string()),
size: att.contents().len(),
inline,
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 {
email: (eml_content_hash.to_string(), Bytes::from(stripped_eml)),
attachments: Some(attachments),
})
.await;
attachment_infos
}
pub fn reattach_eml_content(
account_id: u64,
envelope_id: String,
) -> BichonResult<(Envelope, Bytes)> {
let e = 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
)
})?;
let restored_eml = BLOB_MANAGER
.get_email(&e.envelope.content_hash)?
.ok_or_else(|| {
raise_error!(
format!(
"Original email content not found: account_id={} envelope_id={} content_hash={}",
account_id, &envelope_id, &e.envelope.content_hash
),
ErrorCode::ResourceNotFound
)
})?;
if !e.envelope.has_any_attachments() {
return Ok((e.envelope, restored_eml));
}
let mut restored_eml = restored_eml.to_vec();
let actual_count = e.attachments.as_ref().map(|a| a.len()).unwrap_or(0);
if e.envelope.attachment_count != actual_count {
return Err(raise_error!(
format!(
"Consistency check failed: envelope.attachment_count ({}) does not match attachments.len ({})",
e.envelope.attachment_count,
actual_count
),
ErrorCode::InternalError
));
}
let mut tasks = Vec::new();
for detail in e.attachments.unwrap() {
let placeholder_str = format!("<<BICHON_DETACH_HASH:{}>>", &detail.content_hash);
let pattern = placeholder_str.as_bytes();
let pattern_len = pattern.len();
let mut search_cursor = 0;
while let Some(pos) = restored_eml[search_cursor..]
.windows(pattern_len)
.position(|window| window == pattern)
{
let absolute_start = search_cursor + pos;
let absolute_end = absolute_start + pattern_len;
tasks.push((
absolute_start,
absolute_end,
detail.content_hash.clone(),
));
search_cursor = absolute_end;
}
}
tasks.sort_by(|a, b| b.0.cmp(&a.0));
for (start, end, hash) in tasks {
if let Some(original_data) = BLOB_MANAGER.get_attachment(&hash)? {
restored_eml.splice(start..end, original_data.iter().cloned());
} else {
error!("[ERROR] Missing attachment blob for hash: {}", hash);
}
}
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).await;
Ok(Bytes::from(raw_body))
}
#[cfg(test)]
mod test {
use html2text::config;
#[test]
fn test_various_html_with_overflow_enabled() {
let cases = [
("<p>Hello World</p>", "Simple paragraph"),
("<h1>Title</h1><p>Content</p>", "Heading + paragraph"),
("<ul><li>Item1</li><li>Item2</li></ul>", "Unordered list"),
(
"<strong>Bold</strong> and <em>italic</em>",
"Inline formatting",
),
(
"<div><span>Nested</span> elements</div>",
"Nested inline elements inside block",
),
(
"<table><tr><td>A</td><td>B</td></tr></table>",
"Simple table",
),
(
"<pre> preformatted text\n line2</pre>",
"Preformatted block",
),
("😃 emoji test", "Wide emoji"),
("<a href=\"#\">link</a>", "Anchor tag"),
(
"<blockquote><p>Quoted text</p></blockquote>",
"Blockquote with paragraph",
),
];
for (html, desc) in cases {
let result = config::plain()
.allow_width_overflow()
.string_from_read(html.as_bytes(), 100);
match result {
Ok(output) => {
println!("✓ Rendered ({}) =>\n{}", desc, output);
}
Err(e) => panic!("Unexpected error for {}: {:?}", desc, e),
}
}
}
/// Verifies that [`super::detach_and_store_attachments`] does not panic
/// when mail-parser reports attachment offsets past the raw body length.
///
/// Regression test for: "range end index X out of range for slice of
/// length Y" panic caused by a malformed email whose attachment
/// `raw_end_offset` exceeded the actual body size.
#[tokio::test]
async fn detach_attachments_bounds_check() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: recipient@example.com\r\n",
"Subject: Test\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/mixed; boundary=\"bnd\"\r\n",
"\r\n",
"--bnd\r\n",
"Content-Type: text/plain\r\n",
"\r\n",
"Hello\r\n",
"--bnd\r\n",
"Content-Type: application/octet-stream\r\n",
"Content-Disposition: attachment; filename=\"test.bin\"\r\n",
"\r\n",
"AAAAABBBBBCCCCCDDDDDEEEEEAAAAABBBBBCCCCCDDDDDEEEEE\r\n",
"--bnd--\r\n",
)
.as_bytes()
.to_vec();
let message = mail_parser::MessageParser::new()
.parse(&raw)
.expect("parse valid MIME message");
assert_eq!(message.attachment_count(), 1);
// Truncate the raw body so the attachment's raw_end_offset lies
// past the body end — exactly the scenario reported by users.
let truncated = &raw[..raw.len() - 20];
assert!(truncated.len() < raw.len());
// Must not panic.
let infos = super::detach_and_store_attachments(
truncated,
&message,
"test_content_hash",
)
.await;
// The attachment count must still match so the consistency check
// in reattach_eml_content doesn't fail later.
assert_eq!(infos.len(), 1);
}
}
+15
View File
@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
use crate::base64_decode;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct BichonMetadata {
pub account_email: Option<String>,
pub mailbox_name: Option<String>,
pub tags: Option<Vec<String>>,
}
pub fn parse_bichon_metadata(header_value: &str) -> Option<BichonMetadata> {
let decoded = base64_decode!(header_value.trim());
serde_json::from_slice(&decoded).ok()
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -17,4 +17,5 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod extractor;
pub mod meta;
pub mod utils;
@@ -1,3 +1,21 @@
//
// 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 mail_parser::parsers::MessageStream;
use regex::{Captures, Regex};
@@ -72,43 +90,86 @@ pub fn normalize_subject(raw_subject: Option<&str>) -> String {
#[cfg(test)]
mod tests {
use crate::modules::envelope::utils::merge_contiguous_encoded_words;
use crate::envelope::utils::{merge_contiguous_encoded_words, normalize_subject};
// ── merge_contiguous_encoded_words ──────────────────────────────
#[tokio::test]
async fn test3() {
#[test]
fn merge_basic_utf8_b() {
let s = "Hello =?UTF-8?B?SGVsbG8=?= =?UTF-8?B?V29ybGQ=?= !!!";
assert_eq!(
merge_contiguous_encoded_words(s),
"Hello =?UTF-8?B?SGVsbG8=V29ybGQ=?= !!!"
);
}
#[test]
fn merge_three_blocks() {
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= =?UTF-8?B?Qw==?=";
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?B?QQ==Qg==Qw==?="
);
}
#[test]
fn merge_noncontiguous_blocks() {
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= test =?UTF-8?B?Qw==?= =?UTF-8?B?RA==?=";
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?B?QQ==Qg==?= test =?UTF-8?B?Qw==RA==?="
);
}
#[test]
fn reject_different_charsets() {
let s = "=?UTF-8?B?QQ==?= =?GBK?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), s);
}
#[test]
fn reject_different_encodings() {
let s = "=?UTF-8?B?QQ==?= =?UTF-8?Q?Qg?=";
assert_eq!(merge_contiguous_encoded_words(s), s);
}
#[test]
fn merge_case_insensitive_encoding() {
let s = "=?UTF-8?b?QQ==?= =?UTF-8?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
}
#[test]
fn single_encoded_word_unchanged() {
let s = "Hello =?UTF-8?B?SGVsbG8=?= !!!";
assert_eq!(merge_contiguous_encoded_words(s), s);
}
#[test]
fn multiple_spaces_between_words() {
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
}
#[test]
fn plain_subject_line() {
let s = "Just a normal subject line";
assert_eq!(merge_contiguous_encoded_words(s), s);
}
#[test]
fn merge_quoted_printable() {
let s = "=?UTF-8?Q?Hello_?= =?UTF-8?Q?World?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?Q?Hello_World?=");
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?Q?Hello_World?="
);
}
// ── normalize_subject ───────────────────────────────────────────
#[test]
fn normalize_subject_none() {
assert_eq!(normalize_subject(None), "");
}
}
+44
View File
@@ -0,0 +1,44 @@
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(u32)]
pub enum ErrorCode {
// Client-side errors (1000010999)
InvalidParameter = 10000,
MissingConfiguration = 10020,
Incompatible = 10030,
PayloadTooLarge = 10070,
RequestTimeout = 10080,
MethodNotAllowed = 10090,
// Authentication and authorization errors (2000020999)
PermissionDenied = 20000,
AccountDisabled = 20010,
Forbidden = 20020,
OAuth2ItemDisabled = 20050,
MissingRefreshToken = 20060,
// Resource errors (3000030999)
ResourceNotFound = 30000,
TooManyRequest = 30020,
AlreadyExists = 30030,
// Network connection errors (4000040999)
NetworkError = 40000,
ConnectionTimeout = 40010,
ConnectionPoolTimeout = 40020,
HttpResponseError = 40030,
// Mail service errors (5000050999)
ImapCommandFailed = 50000,
ImapAuthenticationFailed = 50010,
ImapUnexpectedResult = 50020,
AutoconfigFetchFailed = 50060,
// Internal system errors (7000070999)
InternalError = 70000,
UnhandledPoemError = 70010,
}
impl ErrorCode {
pub fn to_u32(&self) -> u32 {
*self as u32
}
}
+19
View File
@@ -0,0 +1,19 @@
use snafu::{Location, Snafu};
use crate::error::code::ErrorCode;
pub mod code;
#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum BichonError {
#[snafu(display("{message}"))]
Generic {
message: String,
#[snafu(implicit)]
location: Location,
code: ErrorCode,
},
}
pub type BichonResult<T, E = BichonError> = std::result::Result<T, E>;
+86
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);
}
+29
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;
+75
View File
@@ -0,0 +1,75 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Attachment text extraction extension point.
//
// Community edition: NoopExtractor — no attachments are text-indexed.
// Pro edition: PdfExtractor — extracts text from PDF, Word, etc.
//
// Used in: crates/core/src/envelope/extractor.rs
use std::sync::{LazyLock, RwLock};
pub struct ExtractedText {
pub text: String,
pub page_count: Option<u32>,
pub is_ocr: bool,
}
pub trait AttachmentTextExtractor: Send + Sync {
/// Returns None if this extractor doesn't handle the file type.
/// Returns Some(ExtractedText) if text was successfully extracted.
fn extract(&self, content_type: &str, ext: &str, bytes: &[u8]) -> Option<ExtractedText>;
}
/// Default — all attachments are skipped.
struct NoopExtractor;
impl AttachmentTextExtractor for NoopExtractor {
fn extract(&self, _ct: &str, _ext: &str, _bytes: &[u8]) -> Option<ExtractedText> {
None
}
}
static EXTRACTOR: LazyLock<RwLock<Box<dyn AttachmentTextExtractor>>> =
LazyLock::new(|| RwLock::new(Box::new(NoopExtractor)));
/// Called by Pro/Enterprise at startup to replace the noop default.
pub fn set_extractor(extractor: Box<dyn AttachmentTextExtractor>) {
*EXTRACTOR.write().unwrap() = extractor;
}
/// Attachments larger than this are skipped (10 MiB). Avoids excessive memory
/// and CPU cost for huge files whose text is rarely useful for search.
pub const MAX_EXTRACT_BYTES: usize = 10 * 1024 * 1024;
/// Quick pre-filter: returns true for file types where text extraction may
/// produce useful results. Avoids cloning attachment bytes for images, videos,
/// archives, etc. when no registered extractor would handle them.
pub fn should_try_extract(content_type: &str, ext: &str) -> bool {
matches!(
ext,
"pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
| "txt" | "rtf" | "odt" | "ods" | "odp"
) || content_type.starts_with("text/")
}
/// Called by the attachment pipeline during IMAP sync.
/// The caller should wrap this in spawn_blocking for CPU-bound extraction.
pub fn extract_text(content_type: &str, ext: &str, bytes: &[u8]) -> Option<ExtractedText> {
EXTRACTOR.read().unwrap().extract(content_type, ext, bytes)
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,9 +16,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::code::ErrorCode;
use crate::modules::imap::session::SessionStream;
use crate::{modules::error::BichonResult, raise_error};
use crate::error::code::ErrorCode;
use crate::imap::session::SessionStream;
use crate::{error::BichonResult, raise_error};
use async_imap::types::Capability;
use async_imap::{types::Capabilities, Session};
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,14 +16,14 @@
// 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::modules::account::entity::Encryption;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::imap::session::SessionStream;
use crate::modules::imap::stats::StatsWrapper;
use crate::modules::utils::net::establish_tcp_connection_with_timeout;
use crate::modules::utils::net::establish_tls_connection;
use crate::modules::utils::tls::establish_tls_stream;
use crate::account::entity::Encryption;
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::imap::session::SessionStream;
use crate::imap::stats::StatsWrapper;
use crate::utils::net::establish_tcp_connection_with_timeout;
use crate::utils::net::establish_tls_connection;
use crate::utils::tls::establish_tls_stream;
use crate::raise_error;
use async_imap::Client as ImapClient;
use async_imap::Session as ImapSession;
+654
View File
@@ -0,0 +1,654 @@
//
// 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::migration::AccountModel;
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;
use crate::imap::session::SessionStream;
use crate::raise_error;
use crate::{error::BichonResult, imap::manager::ImapConnectionManager};
use async_imap::types::Name;
use async_imap::Session;
use futures::TryStreamExt;
use std::collections::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)";
pub struct ImapExecutor;
impl ImapExecutor {
pub async fn list_all_mailboxes(
session: &mut Session<Box<dyn SessionStream>>,
) -> BichonResult<Vec<Name>> {
let list = session
.list(Some(""), Some("*"))
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
let result = list
.try_collect::<Vec<Name>>()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
Ok(result)
}
pub async fn uid_search(
session: &mut Session<Box<dyn SessionStream>>,
mailbox_name: &str,
query: &str,
) -> BichonResult<HashSet<u32>> {
session
.examine(mailbox_name)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
let result = session
.uid_search(query)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
Ok(result)
}
pub async fn append(
session: &mut Session<Box<dyn SessionStream>>,
mailbox_name: impl AsRef<str>,
flags: Option<&str>,
internaldate: Option<&str>,
content: impl AsRef<[u8]>,
) -> BichonResult<()> {
session
.append(mailbox_name, flags, internaldate, content)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
}
/// Fetches new mail for a mailbox.
///
/// When `before` is `Some(date)`, a two-step approach is used:
/// `UID SEARCH` to find matching UIDs (standard IMAP), then batch `UID FETCH`
/// for the specific UIDs. When `before` is `None`, a direct ranged
/// `UID FETCH {start}:*` is issued and results are streamed.
///
/// Returns `Ok(Some(max_uid))` with the highest UID fetched, or `Ok(None)`
/// if no new mail was found.
pub async fn fetch_new_mail(
session: &mut Session<Box<dyn SessionStream>>,
account: &AccountModel,
mailbox: &MailBox,
start_uid: u64,
before: Option<&str>,
token: CancellationToken,
) -> BichonResult<Option<u32>> {
assert!(start_uid > 0, "start_uid must be greater than 0");
session
.examine(&mailbox.encoded_name())
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
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,
}
}
/// Two-step approach for date-filtered incremental fetch: UID SEARCH first,
/// then batch UID FETCH for matching UIDs. Uses standard IMAP syntax that
/// works across all compliant servers.
async fn fetch_new_mail_with_before(
session: &mut Session<Box<dyn SessionStream>>,
account: &AccountModel,
mailbox: &MailBox,
start_uid: u64,
date: &str,
token: CancellationToken,
) -> BichonResult<Option<u32>> {
let query = format!("UID {start_uid}:* BEFORE {date}");
info!(
"[account {}][mailbox {}] fetch_new_mail: UID SEARCH {}",
account.id, mailbox.name, query
);
let results = session.uid_search(&query).await.map_err(|e| {
let err_msg = format!("UID SEARCH failed in [{}]: {:#?}", mailbox.name, e);
let _ = DownloadState::append_session_error(account.id, err_msg);
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
})?;
if results.is_empty() {
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
Some("No new emails found.".into()),
)?;
return Ok(None);
}
let mut uid_vec: Vec<u32> = results.into_iter().collect();
uid_vec.sort();
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() {
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
));
}
let processed = Self::uid_batch_retrieve_emails(
session,
account.id,
mailbox.id,
&batch.0,
account.max_email_size_bytes,
token.clone(),
)
.await?;
count += processed;
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
planned,
count,
FolderStatus::Downloading,
None,
)?;
}
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
count,
count,
FolderStatus::Success,
None,
)?;
Ok(max_uid)
}
/// Direct ranged UID FETCH without date filtering. Streams results from
/// the server in a single IMAP round-trip.
async fn fetch_new_mail_range(
session: &mut Session<Box<dyn SessionStream>>,
account: &AccountModel,
mailbox: &MailBox,
start_uid: u64,
token: CancellationToken,
) -> BichonResult<Option<u32>> {
let uid_range = format!("{start_uid}:*");
info!(
"[account {}][mailbox {}] fetch_new_mail: direct UID FETCH {}",
account.id, mailbox.name, uid_range
);
let mut stream = session
.uid_fetch(&uid_range, BODY_FETCH_COMMAND)
.await
.map_err(|e| {
let err_msg = format!("UID FETCH failed in [{}]: {:#?}", mailbox.name, e);
let _ = DownloadState::append_session_error(account.id, err_msg);
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
})?;
let mut count = 0u64;
let mut skipped = 0u64;
let mut max_uid: Option<u32> = None;
let size_limit = account.max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
while let Some(fetch) = stream
.try_next()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
{
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(
session: &mut Session<Box<dyn SessionStream>>,
account_id: u64,
mailbox_id: u64,
total: u64,
page: u64,
page_size: u64,
encoded_mailbox_name: &str,
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");
// 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 {})",
encoded_mailbox_name, sequence_set, page, page_size
);
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), ErrorCode::ImapCommandFailed)
})?;
let mut uids: Vec<u32> = Vec::new();
while let Some(fetch) = size_stream.try_next().await.map_err(|e| {
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
})? {
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))?;
let mut count = 0;
while let Some(fetch) = body_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
));
}
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), ErrorCode::ImapCommandFailed)
})?;
let mut uids: Vec<u32> = Vec::new();
while let Some(fetch) = size_stream.try_next().await.map_err(|e| {
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
})? {
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))?;
let mut count = 0u64;
while let Some(fetch) = body_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?;
count += 1;
}
Ok(count)
}
/// 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>>,
encoded_mailbox_name: &str,
uid: u32,
) -> BichonResult<Vec<u8>> {
session
.examine(encoded_mailbox_name)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
let mut stream = session
.uid_fetch(uid.to_string(), BODY_FETCH_COMMAND)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
let fetch = stream
.try_next()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
.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), ErrorCode::ImapCommandFailed))?
// .is_some()
// {}
Ok(body)
}
pub async fn create_connection(
account_id: u64,
) -> BichonResult<Session<Box<dyn SessionStream>>> {
ImapConnectionManager::build(account_id).await
}
}
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
}
#[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);
}
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,38 +16,23 @@
// 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::modules::account::dispatcher::STATUS_DISPATCHER;
use crate::modules::account::entity::AuthType;
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::imap::capabilities::{
capability_to_string, check_capabilities, fetch_capabilities,
};
use crate::modules::imap::client::Client;
use crate::modules::imap::oauth2::OAuth2;
use crate::modules::imap::session::SessionStream;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::account::entity::AuthType;
use crate::account::migration::{AccountModel, AccountType};
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::imap::capabilities::{capability_to_string, check_capabilities, fetch_capabilities};
use crate::imap::client::Client;
use crate::imap::oauth2::OAuth2;
use crate::imap::session::SessionStream;
use crate::oauth2::token::OAuth2AccessToken;
use crate::{bichon_version, decrypt, raise_error};
use async_imap::Session;
use tracing::error;
use tracing::{error, warn};
#[derive(Debug)]
pub struct ImapConnectionManager {
pub account_id: u64,
}
pub struct ImapConnectionManager;
impl ImapConnectionManager {
pub fn new(account_id: u64) -> Self {
Self { account_id }
}
pub async fn fetch_account(&self) -> BichonResult<AccountModel> {
// Fetch the account entity in non-test environment
AccountModel::get(self.account_id).await
}
async fn create_client(&self, account: &AccountModel) -> BichonResult<Client> {
async fn create_client(account: &AccountModel) -> BichonResult<Client> {
assert_eq!(account.account_type, AccountType::IMAP);
let imap = account.imap.as_ref().unwrap();
Client::connection(
@@ -61,13 +46,12 @@ impl ImapConnectionManager {
}
async fn authenticate(
&self,
client: Client,
account: &AccountModel,
) -> BichonResult<Session<Box<dyn SessionStream>>> {
assert_eq!(account.account_type, AccountType::IMAP);
let imap = account.imap.as_ref().unwrap();
let username = account.name.clone().unwrap_or(account.email.clone());
let login_name = account.login_name.clone().unwrap_or(account.email.clone());
match &imap.auth.auth_type {
AuthType::Password => {
let password = &imap.auth.password.clone().ok_or_else(|| {
@@ -78,16 +62,16 @@ impl ImapConnectionManager {
})?;
let password = decrypt!(&password)?;
client.login(&username, &password).await.map_err(|e| {
client.login(&login_name, &password).await.map_err(|e| {
error!(
"IMAP password auth failed for username '{}': {}",
username, e
login_name, e
);
e
})
}
AuthType::OAuth2 => {
let record = OAuth2AccessToken::get(self.account_id).await?;
let record = OAuth2AccessToken::get(account.id)?;
let access_token = record.and_then(|r| r.access_token).ok_or_else(|| {
raise_error!(
"Imap auth type is OAuth2, but OAuth2 authorization is not yet complete."
@@ -96,46 +80,36 @@ impl ImapConnectionManager {
)
})?;
client
.authenticate(OAuth2::new(username.clone(), access_token))
.authenticate(OAuth2::new(login_name.clone(), access_token))
.await
.map_err(|e| {
error!("IMAP OAuth2 auth failed for username '{}': {}", username, e);
error!(
"IMAP OAuth2 auth failed for username '{}': {}",
login_name, e
);
e
})
}
}
}
pub async fn build(&self) -> BichonResult<Session<Box<dyn SessionStream>>> {
let account = self.fetch_account().await?;
let client = match self.create_client(&account).await {
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
);
STATUS_DISPATCHER
.append_error(
self.account_id,
format!("imap client connect error: {:#?}", error),
)
.await;
return Err(error);
}
};
let mut session = match self.authenticate(client, &account).await {
let mut session = match Self::authenticate(client, &account).await {
Ok(session) => session,
Err(error) => {
error!("Failed to authenticate IMAP session: {:#?}", error);
STATUS_DISPATCHER
.append_error(
self.account_id,
format!("imap client authenticate error: {:#?}", error),
)
.await;
return Err(error);
}
};
@@ -143,39 +117,27 @@ impl ImapConnectionManager {
match fetch_capabilities(&mut session).await {
Ok(capabilities) => {
let to_save: Vec<String> = capabilities.iter().map(capability_to_string).collect();
AccountModel::update_capabilities(self.account_id, to_save).await?;
AccountModel::update_capabilities(account_id, to_save)?;
if let Err(error) = check_capabilities(&capabilities) {
error!("Failed to check IMAP capabilities: {:#?}", error);
STATUS_DISPATCHER
.append_error(
self.account_id,
format!("imap client check capabilities error: {:#?}", error),
)
.await;
return Err(error);
}
if capabilities.has_str("ID") || capabilities.has_str("id") {
session
if let Err(e) = session
.id([
("name", Some("bichon")),
("version", Some(bichon_version!())),
("vendor", Some("rustmailer")),
])
.await
.map_err(|e| {
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
})?;
{
warn!("IMAP ID command failed (ignored): {:#?}", e);
}
}
}
Err(error) => {
error!("Failed to fetch IMAP capabilities: {:#?}", error);
STATUS_DISPATCHER
.append_error(
self.account_id,
format!("imap client fetch capabilities error: {:#?}", error),
)
.await;
return Err(error);
}
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -22,7 +22,6 @@ pub mod client;
pub mod executor;
pub mod manager;
pub mod oauth2;
pub mod pool;
pub mod session;
pub mod stats;
#[cfg(test)]
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -23,7 +23,7 @@ use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use crate::modules::imap::session::SessionStream;
use crate::imap::session::SessionStream;
pub struct StatsWrapper<T> {
inner: T,
+203
View File
@@ -0,0 +1,203 @@
//
// 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 mail_parser::{parsers::MessageStream, MessageParser, MimeHeaders};
use crate::{
base64_encode_url_safe,
{account::entity::Encryption, imap::client::Client},
};
#[tokio::test]
async fn testxx() {
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
.unwrap();
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None, false)
.await
.unwrap();
let mut session = client.login("xx@zohomail.com", "xxx").await.unwrap();
session.select("INBOX").await.unwrap();
let result = session.uid_search("LARGER 1024").await.unwrap();
println!("{:#?}", result);
}
#[tokio::test]
async fn test1() {
let path = r"C:\Users\polly\Downloads\test.eml";
let eml_data = std::fs::read(path).unwrap();
let input = base64_encode_url_safe!(eml_data);
let message = MessageParser::default().parse(&input).unwrap();
let parts = message.parts;
for part in parts {
println!("{}", part.is_message());
println!("{}", part.is_multipart());
}
}
#[tokio::test]
async fn test2() {
const MESSAGE: &str = r#"From: Art Vandelay <art@vandelay.com> (Vandelay Industries)
X-Gmail-Labels: =?UTF-8?Q?Archiv=C3=A9s,Envoy=C3=A9?=
To: "Colleagues": "James Smythe" <james@vandelay.com>; Friends:
jane@example.com, =?UTF-8?Q?John_Sm=C3=AEth?= <john@example.com>;
Date: Sat, 20 Nov 2021 14:22:01 -0800
Subject: =?utf-8?B?SnVzdCAxNSBkYXlzIGxlZnQgdG8gdmlzaXQgTkFSTklBISDinYTvuI/wn462?=
Content-Type: multipart/mixed; boundary="festivus";
--festivus
Content-Type: text/html; charset="us-ascii"
Content-Transfer-Encoding: base64
PGh0bWw+PHA+SSB3YXMgdGhpbmtpbmcgYWJvdXQgcXVpdHRpbmcgdGhlICZsZHF1bztle
HBvcnRpbmcmcmRxdW87IHRvIGZvY3VzIGp1c3Qgb24gdGhlICZsZHF1bztpbXBvcnRpbm
cmcmRxdW87LDwvcD48cD5idXQgdGhlbiBJIHRob3VnaHQsIHdoeSBub3QgZG8gYm90aD8
gJiN4MjYzQTs8L3A+PC9odG1sPg==
--festivus
Content-Type: message/rfc822
From: "Cosmo Kramer" <kramer@kramerica.com>
Subject: Exporting my book about coffee tables
Content-Type: multipart/mixed; boundary="giddyup";
--giddyup
Content-Type: text/plain; charset="utf-16"
Content-Transfer-Encoding: quoted-printable
=FF=FE=0C!5=D8"=DD5=D8)=DD5=D8-=DD =005=D8*=DD5=D8"=DD =005=D8"=
=DD5=D85=DD5=D8-=DD5=D8,=DD5=D8/=DD5=D81=DD =005=D8*=DD5=D86=DD =
=005=D8=1F=DD5=D8,=DD5=D8,=DD5=D8(=DD =005=D8-=DD5=D8)=DD5=D8"=
=DD5=D8=1E=DD5=D80=DD5=D8"=DD!=00
--giddyup
Content-Type: image/gif; name*1="about "; name*0="Book ";
name*2*=utf-8''%e2%98%95 tables.gif
Content-Transfer-Encoding: Base64
Content-Disposition: attachment
R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
--giddyup--
--festivus--
"#;
let message = MessageParser::default().parse(MESSAGE).unwrap();
let header = message.header_raw("X-Gmail-Labels").unwrap().as_bytes();
let data = MessageStream::new(header)
.parse_unstructured()
.unwrap_text()
.to_string();
println!("{}", data);
// RFC2047 support for encoded text in message readers
//println!("{}", message.subject().unwrap());
}
#[tokio::test]
async fn test_bulk_attachment_stripping_blake3() {
let path = r"C:\Users\polly\Downloads\test666.eml";
let input = std::fs::read(path).expect("Failed to read EML file");
// 1. Initial Parse
let message = MessageParser::default()
.parse(&input)
.expect("Failed to parse EML");
// 2. Collect and cast types explicitly
// We map the u32 offsets to usize here to satisfy the Vec<(usize, usize, ...)> requirement
let mut attachments: Vec<(usize, usize, Vec<u8>)> = message
.attachments()
.map(|att| {
(
att.raw_body_offset() as usize,
att.raw_end_offset() as usize,
att.contents().to_vec(),
)
})
.collect();
// 3. Sort by offset descending (BACK TO FRONT)
// This ensures that modifying the file length doesn't invalidate earlier offsets
attachments.sort_by(|a, b| b.0.cmp(&a.0));
let mut modified_eml = input.clone();
println!(
"Processing {} attachments in reverse order...",
attachments.len()
);
for (start, end, raw_content) in attachments {
// Calculate BLAKE3 Hash
let hash = blake3::hash(&raw_content).to_hex().to_string();
let placeholder = format!("STRIPPED_BLAKE3:{}", hash);
let placeholder_bytes = placeholder.as_bytes();
// Perform the byte surgery
let mut new_buffer =
Vec::with_capacity(modified_eml.len() - (end - start) + placeholder_bytes.len());
new_buffer.extend_from_slice(&modified_eml[..start]);
new_buffer.extend_from_slice(placeholder_bytes);
new_buffer.extend_from_slice(&modified_eml[end..]);
modified_eml = new_buffer;
println!(
"Stripped attachment at offset {}. New hash: {}",
start, hash
);
}
std::fs::write("test.eml", &modified_eml).unwrap();
// 4. Final Verification
let final_message = MessageParser::default().parse(&modified_eml).unwrap();
println!("\n--- Verification Report ---");
for (i, att) in final_message.attachments().enumerate() {
let content = String::from_utf8_lossy(att.contents());
println!(
"Part [{}]: {}, Content: {}",
i,
att.attachment_name().unwrap_or("unknown"),
content
);
assert!(content.contains("STRIPPED_BLAKE3:"));
}
println!("✅ All attachments replaced successfully from back to front.");
}
#[tokio::test]
async fn test_667() {
let path = r"C:\Users\polly\Downloads\test777.eml";
let input = std::fs::read(path).expect("Failed to read EML file");
let message = MessageParser::default()
.parse(&input)
.expect("Failed to parse EML");
for att in message.attachments() {
println!("name: {:#?}", att.attachment_name());
println!("content_type: {:#?}", att.content_type());
println!("is_message: {:#?}", att.is_message());
println!("content_disposition: {:#?}", att.content_disposition());
println!(
"content_transfer_encoding: {:#?}",
att.content_transfer_encoding()
);
println!("content_id: {:#?}", att.content_id());
}
}
@@ -1,24 +1,42 @@
use poem_openapi::Object;
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use tantivy::doc;
use crate::{
base64_decode_url_safe,
modules::{
{
account::migration::{AccountModel, AccountType},
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
envelope::extractor::extract_envelope_from_eml,
error::{code::ErrorCode, BichonResult},
indexer::{
manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
schema::SchemaTools,
},
error::{BichonResult, code::ErrorCode},
utils::create_hash,
},
raise_error,
};
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
/// Skip individual emails larger than this after decoding (100 MB).
const MAX_SINGLE_EML_BYTES: usize = 100 * 1024 * 1024;
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct BatchEmlRequest {
pub account_id: u64,
pub mail_folder: String,
@@ -26,7 +44,8 @@ pub struct BatchEmlRequest {
pub emls: Vec<String>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[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 index: usize,
@@ -34,7 +53,8 @@ pub struct FailedEmlDetail {
pub error_message: String,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[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
pub total: usize,
@@ -49,8 +69,8 @@ pub struct BatchEmlResult {
pub struct ImportEmls;
impl ImportEmls {
pub async fn do_import(request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
let account = AccountModel::check_account_exists(request.account_id).await?;
pub async fn do_import(mut request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
let account = AccountModel::check_account_exists(request.account_id)?;
if !account.enabled {
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
@@ -58,7 +78,7 @@ impl ImportEmls {
let mailbox_id = match account.account_type {
AccountType::IMAP => {
let all_mailboxes = MailBox::list_all(account.id).await?;
let all_mailboxes = MailBox::list_all(account.id)?;
let mailbox = all_mailboxes.into_iter().find(|m| m.name == request.mail_folder);
match mailbox {
@@ -85,21 +105,22 @@ 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
MailBox::batch_upsert(&[mailbox]).await?;
MailBox::batch_upsert(&[mailbox])?;
mailbox_id
},
};
let fields = SchemaTools::eml_fields();
let account_id = account.id;
let mut success_count = 0;
let mut failed_details: Vec<FailedEmlDetail> = Vec::new(); // Store failure details
let total = request.emls.len();
for (index, eml_base64) in request.emls.into_iter().enumerate() {
let mut index: usize = 0;
while let Some(eml_base64) = request.emls.pop() {
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
Ok(bytes) => bytes,
Err(e) => {
@@ -110,12 +131,31 @@ impl ImportEmls {
index,
error_message: error_msg,
});
index += 1;
continue;
}
};
// eml_base64 string dropped here — frees base64 memory before parsing
let envelope = match extract_envelope_from_eml(&decoded, account_id, mailbox_id) {
Ok(env) => env,
if decoded.len() > MAX_SINGLE_EML_BYTES {
let size_mb = decoded.len() as f64 / 1024.0 / 1024.0;
let error_msg = format!(
"Email at index {} is {:.1} MB (limit 50 MB). Skipping.",
index, size_mb,
);
tracing::warn!("{}", error_msg);
failed_details.push(FailedEmlDetail {
index,
error_message: error_msg,
});
index += 1;
continue;
}
match extract_envelope_from_eml(&decoded, account_id, mailbox_id).await {
Ok(_) => {
success_count += 1;
},
Err(e) => {
let error_msg = format!(
"Failed to extract envelope from EML at index {}: {:?}",
@@ -126,27 +166,11 @@ impl ImportEmls {
index,
error_message: error_msg,
});
index += 1;
continue;
}
};
ENVELOPE_INDEX_MANAGER
.add_document(envelope.id, envelope.to_document(mailbox_id).unwrap())
.await;
EML_INDEX_MANAGER
.add_document(
envelope.id,
doc!(
fields.f_id => envelope.id,
fields.f_account_id => account_id,
fields.f_mailbox_id => mailbox_id,
fields.f_eml => decoded
),
)
.await;
success_count += 1;
index += 1;
}
let failed_count = failed_details.len();
+25
View File
@@ -0,0 +1,25 @@
pub mod account;
pub mod ext;
pub mod admin;
pub mod autoconfig;
pub mod cache;
pub mod common;
pub mod context;
pub mod dashboard;
pub mod database;
pub mod envelope;
pub mod error;
pub mod imap;
pub mod import;
pub mod logger;
pub mod mailbox;
pub mod message;
pub mod migrate;
pub mod oauth2;
pub mod settings;
pub mod store;
pub mod tasks;
pub mod token;
pub mod users;
pub mod utils;
pub mod version;
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,10 +16,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::logger::{validate_log_level, LocalTimer};
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::dir::DATA_DIR_MANAGER;
use crate::logger::LocalTimer;
use crate::settings::cli::SETTINGS;
use crate::settings::dir::DATA_DIR_MANAGER;
use std::sync::OnceLock;
use tracing::level_filters::LevelFilter;
use tracing::Level;
@@ -30,9 +29,7 @@ use tracing_subscriber::layer::SubscriberExt;
pub static LOG_WORKER_GUARD: OnceLock<Vec<WorkerGuard>> = OnceLock::new();
pub fn setup_file_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
validate_log_level(&SETTINGS.bichon_log_level);
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
pub fn setup_file_logger(level: Level) -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
let with_ansi = SETTINGS.bichon_ansi_logs;
let (server_nonb, server_guard) = server_log_writer();
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
@@ -16,11 +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 crate::modules::logger::file::setup_file_logger;
use crate::modules::settings::cli::SETTINGS;
use crate::logger::file::setup_file_logger;
use crate::settings::cli::SETTINGS;
use chrono::Local;
use std::process;
use tracing::Level;
use tracing_log::LogTracer;
use tracing_subscriber::fmt::{format::Writer, time::FormatTime};
mod file;
@@ -34,16 +35,18 @@ impl FormatTime for LocalTimer {
}
pub fn initialize_logging() {
let level = validate_log_level(&SETTINGS.bichon_log_level);
if matches!(level, Level::DEBUG) || matches!(level, Level::TRACE) {
LogTracer::init().unwrap();
}
if SETTINGS.bichon_log_to_file {
setup_file_logger().unwrap();
setup_file_logger(level).unwrap();
} else {
setup_stdout_logger().unwrap();
setup_stdout_logger(level).unwrap();
}
}
fn setup_stdout_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
validate_log_level(&SETTINGS.bichon_log_level);
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
fn setup_stdout_logger(level: Level) -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
let with_ansi = SETTINGS.bichon_ansi_logs;
let format = tracing_subscriber::fmt::format()
@@ -61,13 +64,16 @@ fn setup_stdout_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultErro
tracing::subscriber::set_global_default(subscriber)
}
fn validate_log_level(value: &String) {
if value.parse::<Level>().is_err() {
eprintln!(
"Invalid log level specified. Use one of: error, warn, info, debug, trace.
The log level you currently specified is 'rustmailer_log_level'='{}'",
value
);
process::exit(1);
fn validate_log_level(value: &String) -> Level {
match value.parse::<Level>() {
Ok(level) => level,
Err(_) => {
eprintln!(
"Invalid log level specified. Use one of: error, warn, info, debug, trace.
The log level you currently specified is 'rustmailer_log_level'='{}'",
value
);
process::exit(1);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
//
// 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::{
cache::imap::mailbox::MailBox,
error::BichonResult,
store::tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
};
pub async fn delete_mailbox_impl(account_id: u64, mailbox_id: u64) -> BichonResult<()> {
let mailbox = MailBox::get(mailbox_id)?;
let name = mailbox.name;
let delimiter = mailbox.delimiter.unwrap_or("/".to_owned());
let all_mailboxes = MailBox::list_all(account_id)?;
let prefix = format!("{}{}", name, delimiter);
let ids_to_delete: Vec<u64> = all_mailboxes
.into_iter()
.filter(|m| m.id == mailbox_id || m.name.starts_with(&prefix))
.map(|m| m.id)
.collect();
if ids_to_delete.is_empty() {
return Ok(());
}
for id in &ids_to_delete {
MailBox::delete(*id)?;
}
ENVELOPE_MANAGER
.delete_mailbox_envelopes(account_id, ids_to_delete.clone())
.await?;
ATTACHMENT_MANAGER
.delete_mailbox_attachments(account_id, ids_to_delete.clone())
.await?;
Ok(())
}
+231
View File
@@ -0,0 +1,231 @@
//
// 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::migration::{AccountModel, AccountType};
use crate::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
use crate::cache::imap::mailbox_cache::{self, FetchStatus};
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::imap::executor::ImapExecutor;
use crate::imap::session::SessionStream;
use crate::raise_error;
use crate::utils::create_hash;
use async_imap::types::Name;
use async_imap::Session;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct MailboxListResponse {
pub mailboxes: Vec<MailBox>,
/// "ready" | "fetching" | "error"
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub examined: Option<usize>,
pub total: Option<usize>,
}
pub async fn get_account_mailboxes(
account_id: u64,
remote: bool,
) -> BichonResult<MailboxListResponse> {
let account = AccountModel::check_account_exists(account_id)?;
if remote {
if matches!(account.account_type, AccountType::IMAP) {
return Ok(remote_mailboxes(account_id).await);
} else {
return Err(raise_error!(
"The 'remote' option can only be used with IMAP accounts.".into(),
ErrorCode::InvalidParameter
));
}
} else {
let mailboxes = MailBox::list_all(account_id)?;
return Ok(MailboxListResponse {
mailboxes,
status: "ready".into(),
error: None,
examined: None,
total: None,
});
}
}
fn make_pending_response(status: &FetchStatus, error: Option<String>) -> MailboxListResponse {
let (examined, total) = match status {
FetchStatus::Fetching { examined, total } => (Some(*examined), Some(*total)),
_ => (None, None),
};
MailboxListResponse {
mailboxes: vec![],
status: match status {
FetchStatus::Ready => "ready".into(),
FetchStatus::Fetching { .. } => "fetching".into(),
FetchStatus::Error(_) => "error".into(),
},
error,
examined,
total,
}
}
async fn remote_mailboxes(account_id: u64) -> MailboxListResponse {
// Cache hit
if let Some(cached) = mailbox_cache::get(account_id).await {
return MailboxListResponse {
mailboxes: cached,
status: "ready".into(),
error: None,
examined: None,
total: None,
};
}
match mailbox_cache::fetch_status(account_id).await {
Some(status @ FetchStatus::Fetching { .. }) => {
return make_pending_response(&status, None);
}
Some(FetchStatus::Error(err)) => {
mailbox_cache::clear_fetch_state(account_id).await;
return MailboxListResponse {
mailboxes: vec![],
status: "error".into(),
error: Some(err),
examined: None,
total: None,
};
}
_ => {}
}
// No cache, no fetch in progress — start background fetch
mailbox_cache::set_fetching(account_id).await;
spawn_fetch_task(account_id);
MailboxListResponse {
mailboxes: vec![],
status: "fetching".into(),
error: None,
examined: Some(0),
total: Some(0),
}
}
fn spawn_fetch_task(account_id: u64) {
tokio::spawn(async move {
match fetch_remote_with_progress(account_id).await {
Ok(mailboxes) => {
mailbox_cache::set(account_id, mailboxes).await;
mailbox_cache::set_fetch_ready(account_id).await;
}
Err(e) => {
mailbox_cache::set_fetch_error(account_id, format!("{:#?}", e)).await;
}
}
});
}
async fn fetch_remote_with_progress(account_id: u64) -> BichonResult<Vec<MailBox>> {
let mut session = ImapExecutor::create_connection(account_id).await?;
let names = ImapExecutor::list_all_mailboxes(&mut session).await?;
let total = names.len();
mailbox_cache::update_fetch_progress(account_id, 0, total).await;
let mut mailboxes = Vec::new();
for (i, name) in names.iter().enumerate() {
let mailbox_name = name.name().to_string();
let mut mailbox: MailBox = name.into();
if contains_no_select(&mailbox.attributes) {
continue;
}
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
.status(
mailbox_name.as_str(),
"(MESSAGES UNSEEN UIDNEXT UIDVALIDITY)",
)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
mailbox.exists = mx.exists;
mailbox.unseen = mx.unseen;
mailbox.uid_next = mx.uid_next;
mailbox.uid_validity = mx.uid_validity;
mailboxes.push(mailbox);
mailbox_cache::update_fetch_progress(account_id, i + 1, total).await;
}
session.logout().await.ok();
Ok(mailboxes)
}
pub async fn request_imap_all_mailbox_list(account_id: u64) -> BichonResult<Vec<MailBox>> {
let mut session = ImapExecutor::create_connection(account_id).await?;
let names = ImapExecutor::list_all_mailboxes(&mut session).await?;
let result = convert_names_to_mailboxes(account_id, &mut session, names.iter()).await?;
session.logout().await.ok();
Ok(result)
}
fn contains_no_select(attributes: &[Attribute]) -> bool {
attributes
.iter()
.any(|attr| attr.attr == AttributeEnum::NoSelect)
}
pub async fn convert_names_to_mailboxes(
account_id: u64,
session: &mut Session<Box<dyn SessionStream>>,
names: impl IntoIterator<Item = &Name>,
) -> BichonResult<Vec<MailBox>> {
let mut mailboxes = Vec::new();
for name in names {
let mailbox_name = name.name().to_string();
let mut mailbox: MailBox = name.into();
if contains_no_select(&mailbox.attributes) {
continue;
}
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
.status(
mailbox_name.as_str(),
"(MESSAGES UNSEEN UIDNEXT UIDVALIDITY)",
)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
mailbox.exists = mx.exists;
mailbox.unseen = mx.unseen;
mailbox.uid_next = mx.uid_next;
mailbox.uid_validity = mx.uid_validity;
mailboxes.push(mailbox);
}
Ok(mailboxes)
}
@@ -1,5 +1,5 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
+85
View File
@@ -0,0 +1,85 @@
use crate::{
encode_mailbox_name, raise_error,
{
account::migration::{AccountModel, AccountType},
envelope::extractor::reattach_eml_content,
error::{code::ErrorCode, BichonResult},
imap::executor::ImapExecutor,
},
};
//use poem_openapi::Object;
use serde::{Deserialize, Serialize};
const MAX_RESTORE_COUNT: usize = 100;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct RestoreMessagesRequest {
/// envelope IDs to restore (max 100)
pub envelope_ids: Vec<String>,
}
pub async fn restore_emails(account_id: u64, envelope_ids: Vec<String>) -> BichonResult<()> {
if envelope_ids.len() > MAX_RESTORE_COUNT {
return Err(raise_error!(
format!(
"Too many messages to restore: {} (max {})",
envelope_ids.len(),
MAX_RESTORE_COUNT
),
ErrorCode::InvalidParameter
));
}
let account = AccountModel::check_account_exists(account_id)?;
if !matches!(account.account_type, AccountType::IMAP) {
return Err(raise_error!(
"Account type is not IMAP".into(),
ErrorCode::Incompatible
));
}
let mut failed = Vec::new();
let mut session = ImapExecutor::create_connection(account_id).await?;
for envelope_id in envelope_ids {
let result: BichonResult<()> = async {
let (envelope, eml) = reattach_eml_content(account_id, envelope_id.clone())?;
if let Some(mailbox_name) = envelope.mailbox_name {
ImapExecutor::append(
&mut session,
encode_mailbox_name!(&mailbox_name),
None,
None,
&eml,
)
.await?;
}
Ok(())
}
.await;
if let Err(err) = result {
tracing::warn!(
account_id = account_id,
message_id = &envelope_id,
error = ?err,
"Failed to restore email"
);
failed.push(envelope_id);
}
}
if !failed.is_empty() {
tracing::info!(
account_id = account_id,
failed_count = failed.len(),
failed_message_ids = ?failed,
"Restore emails finished with partial failures"
);
}
session.logout().await.ok();
Ok(())
}
+105
View File
@@ -0,0 +1,105 @@
use std::io::Cursor;
use crate::{
raise_error,
{
dashboard::Group,
envelope::extractor::reattach_eml_content,
error::{code::ErrorCode, BichonResult},
utils::compute_content_hash,
},
};
use bytes::Bytes;
use mail_parser::MessageParser;
//use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct AttachmentMetadata {
/// Statistics of attachment file extensions (key + count).
/// Each item represents a file extension and its occurrence count.
/// Example: [{ key: "pdf", count: 10 }, { key: "png", count: 5 }]
pub extensions: Vec<Group>,
/// Statistics of attachment categories (key + count).
/// Each item represents a high-level category and its occurrence count.
/// Example: [{ key: "document", count: 8 }, { key: "image", count: 6 }]
pub categories: Vec<Group>,
/// Statistics of attachment MIME types (Content-Type) (key + count).
/// Each item represents a MIME type and its occurrence count.
/// Example: [{ key: "application/pdf", count: 10 }, { key: "image/jpeg", count: 5 }]
pub content_types: Vec<Group>,
}
pub fn retrieve_attachment_content(
account_id: u64,
envelope_id: String,
content_hash: &str,
) -> BichonResult<Cursor<Bytes>> {
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
let message = MessageParser::default()
.parse(&eml)
.ok_or_else(|| raise_error!("Failed to parse EML".into(), ErrorCode::InternalError))?;
let attachment_content: &[u8] = message
.attachments()
.find(|att| compute_content_hash(att.contents()) == content_hash)
.map(|att| att.contents())
.ok_or_else(|| {
raise_error!(
"Target attachment not found".into(),
ErrorCode::ResourceNotFound
)
})?;
Ok(Cursor::new(Bytes::copy_from_slice(attachment_content)))
}
pub fn retrieve_nested_attachment_content(
account_id: u64,
envelope_id: String,
content_hash: &str,
nested_content_hash: &str,
) -> BichonResult<Cursor<Bytes>> {
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
let parent_message = MessageParser::default().parse(&eml).ok_or_else(|| {
raise_error!(
"Failed to parse parent EML".into(),
ErrorCode::InternalError
)
})?;
let attachment_content = parent_message
.attachments()
.find(|att| compute_content_hash(att.contents()) == content_hash)
.map(|att| att.contents())
.ok_or_else(|| {
raise_error!(
"Target nested EML not found".into(),
ErrorCode::ResourceNotFound
)
})?;
let nested_message = MessageParser::default()
.parse(attachment_content)
.ok_or_else(|| {
raise_error!(
"Failed to parse nested EML".into(),
ErrorCode::InternalError
)
})?;
let attachment_content = nested_message
.attachments()
.find(|att| compute_content_hash(att.contents()) == nested_content_hash)
.map(|att| att.contents())
.ok_or_else(|| {
raise_error!(
"Target nested EML not found".into(),
ErrorCode::ResourceNotFound
)
})?;
Ok(Cursor::new(Bytes::copy_from_slice(attachment_content)))
}
@@ -1,7 +1,7 @@
use poem_openapi::Object;
//use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct Contact {
pub email: String,
pub name: Option<String>,

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