v0.2.8
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-01 20:40:40 +01:00
1 parent cfc74d9db6
commit dacdf9c27d
76 files changed
+5208 -740

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ SIEVE_ALLOW_INSECURE_AUTH=false
SIEVE_TIMEOUT_SECS=10 SIEVE_TIMEOUT_SECS=10
TRUSTED_PROXIES=127.0.0.1,::1 TRUSTED_PROXIES=127.0.0.1,::1
RUST_LOG=info RUST_LOG=warn,rustbox=debug,tower_http=info
# RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml # RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml
# RustBox 0.2 storage/search nested overrides use double underscores. # RustBox 0.2 storage/search nested overrides use double underscores.
+31
View File
@@ -1,5 +1,36 @@
# RustBox changelog # RustBox changelog
### 0.2.8
- Scoped default logging so RustBox can run at DEBUG/INFO without enabling verbose dependency logs; Tantivy merge/GC/file-watcher noise and its internal filesystem/source paths are hidden unless explicitly enabled through a custom `RUST_LOG` target filter. Development and deployment examples now use target-scoped filters.
- Kept administrator diagnostics process-local and cross-platform: removed host CPU/RAM/load-average and Linux `/proc` sampling; runtime diagnostics now rely on portable RustBox in-memory counters plus the process PID.
- Added a disabled-by-default administrator system-diagnostics endpoint with loopback-only CIDR allowlisting, mandatory token or Basic authentication, fail-closed trusted-proxy handling, secret redaction, live SQL/session/storage/search probes, active IMAP IDLE/session/runtime state and bounded security telemetry for failed logins, rate limiting, access denials and server errors.
- Moved the synchronization/index activity indicator to the left of Contacts.
- Corrected production TOML section ordering so intended top-level database/session/mail/security/special-folder settings are no longer accidentally parsed as search settings.
- Refined folder counters so only the unread value is emphasized, hid the sync/index activity icon when idle, and made manual search-index rebuilds drive the same live activity indicator as background indexing.
- Made folder totals/unread counts server-authoritative: IMAP `STATUS MESSAGES/UNSEEN` drives badges, selected-mailbox fallback counts `UID SEARCH UNSEEN`, periodic reconciliation repairs counters even when `HIGHESTMODSEQ` is unchanged, and mutation-time compare-and-set/retry prevents out-of-order STATUS responses from rolling counters backwards.
- Decoupled counter refresh from folder-tree discovery: a forced counter refresh reuses a fresh cached folder tree and batches STATUS requests instead of forcing another IMAP LIST.
- Changed read/unread/flag updates to patch the affected message row and folder badge live; ordinary flag changes no longer rebuild the message list, removing the reader/list jump during mark-read operations.
- Added responsive folder counters (`total / unread` when unread is non-zero, total-only at zero, compact mode in narrow sidebars) and surfaced IMAP header-cache plus search-index backlog in the top-right activity indicator/tooltip.
- Extended the opt-in security-header middleware with a private `/api` profile (`no-store`, `nosniff`, same-origin CORP, `frame-ancestors 'none'` and `X-Frame-Options: DENY`), while keeping Permissions-Policy/COOP and the full document CSP on HTML and HSTS at the public HTTPS edge; disabling `security_headers_enabled` removes the whole RustBox-added security profile.
- Minimized unauthenticated `/api/auth/policy` to `rememberMeEnabled`; optional feature policy moved behind authenticated `/api/auth/session-policy`, and session/remember durations are no longer exposed by policy endpoints.
- Added optimistic read/unread updates in the browser with guarded rollback, followed by authoritative server reconciliation; normal read/unread operations no longer replace the message list.
- Fixed realtime WebSocket deduplication so unique server `eventId` values always win over payload fingerprints; rapid legitimate counter cycles such as `1 → 0 → 1` are no longer dropped as duplicates. Active-folder total text is patched live together with the sidebar counter.
- Hardened IDLE/CONDSTORE reconciliation: counter STATUS no longer advances the flag-sync cursor, non-CONDSTORE servers fall back to a full `UID FLAGS` comparison, partial IMAP fetch streams are discarded instead of being treated as complete, and IDLE header/index writes participate in the same cache-generation transaction.
- Fixed UIDVALIDITY reset writes to stay inside the same database transaction on SQLite, MySQL and PostgreSQL instead of mixing transaction/base-connection writes.
- Tightened the HTML CSP to `style-src 'self'` plus the narrower `style-src-attr 'unsafe-inline'`; inline JavaScript remains disallowed.
- Added regression coverage for authoritative unread refresh, API/document/static header profiles and pre-login policy minimization.
- Added database migration V048 on SQLite, MySQL and PostgreSQL: folder counters now carry a monotonic `counter_generation`, so delayed STATUS responses cannot win an ABA race after newer read/unread activity. V049 is the next migration number.
- Bumped the application/source release to 0.2.8.
### 0.2.7
- Made opt-in browser security headers content-type-aware instead of attaching document-only policies to every response.
- HTML documents keep the full CSP, clickjacking, permissions, COOP, referrer and MIME-sniffing protections.
- CSS responses now receive only the applicable `Referrer-Policy` and `X-Content-Type-Options`; JavaScript, JSON, SVG, API and other non-document responses retain `X-Content-Type-Options: nosniff` without redundant document policies.
- Added regression coverage for HTML, CSS and API header sets while preserving the disabled-by-default compatibility switch.
- No schema migration is required; runtime schema remains V047 and V048 remains the next migration number.
- Bumped the application/source release to 0.2.7.
### 0.2.6 ### 0.2.6
- Fixed iPhone/iOS reader toolbar popovers (`Message details` and `Move / copy`) by rendering the open menu outside the horizontally scrolling toolbar and positioning it against the visual viewport. - Fixed iPhone/iOS reader toolbar popovers (`Message details` and `Move / copy`) by rendering the open menu outside the horizontally scrolling toolbar and positioning it against the visual viewport.
- Made folder counters and message-list refreshes more responsive: realtime sync stores the server-reported unread count, folder hierarchy refresh avoids unnecessary IMAP LIST calls, and manual refresh forces the active-folder status check in parallel with counter refresh. - Made folder counters and message-list refreshes more responsive: realtime sync stores the server-reported unread count, folder hierarchy refresh avoids unnecessary IMAP LIST calls, and manual refresh forces the active-folder status check in parallel with counter refresh.
Generated
+1 -1
View File
@@ -3506,7 +3506,7 @@ dependencies = [
[[package]] [[package]]
name = "rustbox" name = "rustbox"
version = "0.2.6" version = "0.2.8"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-imap", "async-imap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustbox" name = "rustbox"
version = "0.2.6" version = "0.2.8"
edition = "2024" edition = "2024"
rust-version = "1.99.1" rust-version = "1.99.1"
default-run = "rustbox" default-run = "rustbox"
+1 -1
View File
@@ -1,6 +1,6 @@
# RustBox attribution / notice # RustBox attribution / notice
RustBox 0.2.6 is derived from the Oxi webmail project: RustBox 0.2.8 is derived from the Oxi webmail project:
- Upstream: `c0h1b4/oxi` - Upstream: `c0h1b4/oxi`
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
+17 -6
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.6 # RustBox 0.2.8
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`. RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
@@ -34,7 +34,7 @@ rustbox/
`build.rs` combines the CSS modules from `src/web/css/manifest.txt` and JavaScript modules from `src/web/js/manifest.txt`. Cargo embeds the generated CSS/JS bundles, HTML and SVG sprite in the RustBox executable. There is no standalone source `src/web/app.js`; `src/web/` is the single source of truth and Cargo generates the embedded bundles in `OUT_DIR`. `build.rs` combines the CSS modules from `src/web/css/manifest.txt` and JavaScript modules from `src/web/js/manifest.txt`. Cargo embeds the generated CSS/JS bundles, HTML and SVG sprite in the RustBox executable. There is no standalone source `src/web/app.js`; `src/web/` is the single source of truth and Cargo generates the embedded bundles in `OUT_DIR`.
## Storage and search in 0.2.6 ## Storage and search in 0.2.8
RustBox separates three concerns: SQL metadata, blob storage and search. The SQL engine does **not** decide whether `data_dir` may be used. A single-node PostgreSQL/MySQL deployment may still use filesystem blobs and local Tantivy. In cluster mode, local node state is rejected unless the configured backend is explicitly shared. RustBox separates three concerns: SQL metadata, blob storage and search. The SQL engine does **not** decide whether `data_dir` may be used. A single-node PostgreSQL/MySQL deployment may still use filesystem blobs and local Tantivy. In cluster mode, local node state is rejected unless the configured backend is explicitly shared.
@@ -51,6 +51,8 @@ Search backends:
In a cluster using shared SQL + S3/shared POSIX + OpenSearch/disabled search + `pgp_enabled=false`, `data_dir` is not required as persistent node state. See `docker/README.md` for ready Compose variants. In a cluster using shared SQL + S3/shared POSIX + OpenSearch/disabled search + `pgp_enabled=false`, `data_dir` is not required as persistent node state. See `docker/README.md` for ready Compose variants.
Administrator-only deep runtime diagnostics can be enabled separately under `[diagnostics_endpoint]`. It is disabled and loopback-only by default, supports a network allowlist plus token or Basic Auth, redacts secrets, and probes SQL/Redis/storage/search/session/runtime/security state. See [`docs/SYSTEM_DIAGNOSTICS.md`](docs/SYSTEM_DIAGNOSTICS.md).
## Configure mail servers ## Configure mail servers
Mail endpoints are configured by the administrator, not on the login screen. Users enter only email and password. Mail endpoints are configured by the administrator, not on the login screen. Users enter only email and password.
@@ -60,7 +62,10 @@ Edit `config.dev.toml` before the first run:
```toml ```toml
default_theme = "system" # system | dark | light default_theme = "system" # system | dark | light
default_interface_skin = "rustbox" # rustbox | gmail | proton | zoho | outlook | fastmail | apple-mail | yahoo | hey | nord | github | gitlab | discord | slack | reddit | notion | ... default_interface_skin = "rustbox" # rustbox | gmail | proton | zoho | outlook | fastmail | apple-mail | yahoo | hey | nord | github | gitlab | discord | slack | reddit | notion | ...
log_level = "info" # trace | debug | info | warn | error | off; RUST_LOG overrides it log_level = "info" # RustBox app level; dependencies default to WARN. RUST_LOG can override the full filter.
# Recommended dev filter: warn,rustbox=debug,tower_http=info
# Avoid a global RUST_LOG=debug/info unless diagnosing a dependency: third-party
# crates may emit internal filesystem/source paths in verbose records.
# Mail hosts may be omitted: IMAP defaults to localhost:993 and SMTP to localhost:587. # Mail hosts may be omitted: IMAP defaults to localhost:993 and SMTP to localhost:587.
# imap_host = "localhost" # imap_host = "localhost"
# smtp_host = "localhost" # smtp_host = "localhost"
@@ -75,7 +80,7 @@ max_message_size_mb = 50
sieve_enabled = true sieve_enabled = true
diagnostics_enabled = true diagnostics_enabled = true
about_enabled = true about_enabled = true
# Adds CSP/frame/referrer/permissions/COOP response headers when enabled. # Adds content-type-aware browser security headers when enabled: full document policy on HTML, private no-store/CORP/anti-framing policy on /api, Referrer-Policy on HTML/CSS, and nosniff broadly.
security_headers_enabled = false security_headers_enabled = false
# Optional; defaults to imap_host. Ignored when sieve_enabled=false. # Optional; defaults to imap_host. Ignored when sieve_enabled=false.
@@ -125,10 +130,16 @@ bucket = "rustbox"
region = "eu-central-1" region = "eu-central-1"
# endpoint = "https://s3.example.com" # MinIO/Ceph RGW/etc. # endpoint = "https://s3.example.com" # MinIO/Ceph RGW/etc.
[sync]
interval_secs = 30
deep_index_batch_size = 10
[search] [search]
backend = "opensearch" backend = "opensearch"
urls = ["https://opensearch.example.com:9200"] urls = ["https://opensearch.example.com:9200"]
required = true required = true
# Production-safe default: do not proactively download every full message body.
deep_index = false
``` ```
Shared POSIX is also supported without S3: Shared POSIX is also supported without S3:
@@ -155,7 +166,7 @@ rustbox search reindex
Ready Docker examples are in `docker/`: standalone SQLite, PostgreSQL + SILO (S3-compatible) + OpenSearch, and MySQL + NFS + OpenSearch. Ready Docker examples are in `docker/`: standalone SQLite, PostgreSQL + SILO (S3-compatible) + OpenSearch, and MySQL + NFS + OpenSearch.
All executable application SQL is centralized by dialect under `src/queries/{sqlite,mysql,pgsql}/queries.rs`. The squashed compatibility baseline remains V046; RustBox 0.2.1 adds runtime migration V047 for attachment checksums/logical blob keys and the durable search outbox. The next migration number is V048. See `migrations/README.md`. All executable application SQL is centralized by dialect under `src/queries/{sqlite,mysql,pgsql}/queries.rs`. The squashed compatibility baseline remains V046; RustBox 0.2.1 adds runtime migration V047 for attachment checksums/logical blob keys and the durable search outbox, while RustBox 0.2.8 adds V048 for monotonic folder-counter generations used by race-safe IMAP counter reconciliation. The next migration number is V049. See `migrations/README.md`.
## Development ## Development
@@ -224,7 +235,7 @@ Initialize Git after extracting the release archive:
```bash ```bash
git init git init
git add . git add .
git commit -m "RustBox 0.2.6 release" git commit -m "RustBox 0.2.8 release"
./scripts/setup-upstream.sh ./scripts/setup-upstream.sh
``` ```
+20 -38
View File
@@ -1,15 +1,20 @@
RustBox 0.2.6 release manifest RustBox 0.2.8 release manifest
0.2.6 feature/configuration changes: 0.2.8 feature/configuration changes:
- fixes iPhone/iOS Safari reader action menus by rendering open Details / Move / copy popovers outside the scrolling toolbar - fixes folder unread counts by treating IMAP STATUS UNSEEN as authoritative and never using SELECT/EXAMINE UNSEEN as a count
- refreshes unread counters with server-reported unread counts and avoids frequent IMAP folder-list refreshes - falls back to UID SEARCH UNSEEN when STATUS is unavailable for the selected mailbox
- watches the currently viewed folder with IMAP IDLE and synchronizes remaining folders on a 30-second fallback cadence - reconciles MESSAGES/UNSEEN even when HIGHESTMODSEQ is unchanged and rejects out-of-order stale counter writes with compare-and-set/retry
- stores new message headers before realtime events, allowing newly arrived messages to appear live in the active list - updates individual message rows and folder counters optimistically/live for read/unread/flag events instead of rebuilding the message list, then reconciles them to authoritative IMAP state
- shows an in-app toast for new mail in any folder and deduplicates browser notifications per message - shows wide folder badges as total / unread, hides the unread suffix at zero, and switches to compact counters in narrow sidebars
- shows an animated indexing indicator beside Contacts while deep indexing is active - exposes IMAP header-cache and deep-index backlog through the activity indicator and tooltip
- excludes Trash/Junk/Spam from the deep-index selection query - keeps counter STATUS separate from the CONDSTORE cursor, falls back to full UID/FLAGS reconciliation when needed, and refuses to apply partial IMAP streams
- manual refresh forces current-folder message/count refresh while folder counters refresh in parallel - keeps security_headers_enabled opt-in, centralizes the RustBox-added browser security profile behind that switch, and applies the full document-only profile only to HTML/XHTML
- no schema migration; V047 remains current and V048 remains next - uses external same-origin JavaScript with `script-src 'self'`, disables inline script attributes, restricts stylesheets to `style-src 'self'`, and permits only dynamic style attributes via `style-src-attr 'unsafe-inline'`
- applies no-store, nosniff, same-origin CORP and an anti-framing `frame-ancestors 'none'`/X-Frame-Options policy to private API responses; the full document CSP plus Permissions-Policy/COOP remain HTML-only, and assets receive same-origin CORP where applicable
- leaves HSTS to the HTTPS/TLS-terminating deployment layer
- minimizes the public auth policy to rememberMeEnabled and moves optional feature availability behind authenticated /api/auth/session-policy
- adds/extends regression coverage and offline release checks for the changed behavior
- adds runtime migration V048 on SQLite/MySQL/PostgreSQL for monotonic folder-counter generations; V049 remains next
Architecture: Architecture:
- database_backend = sqlite | mysql | pgsql selects authoritative SQL only - database_backend = sqlite | mysql | pgsql selects authoritative SQL only
@@ -36,31 +41,8 @@ Database/schema:
- migrations/{sqlite,mysql,pgsql}/schema.sql contain the current fresh schema - migrations/{sqlite,mysql,pgsql}/schema.sql contain the current fresh schema
- compatibility baseline remains V046 - compatibility baseline remains V046
- runtime migration V047 adds attachment checksum metadata and search_outbox - runtime migration V047 adds attachment checksum metadata and search_outbox
- next migration number: V048 - runtime migration V048 adds `folders.counter_generation` for race-safe authoritative IMAP counter writes
- next migration number: V049
PostgreSQL message mutation fix (0.2.2):
- header_sync_generation UPSERT qualifies the current tenant user_meta.value on the UPDATE RHS
- this prevents PostgreSQL from treating `value` as ambiguous during ON CONFLICT DO UPDATE
- SQLite/MySQL equivalents were reviewed and require no matching SQL change
- no schema migration is required
PostgreSQL startup compile fix (0.2.2):
- multi-statement global schema DDL uses `sqlx::Executor::execute()` on the transaction connection
- avoids SQLx raw_sql mutable-connection lifetime inference failures (`Executor is not general enough`)
- retains the startup transaction and `SET LOCAL client_min_messages TO WARNING`
- no schema migration is required
Database/session startup (0.2.2):
- db::pool::configure() is the single owner of global schema initialization
- database-backed session persistence does not execute schema.sql a second time
- the fix applies uniformly to SQLite, MySQL and PostgreSQL
- PostgreSQL expected IF NOT EXISTS notices are suppressed only inside the startup DDL transaction
- no SQL migration is required; runtime schema remains V047
SQLite test isolation (0.2.2):
- test builds initialize each requested SQLite database but do not publish a TempDir-backed path into the process-wide database OnceLock
- account/database tests therefore remain isolated when Rust runs them in parallel
- a regression test covers deleting the first TempDir before opening a second test database
Docker examples: Docker examples:
- docker/compose-single-sqlite.yml - docker/compose-single-sqlite.yml
@@ -69,14 +51,14 @@ Docker examples:
- docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch) - docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch)
Operations/docs: Operations/docs:
- README.md documents 0.2.6 storage/search configuration - README.md documents 0.2.8 configuration
- docker/README.md documents Compose variants - docker/README.md documents Compose variants
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior - docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
- TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending - TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending
Build/release metadata: Build/release metadata:
- `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts
- Cargo.toml version = 0.2.6 - Cargo.toml version = 0.2.8
- rust-toolchain.toml pins Rust 1.99.1 - rust-toolchain.toml pins Rust 1.99.1
- Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing - Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing
- CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build - CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build
+20 -37
View File
@@ -1,46 +1,29 @@
RustBox 0.2.6 - final source validation RustBox 0.2.8 - final source validation
0.2.6 scope: 0.2.8 scope:
- iOS-safe reader action popovers for Details and Move / copy, portaled outside the horizontally scrolling toolbar - authoritative IMAP folder totals/unread counters from server state, including selected-mailbox fallback via UID SEARCH UNSEEN
- faster folder-counter refresh using a server-authoritative realtime unread counts and a longer folder-list cache TTL - optimistic/targeted realtime read/unread/flag updates without full message-list rerenders
- live refresh for the currently viewed folder via IMAP IDLE plus a 30-second fallback sync for other folders - responsive total/unread folder badges, authoritative FolderStatus updates, and separate counter/CONDSTORE cursor handling
- periodic sync stores newly arrived message headers before emitting realtime events, so new messages can appear in the list immediately - IMAP cache/deep-index progress surfaced through the activity indicator
- in-app toast for new mail in any folder and per-message browser-notification deduplication - content-type-aware opt-in security headers with external-script `script-src 'self'` CSP and stylesheet-only `style-src 'self'`
- animated indexing indicator beside Contacts while deep indexing is active - private API no-store/nosniff/same-origin-CORP plus anti-framing CSP/X-Frame-Options; full document CSP, Permissions-Policy and COOP remain HTML-only
- deep indexing excludes Trash/Junk/Spam at query time so excluded folders cannot block the indexing queue - pre-login auth policy minimized to rememberMeEnabled; optional feature policy requires authentication
- manual refresh forces current-folder status refresh while folder-list/count refresh runs in parallel - runtime migration V048 adds monotonic folder-counter generations on SQLite/MySQL/PostgreSQL; V049 remains next
- no database migration; V047 remains current and V048 remains next - version metadata updated to 0.2.8
- version metadata updated to 0.2.6
Completed in this environment: Completed in this environment:
- offline release validator: scripts/validate-release-static.py (212/212 checks passed) - offline release validator: scripts/validate-release-static.py (279/279 checks passed)
- all maintained TOML examples parse successfully - browser JavaScript assembled in manifest order passes `node --check`
- Docker Compose topology/config references are validated by the offline release validator - all JSON files parse successfully
- JSON files parse successfully
- browser JavaScript assembled in src/web/js/manifest.txt order passes node --check
- shell scripts pass bash -n
- Python helper scripts compile successfully - Python helper scripts compile successfully
- targeted source checks validate the PostgreSQL header-sync UPSERT and startup schema-initialization control flow - shell scripts pass `bash -n`
- the PostgreSQL startup DDL path now uses `Executor::execute()` instead of the lifetime-problematic `raw_sql(...).execute(&mut PgConnection)` path; compiler validation remains part of the Rust-host release gate - release metadata consistently identifies 0.2.8
- whitespace/patch-artifact checks pass on the final source tree (this packaged worktree has no `.git` metadata) - V048 migration files/catalog registration and SQLite upgrade execution are validated offline
- Cargo.lock is retained for reproducible builds; no *.orig, *.rej, temporary FIX/APPLY scripts, or patch leftovers are shipped
- regression source checks retain the PostgreSQL header-sync qualification audit and now reject the SQLx raw_sql mutable-connection lifetime regression in startup DDL
- SQLite unit-test configuration no longer leaks a temporary database path through the process-wide OnceLock; a dedicated regression test covers deleted TempDir reuse
- the static release contract now verifies that build-release.sh requires cargo test, Clippy with -D warnings, and a locked release build
- scripts/package-release.sh runs the full build gate before creating the source ZIP and SHA256, so packaging cannot succeed after a failed Rust test/Clippy/build gate
0.2.2 fixes included:
- PostgreSQL startup schema initialization no longer triggers SQLx `Executor is not general enough`; raw multi-statement DDL is executed directly through `Executor::execute()` inside the existing transaction
- PostgreSQL header-sync invalidation qualifies the existing tenant user_meta.value in ON CONFLICT DO UPDATE, eliminating the ambiguous-column 500 seen during message mutations such as marking spam
- tenant SQL rewriting has a regression test proving the qualified user_meta reference is rewritten to the tenant table
- SQLite and MySQL equivalents were reviewed and do not have the PostgreSQL ambiguity
- 0.2.2 single-schema-owner and PostgreSQL NOTICE-noise fixes remain in place
- no database migration is required; V047 remains current and V048 remains next
- 0.2.1 OpenPGP visibility, OpenSearch backpressure and HTTP/dev cookie fixes remain in place
Not executed in this packaging environment: Not executable in this packaging environment:
- cargo fmt/test/clippy/build with Rust 1.99.1, because Rust/Cargo is not installed in this validation environment - cargo fmt/test/clippy/build with Rust 1.99.1, because Rust/Cargo is not installed
- live PostgreSQL/MySQL/S3/NFS/OpenSearch integration tests - live PostgreSQL/MySQL/S3/NFS/OpenSearch/IMAP integration tests
The final release gate on a Rust host is: The final release gate on a Rust host is:
bash scripts/build-release.sh bash scripts/build-release.sh
+9 -1
View File
@@ -58,9 +58,11 @@ fn main() {
let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")); let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"));
let css_dir = root.join("src/web/css"); let css_dir = root.join("src/web/css");
let js_dir = root.join("src/web/js"); let js_dir = root.join("src/web/js");
let theme_bootstrap_path = root.join("src/web/theme-bootstrap.js");
for path in [ for path in [
root.join("src/web/index.html"), root.join("src/web/index.html"),
theme_bootstrap_path.clone(),
root.join("src/web/icons.svg"), root.join("src/web/icons.svg"),
root.join("src/web/i18n/en.json"), root.join("src/web/i18n/en.json"),
root.join("src/web/i18n/pl.json"), root.join("src/web/i18n/pl.json"),
@@ -70,8 +72,14 @@ fn main() {
let css_bundle = bundle_manifest(&root, &css_dir, &css_dir.join("manifest.txt"), ".css"); let css_bundle = bundle_manifest(&root, &css_dir, &css_dir.join("manifest.txt"), ".css");
let js_bundle = bundle_manifest(&root, &js_dir, &js_dir.join("manifest.txt"), ".js"); let js_bundle = bundle_manifest(&root, &js_dir, &js_dir.join("manifest.txt"), ".js");
let theme_bootstrap = fs::read_to_string(&theme_bootstrap_path)
.unwrap_or_else(|error| panic!("{}: {error}", theme_bootstrap_path.display()));
let asset_id = fnv1a64(&[css_bundle.as_bytes(), js_bundle.as_bytes()]); let asset_id = fnv1a64(&[
css_bundle.as_bytes(),
js_bundle.as_bytes(),
theme_bootstrap.as_bytes(),
]);
println!("cargo:rustc-env=RUSTBOX_ASSET_ID={asset_id:016x}"); println!("cargo:rustc-env=RUSTBOX_ASSET_ID={asset_id:016x}");
fs::write(out.join("rustbox-ui.css"), css_bundle).expect("write RustBox CSS bundle"); fs::write(out.join("rustbox-ui.css"), css_bundle).expect("write RustBox CSS bundle");
+48
View File
@@ -52,3 +52,51 @@ sieve_allow_insecure_auth = false
sieve_timeout_secs = 10 sieve_timeout_secs = 10
trusted_proxies = "127.0.0.1,::1" trusted_proxies = "127.0.0.1,::1"
[sync]
interval_secs = 30
# Maximum number of full message bodies fetched from IMAP during one sync pass when
# automatic deep indexing is enabled. Higher values fill the cache/index faster but
# increase network traffic, application database/search-storage usage and indexing CPU/I/O bursts.
deep_index_batch_size = 10
# Development search/cache profile.
[search]
backend = "local_tantivy"
# Force automatic full-body cache/index for every account in this development instance.
# Each periodic sync may download up to sync.deep_index_batch_size full MIME bodies.
# This is intentionally ON in dev so cache/index behaviour is visible during testing.
# Setting it to false falls back to the existing per-account deep_index preference.
deep_index = true
# Search outbox worker cadence/batch; these do NOT control IMAP body downloads.
worker_interval_secs = 2
worker_batch_size = 100
worker_max_attempts = 12
# Periodic IMAP reconciliation. Active folders still use IMAP IDLE for near-real-time
# updates; this interval controls the fallback/background pass for counters, flags,
# new headers in other folders, and the automatic deep-index pass. Lower values make
# background state converge faster but increase IMAP traffic and server load.
# Administrator-only system diagnostics endpoint. Disabled by default.
# When enabled it exposes a redacted runtime/configuration report plus live probes
# for SQL, session persistence (database/Redis), blob storage (filesystem/S3),
# search (Tantivy/OpenSearch), IMAP IDLE counters and security telemetry.
# Access requires BOTH an allowed source network and the configured auth method.
# Secrets (passwords, tokens, S3 keys, session keys and URL credentials) are never returned.
[diagnostics_endpoint]
enabled = false
path = "/api/system/diagnostics"
# Default/fail-safe allowlist: only this host. Add trusted LAN/pod/monitoring CIDRs explicitly.
allowed_networks = ["127.0.0.1/32", "::1/128"]
# token | basic. Credentials should normally come from .env / secret manager, not TOML.
auth = "token"
# token = "replace-with-at-least-24-random-characters"
# basic_username = "rustbox-monitor"
# basic_password = "replace-with-a-long-random-password"
# E-mail addresses and stable user hashes are omitted by default; aggregate counts remain available.
include_user_identifiers = false
# Timeout applied independently to database/session/storage/search probes.
probe_timeout_secs = 5
# Rolling in-memory window used for login failures, 403, 429 and 5xx attack/problem signals.
security_window_secs = 900
+50 -7
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.6 # RustBox 0.2.8
host = "127.0.0.1" host = "127.0.0.1"
port = 3001 port = 3001
environment = "production" environment = "production"
@@ -59,7 +59,7 @@ max_message_size_mb = 50
sieve_enabled = true sieve_enabled = true
diagnostics_enabled = true diagnostics_enabled = true
about_enabled = true about_enabled = true
# Adds browser-facing security headers (CSP, frame/referrer/permissions policies). # Adds content-type-aware browser security headers: document policies on HTML, private no-store/framing/CORP policy on /api, Referrer-Policy on HTML/CSS, and nosniff broadly.
security_headers_enabled = false security_headers_enabled = false
# ManageSieve. If sieve_host is omitted, imap_host from the authenticated # ManageSieve. If sieve_host is omitted, imap_host from the authenticated
@@ -72,7 +72,6 @@ sieve_allow_insecure_auth = false
sieve_timeout_secs = 10 sieve_timeout_secs = 10
trusted_proxies = "127.0.0.1,::1" trusted_proxies = "127.0.0.1,::1"
# Optional server-wide defaults for special IMAP folders. These are used when # Optional server-wide defaults for special IMAP folders. These are used when
# a mailbox has no per-account override and the named folder exists. # a mailbox has no per-account override and the named folder exists.
# default_sent_folder = "Sent" # default_sent_folder = "Sent"
@@ -96,10 +95,6 @@ pgp_max_key_bytes = 1048576
# Private-key export is deliberately disabled unless the administrator opts in. # Private-key export is deliberately disabled unless the administrator opts in.
pgp_allow_private_export = false pgp_allow_private_export = false
# RustBox 0.2 blob/search backends
# Blob storage is independent from the SQL database. PostgreSQL/MySQL can still use
# local filesystem storage in single-node mode. In cluster mode use S3-compatible
# storage or a genuinely shared POSIX filesystem (NFS/CephFS/GlusterFS).
[storage] [storage]
backend = "filesystem" # filesystem | s3 backend = "filesystem" # filesystem | s3
# path = "/var/lib/rustbox/blobs" # defaults to data_dir for filesystem # path = "/var/lib/rustbox/blobs" # defaults to data_dir for filesystem
@@ -120,6 +115,17 @@ retry_timeout_secs = 60
# server_side_encryption = "AES256" # or "aws:kms" # server_side_encryption = "AES256" # or "aws:kms"
# kms_key_id = "..." # kms_key_id = "..."
# Periodic IMAP reconciliation. Active folders still use IMAP IDLE; this is the
# background/fallback cadence for folder counters, flags, new headers in other
# folders, and the automatic deep-index pass. Lower values improve convergence but
# increase IMAP traffic and mail-server load.
[sync]
interval_secs = 30
# Maximum full message bodies downloaded from IMAP per periodic pass when deep index
# is effective. Larger batches catch up faster at the cost of network traffic,
# application-database/search-storage usage and indexing CPU/I/O.
deep_index_batch_size = 10
# Search is independent from blob storage. Local Tantivy is single-node only. # Search is independent from blob storage. Local Tantivy is single-node only.
[search] [search]
backend = "local_tantivy" # disabled | local_tantivy | opensearch backend = "local_tantivy" # disabled | local_tantivy | opensearch
@@ -129,8 +135,45 @@ index_prefix = "rustbox"
required = false required = false
allow_insecure_http = false allow_insecure_http = false
request_timeout_secs = 10 request_timeout_secs = 10
# Server-wide automatic full-body cache/index switch. When true, RustBox proactively
# downloads message bodies from IMAP and indexes them for every account, up to
# sync.deep_index_batch_size per sync pass. This increases IMAP traffic,
# application-database/search-storage usage and indexing load. Production default
# is false. When false,
# the existing per-account deep_index preference may still opt an account in.
deep_index = false
# Search outbox worker cadence/batch; these do NOT control IMAP body downloads.
worker_interval_secs = 2 worker_interval_secs = 2
worker_batch_size = 100 worker_batch_size = 100
worker_max_attempts = 8 worker_max_attempts = 8
# username = "rustbox" # username = "rustbox"
# password = "..." # password = "..."
# RustBox 0.2 blob/search backends
# Blob storage is independent from the SQL database. PostgreSQL/MySQL can still use
# local filesystem storage in single-node mode. In cluster mode use S3-compatible
# storage or a genuinely shared POSIX filesystem (NFS/CephFS/GlusterFS).
# Administrator-only system diagnostics endpoint. Disabled by default.
# When enabled it exposes a redacted runtime/configuration report plus live probes
# for SQL, session persistence (database/Redis), blob storage (filesystem/S3),
# search (Tantivy/OpenSearch), IMAP IDLE counters and security telemetry.
# Access requires BOTH an allowed source network and the configured auth method.
# Secrets (passwords, tokens, S3 keys, session keys and URL credentials) are never returned.
[diagnostics_endpoint]
enabled = false
path = "/api/system/diagnostics"
# Default/fail-safe allowlist: only this host. Add trusted LAN/pod/monitoring CIDRs explicitly.
allowed_networks = ["127.0.0.1/32", "::1/128"]
# token | basic. Credentials should normally come from .env / secret manager, not TOML.
auth = "token"
# token = "replace-with-at-least-24-random-characters"
# basic_username = "rustbox-monitor"
# basic_password = "replace-with-a-long-random-password"
# E-mail addresses and stable user hashes are omitted by default; aggregate counts remain available.
include_user_identifiers = false
# Timeout applied independently to database/session/storage/search probes.
probe_timeout_secs = 5
# Rolling in-memory window used for login failures, 403, 429 and 5xx attack/problem signals.
security_window_secs = 900
+49 -3
View File
@@ -4,6 +4,17 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT" cd "$ROOT"
# Local development settings live in .env. RustBox reads environment variables
# from the process; it does not parse dotenv files itself. Export the file here
# before applying development defaults so explicit .env values always win.
ENV_FILE="${RUSTBOX_ENV_FILE:-$ROOT/.env}"
if [ -f "$ENV_FILE" ]; then
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
fi
if ! command -v cargo >/dev/null 2>&1; then if ! command -v cargo >/dev/null 2>&1; then
echo "error: Rust/Cargo is required" >&2 echo "error: Rust/Cargo is required" >&2
exit 1 exit 1
@@ -12,7 +23,28 @@ fi
mkdir -p "$ROOT/.dev/data" mkdir -p "$ROOT/.dev/data"
export RUSTBOX_CONFIG="${RUSTBOX_CONFIG:-$ROOT/config.dev.toml}" export RUSTBOX_CONFIG="${RUSTBOX_CONFIG:-$ROOT/config.dev.toml}"
export DATA_DIR="${DATA_DIR:-$ROOT/.dev/data}" export DATA_DIR="${DATA_DIR:-$ROOT/.dev/data}"
export RUST_LOG="${RUST_LOG:-rustbox=debug,tower_http=info}"
# Keep application debugging useful without enabling verbose INFO/DEBUG output
# from every dependency. A legacy/simple RUST_LOG level from .env is scoped to
# RustBox automatically; explicit directive filters (containing '=' or ',') are
# respected exactly as provided.
case "${RUST_LOG:-}" in
""|debug)
export RUST_LOG="warn,rustbox=debug,tower_http=info"
;;
trace)
export RUST_LOG="warn,rustbox=trace,tower_http=debug"
;;
info)
export RUST_LOG="warn,rustbox=info,tower_http=info"
;;
warn|error|off)
export RUST_LOG="${RUST_LOG}"
;;
*)
export RUST_LOG="${RUST_LOG}"
;;
esac
if [ ! -f "$RUSTBOX_CONFIG" ]; then if [ ! -f "$RUSTBOX_CONFIG" ]; then
echo "error: config not found: $RUSTBOX_CONFIG" >&2 echo "error: config not found: $RUSTBOX_CONFIG" >&2
@@ -20,8 +52,22 @@ if [ ! -f "$RUSTBOX_CONFIG" ]; then
fi fi
echo "RustBox: http://127.0.0.1:3001" echo "RustBox: http://127.0.0.1:3001"
echo "Config: $RUSTBOX_CONFIG" display_path() {
echo "Data: $DATA_DIR" local value="$1"
if [[ "$value" == "$ROOT"/* ]]; then
printf './%s' "${value#"$ROOT"/}"
else
printf '%s' "$(basename "$value")"
fi
}
echo "Config: $(display_path "$RUSTBOX_CONFIG")"
echo "Data: $(display_path "$DATA_DIR")"
if [ -f "$ENV_FILE" ]; then
echo "Env: $(display_path "$ENV_FILE")"
fi
echo "IMAP: ${IMAP_HOST:-localhost}:${IMAP_PORT:-993}"
echo "SMTP: ${SMTP_HOST:-${IMAP_HOST:-localhost}}:${SMTP_PORT:-587}"
echo echo
cargo run --quiet --bin rustbox -- --check-config cargo run --quiet --bin rustbox -- --check-config
+10 -1
View File
@@ -15,7 +15,7 @@ SMTP_PORT=587
TLS_ENABLED=true TLS_ENABLED=true
# Application logging. # Application logging.
RUST_LOG=info RUST_LOG=warn,rustbox=info,tower_http=info
# Shared session encryption key. Required to be identical on all RustBox nodes. # Shared session encryption key. Required to be identical on all RustBox nodes.
# Replace for any non-local deployment. # Replace for any non-local deployment.
@@ -52,3 +52,12 @@ COMPOSE_PROJECT_NAME=rustbox
# development = cookies work over plain HTTP; production = Secure cookies, requires HTTPS # development = cookies work over plain HTTP; production = Secure cookies, requires HTTPS
ENVIRONMENT=development ENVIRONMENT=development
# Administrator-only diagnostics endpoint (requires corresponding TOML section).
# DIAGNOSTICS_ENDPOINT__ENABLED=true
# DIAGNOSTICS_ENDPOINT__AUTH=token
# DIAGNOSTICS_ENDPOINT__TOKEN=replace-with-at-least-24-random-characters
# Alternative Basic Auth:
# DIAGNOSTICS_ENDPOINT__AUTH=basic
# DIAGNOSTICS_ENDPOINT__BASIC_USERNAME=rustbox-monitor
# DIAGNOSTICS_ENDPOINT__BASIC_PASSWORD=replace-with-a-long-random-password
+2 -2
View File
@@ -80,7 +80,7 @@ cp .env.example .env
| `SMTP_HOST` | `mail.example.com` | all | SMTP server hostname used for sending mail. | | `SMTP_HOST` | `mail.example.com` | all | SMTP server hostname used for sending mail. |
| `SMTP_PORT` | `587` | all | SMTP submission port. | | `SMTP_PORT` | `587` | all | SMTP submission port. |
| `TLS_ENABLED` | `true` | all | Enables TLS for mail connections according to RustBox configuration. | | `TLS_ENABLED` | `true` | all | Enables TLS for mail connections according to RustBox configuration. |
| `RUST_LOG` | `info` | all | Rust logging filter/level. | | `RUST_LOG` | `warn,rustbox=info,tower_http=info` | all | Rust logging filter. Keeps dependencies quiet by default; use explicit target directives for dependency debugging. |
| `SESSION_ENCRYPTION_KEY` | development key | PostgreSQL, MySQL | Shared key used to encrypt session data. All application nodes must use the same value. Replace it outside local testing. | | `SESSION_ENCRYPTION_KEY` | development key | PostgreSQL, MySQL | Shared key used to encrypt session data. All application nodes must use the same value. Replace it outside local testing. |
| `POSTGRES_PASSWORD` | `rustbox-dev-password` | PostgreSQL | Password for PostgreSQL user `rustbox`; also used in `DATABASE_URL`. | | `POSTGRES_PASSWORD` | `rustbox-dev-password` | PostgreSQL | Password for PostgreSQL user `rustbox`; also used in `DATABASE_URL`. |
| `S3_ACCESS_KEY` | `rustbox` | PostgreSQL/S3 | Access key for the bundled S3-compatible SILO service and RustBox S3 client. | | `S3_ACCESS_KEY` | `rustbox` | PostgreSQL/S3 | Access key for the bundled S3-compatible SILO service and RustBox S3 client. |
@@ -201,4 +201,4 @@ docker compose -p rustbox -f compose-postgres-s3-opensearch.yml exec opensearch
-d '{"index.blocks.read_only_allow_delete":null}' -d '{"index.blocks.read_only_allow_delete":null}'
``` ```
RustBox 0.2.6 retains the OpenSearch outbox backoff introduced in 0.2.2, so these 429/flood-stage responses no longer produce one warning per queued event on a full disk. RustBox 0.2.8 retains the OpenSearch outbox backoff introduced in 0.2.2, so these 429/flood-stage responses no longer produce one warning per queued event on a full disk.
+1 -1
View File
@@ -23,7 +23,7 @@ x-rustbox-common: &rustbox-common
SMTP_HOST: ${SMTP_HOST:-mail.example.com} SMTP_HOST: ${SMTP_HOST:-mail.example.com}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
TLS_ENABLED: ${TLS_ENABLED:-true} TLS_ENABLED: ${TLS_ENABLED:-true}
RUST_LOG: ${RUST_LOG:-info} RUST_LOG: ${RUST_LOG:-warn,rustbox=info,tower_http=info}
DATABASE_URL: postgres://rustbox:${POSTGRES_PASSWORD:-rustbox-dev-password}@postgres:5432/rustbox DATABASE_URL: postgres://rustbox:${POSTGRES_PASSWORD:-rustbox-dev-password}@postgres:5432/rustbox
SESSION_ENCRYPTION_KEY: ${SESSION_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=} SESSION_ENCRYPTION_KEY: ${SESSION_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
AWS_ACCESS_KEY_ID: ${GARAGE_ACCESS_KEY_ID:-GKb69252a3b0643e8bd08d4cd4} AWS_ACCESS_KEY_ID: ${GARAGE_ACCESS_KEY_ID:-GKb69252a3b0643e8bd08d4cd4}
+1 -1
View File
@@ -52,7 +52,7 @@ services:
SMTP_HOST: ${SMTP_HOST:-mail.example.com} SMTP_HOST: ${SMTP_HOST:-mail.example.com}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
TLS_ENABLED: ${TLS_ENABLED:-true} TLS_ENABLED: ${TLS_ENABLED:-true}
RUST_LOG: ${RUST_LOG:-info} RUST_LOG: ${RUST_LOG:-warn,rustbox=info,tower_http=info}
DATABASE_URL: mysql://rustbox:${MYSQL_PASSWORD:-rustbox-dev-password}@mysql:3306/rustbox DATABASE_URL: mysql://rustbox:${MYSQL_PASSWORD:-rustbox-dev-password}@mysql:3306/rustbox
SESSION_ENCRYPTION_KEY: ${SESSION_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=} SESSION_ENCRYPTION_KEY: ${SESSION_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
volumes: volumes:
+1 -1
View File
@@ -76,7 +76,7 @@ services:
SMTP_HOST: ${SMTP_HOST:-mail.example.com} SMTP_HOST: ${SMTP_HOST:-mail.example.com}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
TLS_ENABLED: ${TLS_ENABLED:-true} TLS_ENABLED: ${TLS_ENABLED:-true}
RUST_LOG: ${RUST_LOG:-info} RUST_LOG: ${RUST_LOG:-warn,rustbox=info,tower_http=info}
DATABASE_URL: postgres://rustbox:${POSTGRES_PASSWORD:-rustbox-dev-password}@postgres:5432/rustbox DATABASE_URL: postgres://rustbox:${POSTGRES_PASSWORD:-rustbox-dev-password}@postgres:5432/rustbox
SESSION_ENCRYPTION_KEY: ${SESSION_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=} SESSION_ENCRYPTION_KEY: ${SESSION_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-rustbox} AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-rustbox}
+1 -1
View File
@@ -17,7 +17,7 @@ services:
SMTP_HOST: ${SMTP_HOST:-mail.example.com} SMTP_HOST: ${SMTP_HOST:-mail.example.com}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
TLS_ENABLED: ${TLS_ENABLED:-true} TLS_ENABLED: ${TLS_ENABLED:-true}
RUST_LOG: ${RUST_LOG:-info} RUST_LOG: ${RUST_LOG:-warn,rustbox=info,tower_http=info}
volumes: volumes:
- ./configs/single-sqlite.toml:/etc/rustbox/rustbox.toml:ro - ./configs/single-sqlite.toml:/etc/rustbox/rustbox.toml:ro
- rustbox-data:/var/lib/rustbox - rustbox-data:/var/lib/rustbox
+21
View File
@@ -21,8 +21,16 @@ path_style = true
allow_http = true allow_http = true
prefix = "mail" prefix = "mail"
[sync]
# Background IMAP reconciliation cadence; active folders still use IDLE.
interval_secs = 30
# Full bodies fetched per pass when search.deep_index is effective.
deep_index_batch_size = 10
[search] [search]
backend = "opensearch" backend = "opensearch"
# Dev: proactively cache/index full message bodies for testing.
deep_index = true
urls = ["http://opensearch:9200"] urls = ["http://opensearch:9200"]
index_prefix = "rustbox" index_prefix = "rustbox"
allow_insecure_http = true allow_insecure_http = true
@@ -30,3 +38,16 @@ required = true
worker_interval_secs = 2 worker_interval_secs = 2
worker_batch_size = 100 worker_batch_size = 100
worker_max_attempts = 8 worker_max_attempts = 8
# Administrator-only system diagnostics. Disabled by default and loopback-only when enabled.
# For monitoring in Docker/Kubernetes, explicitly add the monitoring/pod CIDR and keep
# token/basic credentials in the orchestrator secret store.
[diagnostics_endpoint]
enabled = false
path = "/api/system/diagnostics"
allowed_networks = ["127.0.0.1/32", "::1/128"]
auth = "token"
include_user_identifiers = false
probe_timeout_secs = 5
security_window_secs = 900
+21
View File
@@ -11,8 +11,16 @@ backend = "filesystem"
path = "/mnt/rustbox-shared" path = "/mnt/rustbox-shared"
shared = true shared = true
[sync]
# Background IMAP reconciliation cadence; active folders still use IDLE.
interval_secs = 30
# Full bodies fetched per pass when search.deep_index is effective.
deep_index_batch_size = 10
[search] [search]
backend = "opensearch" backend = "opensearch"
# Production: do not proactively download/store every full message body.
deep_index = false
urls = ["http://opensearch:9200"] urls = ["http://opensearch:9200"]
index_prefix = "rustbox" index_prefix = "rustbox"
allow_insecure_http = true allow_insecure_http = true
@@ -20,3 +28,16 @@ required = true
worker_interval_secs = 2 worker_interval_secs = 2
worker_batch_size = 100 worker_batch_size = 100
worker_max_attempts = 8 worker_max_attempts = 8
# Administrator-only system diagnostics. Disabled by default and loopback-only when enabled.
# For monitoring in Docker/Kubernetes, explicitly add the monitoring/pod CIDR and keep
# token/basic credentials in the orchestrator secret store.
[diagnostics_endpoint]
enabled = false
path = "/api/system/diagnostics"
allowed_networks = ["127.0.0.1/32", "::1/128"]
auth = "token"
include_user_identifiers = false
probe_timeout_secs = 5
security_window_secs = 900
@@ -15,8 +15,16 @@ path_style = true
allow_http = true allow_http = true
prefix = "mail" prefix = "mail"
[sync]
# Background IMAP reconciliation cadence; active folders still use IDLE.
interval_secs = 30
# Full bodies fetched per pass when search.deep_index is effective.
deep_index_batch_size = 10
[search] [search]
backend = "opensearch" backend = "opensearch"
# Production: do not proactively download/store every full message body.
deep_index = false
urls = ["http://opensearch:9200"] urls = ["http://opensearch:9200"]
index_prefix = "rustbox" index_prefix = "rustbox"
allow_insecure_http = true allow_insecure_http = true
@@ -24,3 +32,16 @@ required = true
worker_interval_secs = 2 worker_interval_secs = 2
worker_batch_size = 100 worker_batch_size = 100
worker_max_attempts = 8 worker_max_attempts = 8
# Administrator-only system diagnostics. Disabled by default and loopback-only when enabled.
# For monitoring in Docker/Kubernetes, explicitly add the monitoring/pod CIDR and keep
# token/basic credentials in the orchestrator secret store.
[diagnostics_endpoint]
enabled = false
path = "/api/system/diagnostics"
allowed_networks = ["127.0.0.1/32", "::1/128"]
auth = "token"
include_user_identifiers = false
probe_timeout_secs = 5
security_window_secs = 900
+21
View File
@@ -12,6 +12,27 @@ backend = "filesystem"
path = "/var/lib/rustbox/blobs" path = "/var/lib/rustbox/blobs"
shared = false shared = false
[sync]
# Background IMAP reconciliation cadence; active folders still use IDLE.
interval_secs = 30
# Full bodies fetched per pass when search.deep_index is effective.
deep_index_batch_size = 10
[search] [search]
backend = "local_tantivy" backend = "local_tantivy"
# Production: do not proactively download/store every full message body.
deep_index = false
path = "/var/lib/rustbox/search" path = "/var/lib/rustbox/search"
# Administrator-only system diagnostics. Disabled by default and loopback-only when enabled.
# For monitoring in Docker/Kubernetes, explicitly add the monitoring/pod CIDR and keep
# token/basic credentials in the orchestrator secret store.
[diagnostics_endpoint]
enabled = false
path = "/api/system/diagnostics"
allowed_networks = ["127.0.0.1/32", "::1/128"]
auth = "token"
include_user_identifiers = false
probe_timeout_secs = 5
security_window_secs = 900
+20 -4
View File
@@ -1,8 +1,18 @@
# RustBox 0.2.6 - implementation status # RustBox 0.2.8 - implementation status
This file tracks the `0.2.6` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. This file tracks the `0.2.8` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly.
## 0.2.6 storage/search architecture ## 0.2.8 browser security headers
- `security_headers_enabled` remains opt-in and disabled by default for compatibility; optional browser-security headers are centralized in this middleware, so disabling it removes the RustBox-added security profile while normal response semantics such as `Content-Type` and route-specific caching remain.
- HTML/XHTML documents receive the full document profile: CSP, `X-Frame-Options: DENY`, `Permissions-Policy`, `Cross-Origin-Opener-Policy: same-origin`, `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`.
- The HTML CSP allows only same-origin scripts (`script-src 'self'`) and disables inline script attributes; the theme bootstrap is an external fingerprinted asset. Stylesheets are same-origin only (`style-src 'self'`), while `style-src-attr 'unsafe-inline'` is narrowly retained because the current UI still sets per-element style attributes dynamically.
- Private `/api` responses receive `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `Content-Security-Policy: frame-ancestors 'none'` and legacy-compatible `X-Frame-Options: DENY`. API JSON does not receive the full document CSP, `Permissions-Policy`, COOP or `Referrer-Policy`.
- CSS receives `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`; application assets also receive same-origin CORP without redundant HTML document policies.
- HSTS is deliberately not synthesized by RustBox because the application may sit behind a TLS-terminating reverse proxy and cannot reliably determine the public HTTPS boundary; HSTS belongs at that HTTPS edge.
- The public `/api/auth/policy` is data-minimized to the single login-form capability `rememberMeEnabled`. Optional feature availability (OpenPGP, Sieve, diagnostics, About) is returned only from authenticated `/api/auth/session-policy`; session/remember durations are not exposed to the browser policy endpoints.
## 0.2.8 storage/search architecture
- SQL backend selection (`sqlite`/`mysql`/`pgsql`) is independent from blob storage and search backend selection. - SQL backend selection (`sqlite`/`mysql`/`pgsql`) is independent from blob storage and search backend selection.
- Cluster mode clears an implicit/default `data_dir`; PostgreSQL/MySQL single-node deployments may still intentionally use `data_dir`-backed filesystem storage and local Tantivy. - Cluster mode clears an implicit/default `data_dir`; PostgreSQL/MySQL single-node deployments may still intentionally use `data_dir`-backed filesystem storage and local Tantivy.
@@ -10,7 +20,7 @@ This file tracks the `0.2.6` source snapshot. Storage/search scope and outstandi
- Draft attachment upload/read/delete/snapshot/send paths use the `BlobStorage` abstraction and logical object keys instead of assuming node-local paths. - Draft attachment upload/read/delete/snapshot/send paths use the `BlobStorage` abstraction and logical object keys instead of assuming node-local paths.
- OpenSearch is available as the cluster-capable indexer. Local Tantivy remains single-node only; search may also be disabled. - OpenSearch is available as the cluster-capable indexer. Local Tantivy remains single-node only; search may also be disabled.
- Search mutations use a durable SQL outbox with claim/lease, retry/backoff and dead-letter state; the index remains rebuildable derived state. - Search mutations use a durable SQL outbox with claim/lease, retry/backoff and dead-letter state; the index remains rebuildable derived state.
- V047 adds attachment checksum metadata and the search outbox across SQLite, MySQL and PostgreSQL. - V047 adds attachment checksum metadata and the search outbox across SQLite, MySQL and PostgreSQL. V048 adds monotonic folder-counter generations used by compare-and-set reconciliation of authoritative IMAP counts; V049 is reserved as the next migration number.
- SQL global-schema initialization is centralized in `db::pool::configure()`; database-backed sessions do not repeat startup DDL for SQLite, MySQL or PostgreSQL. - SQL global-schema initialization is centralized in `db::pool::configure()`; database-backed sessions do not repeat startup DDL for SQLite, MySQL or PostgreSQL.
- Docker Compose examples cover standalone SQLite, PostgreSQL + SILO (S3-compatible) + OpenSearch, and MySQL + NFS + OpenSearch. - Docker Compose examples cover standalone SQLite, PostgreSQL + SILO (S3-compatible) + OpenSearch, and MySQL + NFS + OpenSearch.
- Live SILO/MinIO/AWS S3/Ceph RGW, live NFS multi-host, live OpenSearch and full multi-node failover acceptance remain release-environment validation items and are not represented as completed here. - Live SILO/MinIO/AWS S3/Ceph RGW, live NFS multi-host, live OpenSearch and full multi-node failover acceptance remain release-environment validation items and are not represented as completed here.
@@ -26,6 +36,11 @@ This file tracks the `0.2.6` source snapshot. Storage/search scope and outstandi
### Mail and IMAP workflow ### Mail and IMAP workflow
- Folder totals and unread counters use authoritative IMAP `STATUS (... MESSAGES UNSEEN ...)` data. If STATUS is unavailable for a selected mailbox, RustBox falls back to `UID SEARCH UNSEEN`; it never treats SELECT/EXAMINE `UNSEEN` (the first unseen sequence number) as an unread count.
- Folder counter reconciliation runs independently from content synchronization and repairs `MESSAGES/UNSEEN` even when `HIGHESTMODSEQ` is unchanged. A manual counter refresh reuses the cached folder tree while it is fresh and batches STATUS calls on one IMAP session instead of forcing another LIST.
- Read/unread/flag mutations update the affected row/badge optimistically, then reconcile against authoritative IMAP state and publish targeted flag/`FolderStatus` events without replacing the whole message list. The active folder is watched through IMAP IDLE; other folders are reconciled every 30 seconds.
- Large folders may hydrate message headers incrementally, but the sidebar total/unread badge remains server-authoritative. IDLE/periodic reconciliation keeps the CONDSTORE cursor separate from counter refresh, falls back to a full `UID FLAGS` comparison when CONDSTORE is unavailable, and discards partial IMAP fetch streams rather than applying incomplete state. The top-right activity indicator distinguishes active synchronization/indexing from an incomplete local IMAP header cache and exposes backlog details in its tooltip.
- Wide folder badges display `total / unread` while unread is non-zero and total alone at zero; narrow sidebars switch to the compact existing counter presentation.
- IMAP folder list, hierarchy, create, create subfolder, rename, delete, subscribe/unsubscribe, plus collapsible branch navigation with per-account remembered expansion state. - IMAP folder list, hierarchy, create, create subfolder, rename, delete, subscribe/unsubscribe, plus collapsible branch navigation with per-account remembered expansion state.
- Special folders stored per account: Sent, Drafts, Trash, Junk and Archive. Spam/Junk additionally supports a browser-wide default destination with a per-account override and IMAP special-use/name fallback. - Special folders stored per account: Sent, Drafts, Trash, Junk and Archive. Spam/Junk additionally supports a browser-wide default destination with a per-account override and IMAP special-use/name fallback.
- Special-folder references follow folder renames and are cleared when a referenced folder is deleted. - Special-folder references follow folder renames and are cleared when a referenced folder is deleted.
@@ -98,6 +113,7 @@ This file tracks the `0.2.6` source snapshot. Storage/search scope and outstandi
- Account switch/remove/add workflow for multiple authenticated accounts in one browser session. - Account switch/remove/add workflow for multiple authenticated accounts in one browser session.
- Active-session list across browsers while the RustBox process is running, with creation/last-active timestamps, friendly browser/device labels, full User-Agent hover detail, individual revoke and revoke-all-others. - Active-session list across browsers while the RustBox process is running, with creation/last-active timestamps, friendly browser/device labels, full User-Agent hover detail, individual revoke and revoke-all-others.
- Diagnostics panel exposing IMAP capabilities (including persistent custom-keyword support), authenticated SMTP connectivity, ManageSieve status, browser WebSocket state, local synchronization-cache timestamps/counts and live IMAP IDLE task/subscriber counts without returning server host names or credentials. Public liveness (`/api/health`) and local-storage readiness (`/api/ready`) endpoints are available for service supervision. - Diagnostics panel exposing IMAP capabilities (including persistent custom-keyword support), authenticated SMTP connectivity, ManageSieve status, browser WebSocket state, local synchronization-cache timestamps/counts and live IMAP IDLE task/subscriber counts without returning server host names or credentials. Public liveness (`/api/health`) and local-storage readiness (`/api/ready`) endpoints are available for service supervision.
- Optional administrator system diagnostics endpoint (disabled by default) supports explicit IP/CIDR allowlisting plus token/Basic authentication, redacts configured secrets, probes SQL/session/storage/search backends, reports active sessions and IMAP IDLE state, and surfaces bounded node-local security signals for failed logins, 403/429 responses and 5xx failures.
- Search-index diagnostics expose cached/indexed counts, with a rebuild operation from the configured account-data backend. - Search-index diagnostics expose cached/indexed counts, with a rebuild operation from the configured account-data backend.
- IMAP reconnect control drops reusable request-path sessions and verifies a fresh authenticated connection. - IMAP reconnect control drops reusable request-path sessions and verifies a fresh authenticated connection.
- Graceful SIGTERM/Ctrl+C shutdown stops accepting new requests and aborts dedicated IMAP IDLE tasks before process exit. - Graceful SIGTERM/Ctrl+C shutdown stops accepting new requests and aborts dedicated IMAP IDLE tasks before process exit.
+1 -1
View File
@@ -26,7 +26,7 @@ RUSTBOX_CONFIG=./config.dev.toml ./target/release/rustbox --check-config
Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source. Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source.
## Updating an existing checkout to 0.2.6 ## Updating an existing checkout to 0.2.8
Do not overlay the release with `cp *` or another command that skips dotfiles. That can retain stale `.github`, `.env.example`, or `Cargo.lock` files. Do not overlay the release with `cp *` or another command that skips dotfiles. That can retain stale `.github`, `.env.example`, or `Cargo.lock` files.
+10 -2
View File
@@ -1,6 +1,6 @@
# RustBox 0.2.6 storage/search operations # RustBox 0.2.8 storage/search operations
RustBox 0.2.6 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed. RustBox 0.2.8 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed.
## Deployment matrix ## Deployment matrix
@@ -50,6 +50,14 @@ The rebuild removes superseded outbox events older than the rebuild start and ke
If `search.required=false`, an unavailable OpenSearch backend may leave the application usable while the outbox grows; readiness reports degraded search and workers retry later. With `search.required=true`, search health is a startup/readiness requirement. If `search.required=false`, an unavailable OpenSearch backend may leave the application usable while the outbox grows; readiness reports degraded search and workers retry later. With `search.required=true`, search health is a startup/readiness requirement.
### Automatic body cache / deep index
Automatic full-body caching is controlled by `search.deep_index`. When it is `true` and the search backend is enabled, every periodic IMAP reconciliation may fetch full MIME bodies for messages that have headers cached but no body/index yet. `sync.deep_index_batch_size` caps the number of bodies fetched in one pass. Full bodies are stored in the per-account application database/cache and then queued/indexed, so enabling this increases IMAP bandwidth, application-database/search-storage use and indexing CPU/I/O.
`search.deep_index=false` is the production default. It prevents a server-wide forced full-mailbox download, but preserves the existing per-account `deep_index` opt-in. Development configuration sets `search.deep_index=true` so cache/index behavior is exercised without an additional preference change.
`sync.interval_secs` controls the periodic reconciliation cadence. Active folders still use IMAP IDLE for near-real-time events; the periodic pass is the fallback/background reconciliation for counters, flags and headers in other folders and is also the cadence for automatic deep-index batches. Lower values converge faster but create more IMAP requests.
## Failure behavior ## Failure behavior
- SQL unavailable: authoritative mailbox/session/account operations cannot proceed. - SQL unavailable: authoritative mailbox/session/account operations cannot proceed.
+149
View File
@@ -0,0 +1,149 @@
# Administrator system diagnostics endpoint
RustBox can expose a single administrator-only JSON endpoint for operational and security diagnostics. It is intended for local troubleshooting, monitoring agents and cluster operations. It is **disabled by default** and, when enabled without changing the allowlist, accepts only loopback clients.
The endpoint is separate from the user-facing `/api/diagnostics/*` routes. It does not require a RustBox mailbox session. Instead, access requires **both**:
1. the effective client IP to match `diagnostics_endpoint.allowed_networks`, and
2. the configured diagnostics token or Basic Auth credentials.
Requests from a non-allowed address receive HTTP `403`. Requests from an allowed address with missing/invalid diagnostics credentials receive HTTP `401`. Critical backend probe failures return HTTP `503`; healthy or degraded reports return HTTP `200` with `status` set to `ok` or `degraded`.
## Configuration
```toml
[diagnostics_endpoint]
enabled = false
path = "/api/system/diagnostics"
allowed_networks = ["127.0.0.1/32", "::1/128"]
auth = "token" # token | basic
# token = "replace-with-at-least-24-random-characters"
# basic_username = "rustbox-monitor"
# basic_password = "replace-with-a-long-random-password"
include_user_identifiers = false
probe_timeout_secs = 5
security_window_secs = 900
```
`path` must remain below `/api/system/` so it cannot collide with normal application API routes.
### Token authentication
Prefer keeping the token outside TOML, for example in `.env` or an orchestrator secret:
```bash
DIAGNOSTICS_ENDPOINT__ENABLED=true
DIAGNOSTICS_ENDPOINT__AUTH=token
DIAGNOSTICS_ENDPOINT__TOKEN='a-long-random-secret-at-least-24-characters'
```
Example request:
```bash
curl -fsS \
-H "Authorization: Bearer $DIAGNOSTICS_TOKEN" \
http://127.0.0.1:3001/api/system/diagnostics
```
`X-RustBox-Diagnostics-Token` is accepted as an alternative to the Bearer header for monitoring systems that cannot set an `Authorization` header.
### Basic authentication
```toml
[diagnostics_endpoint]
enabled = true
allowed_networks = ["127.0.0.1/32", "::1/128"]
auth = "basic"
```
```bash
DIAGNOSTICS_ENDPOINT__BASIC_USERNAME='rustbox-monitor'
DIAGNOSTICS_ENDPOINT__BASIC_PASSWORD='a-long-random-monitoring-password'
```
Basic Auth must only be used over HTTPS/TLS (directly or at a trusted reverse proxy), because the credentials are not encrypted by the Basic scheme itself.
## Networks and reverse proxies
The network allowlist accepts IPv4/IPv6 addresses and CIDRs. Single addresses may also be specified in TOML.
Example monitoring network:
```toml
[diagnostics_endpoint]
enabled = true
allowed_networks = [
"127.0.0.1/32",
"::1/128",
"10.42.20.0/24",
"2001:db8:42::/64",
]
auth = "token"
```
For a reverse proxy, configure `trusted_proxies` separately. RustBox only accepts `X-Forwarded-For` for diagnostics when the directly connected proxy is trusted. If proxy headers arrive from an untrusted peer, diagnostics fails closed with HTTP `403` rather than treating the proxy address as the real client.
```toml
trusted_proxies = "127.0.0.1/32,10.42.0.0/16"
[diagnostics_endpoint]
enabled = true
allowed_networks = ["10.42.20.0/24"]
auth = "token"
```
A trusted proxy should provide a valid `X-Forwarded-For`. `Forwarded` or `X-Real-IP` without `X-Forwarded-For` is deliberately rejected for this endpoint. In production, also restrict the diagnostics path at the firewall, ingress or reverse-proxy layer.
## Returned data
The report contains:
- application version, process uptime/start time and PID; host-wide CPU, memory, load-average and other machine statistics are deliberately not collected;
- effective non-secret configuration and feature switches;
- IMAP/SMTP/Sieve endpoint configuration without credentials;
- SQL backend, engine version, query latency, account/schema versions, SQLite file size or SQL pool size/idle connection count;
- session persistence health and aggregate active session/user/browser counts for SQL or Redis;
- local-node cached sessions, timestamps and mail endpoints; e-mail/user hash identifiers appear only when `include_user_identifiers=true`;
- filesystem/S3 configuration plus a live storage health probe;
- Tantivy/OpenSearch configuration; OpenSearch adds cluster health, node/shard counts and index document count;
- active IMAP IDLE task/subscriber counts;
- request counters and a bounded in-memory security window for failed logins, access denials, rate-limit responses and server errors;
- configuration warnings for risky settings such as disabled TLS verification, HTTP OpenSearch/S3, global diagnostics allowlists or production browser security headers being disabled.
## Secret handling
The endpoint never returns diagnostics credentials, mailbox passwords, session encryption keys, Redis passwords, S3 access/secret/session keys, OpenSearch passwords or full credential-bearing DSNs. URLs are reduced to scheme/host/port/path and query strings/userinfo are removed. Secret fields are represented only as `...Configured: true|false` where useful.
`include_user_identifiers=false` is the default. With that setting, local session details omit e-mail addresses and stable user hashes while aggregate user/session counts remain visible.
All endpoint responses, including `401` and `403`, carry `Cache-Control: no-store`, `Pragma: no-cache`, `X-Content-Type-Options: nosniff` and `Cross-Origin-Resource-Policy: same-origin` independently of the general browser security-header switch.
## Security signals
The telemetry buffer is bounded and in-memory; it stores only timestamp, event category, source IP and request path. It does **not** store credentials, Authorization headers, cookies, tokens, request bodies or mail content.
Within `security_window_secs` the report includes counts and top source IPs for:
- rejected login attempts;
- HTTP `429` rate limiting;
- HTTP `403` access denials;
- HTTP `5xx` server failures;
- denied diagnostics access.
`bruteForceSignal` becomes true after repeated login rejection/rate-limit activity, while `diagnosticsAttackSignal` flags repeated attempts against the management endpoint. These are preliminary operational signals, not an IDS or an account-lockout mechanism. In a multi-node deployment the counters are node-local; aggregate application/security logs in a central monitoring/SIEM system for cluster-wide detection.
## Process and host metrics
The diagnostics endpoint deliberately avoids host-wide metrics and platform-specific process probes. It does not read `/proc`, query system CPU/RAM/load averages, enumerate host resources or add a system-information dependency. The portable process section is limited to the RustBox PID, while process uptime/start time and request/security counters come from RustBox's own in-memory runtime state. This keeps the endpoint low-overhead and portable across supported operating systems.
## Probe cost
The endpoint performs live backend checks, so it should not be polled at sub-second frequency:
- SQL executes lightweight health/version/schema queries;
- Redis uses `PING` plus bounded `SCAN`/`MGET` of at most 5000 session records and reports `truncated=true` if the cap is hit;
- S3/filesystem health writes and deletes a tiny temporary `_health/...` object;
- OpenSearch queries cluster health and the configured index count;
- each backend probe has its own `probe_timeout_secs` timeout.
A polling interval around tens of seconds is appropriate for typical monitoring. Use the normal `/api/health` or `/api/ready` endpoints for very frequent liveness/readiness checks.
+9 -6
View File
@@ -1,6 +1,6 @@
# Database schemas and migrations # Database schemas and migrations
RustBox 0.2.6 keeps SQL, blob storage and search as independent concerns: RustBox 0.2.8 keeps SQL, blob storage and search as independent concerns:
```text ```text
database_backend = sqlite | mysql | pgsql database_backend = sqlite | mysql | pgsql
@@ -17,16 +17,19 @@ When `session_backend = "database"`, sessions live in the selected SQL database.
migrations/ migrations/
├── sqlite/ ├── sqlite/
│ ├── schema.sql │ ├── schema.sql
│ └── V047__blob_keys_and_search_outbox.sql │ ├── V047__blob_keys_and_search_outbox.sql
│ └── V048__folder_counter_generation.sql
├── mysql/ ├── mysql/
│ ├── schema.sql │ ├── schema.sql
│ └── V047__blob_keys_and_search_outbox.sql │ ├── V047__blob_keys_and_search_outbox.sql
│ └── V048__folder_counter_generation.sql
└── pgsql/ └── pgsql/
├── schema.sql ├── schema.sql
└── V047__blob_keys_and_search_outbox.sql ├── V047__blob_keys_and_search_outbox.sql
└── V048__folder_counter_generation.sql
``` ```
Each `schema.sql` is the current complete schema for fresh installations. The historical compatibility baseline remains **V046**. RustBox 0.2.1 adds **V047**, which adds attachment SHA-256 metadata and the durable search outbox used by external indexing. Each `schema.sql` is the current complete schema for fresh installations. The historical compatibility baseline remains **V046**. RustBox 0.2.1 adds **V047**, which adds attachment SHA-256 metadata and the durable search outbox used by external indexing. RustBox 0.2.8 adds **V048**, which adds the monotonic `folders.counter_generation` used to reject stale IMAP counter writes even when the numeric counts temporarily return to an earlier value.
Account data is isolated inside the selected physical database with a deterministic, collision-checked table prefix derived from the account hash. The `{{p}}` marker in tenant SQL is replaced by RustBox at provisioning/migration time. Sessions are global and therefore are not prefixed. Account data is isolated inside the selected physical database with a deterministic, collision-checked table prefix derived from the account hash. The `{{p}}` marker in tenant SQL is replaced by RustBox at provisioning/migration time. Sessions are global and therefore are not prefixed.
@@ -34,7 +37,7 @@ Account data is isolated inside the selected physical database with a determinis
Every schema-changing release must add the same logical migration for all supported SQL engines and register it in `src/db/pool.rs`. Do not update only `schema.sql`: fresh installs use the baseline while existing installations use the numbered migration path. Every schema-changing release must add the same logical migration for all supported SQL engines and register it in `src/db/pool.rs`. Do not update only `schema.sql`: fresh installs use the baseline while existing installations use the numbered migration path.
The next migration number after RustBox 0.2.6 is **V048**. The next migration number after RustBox 0.2.8 is **V049**.
Redis and Redis Sentinel do not use SQL migrations. Redis and Redis Sentinel do not use SQL migrations.
@@ -0,0 +1 @@
ALTER TABLE {{p}}folders ADD COLUMN counter_generation BIGINT NOT NULL DEFAULT 0;
+2 -1
View File
@@ -1,7 +1,7 @@
-- RustBox 0.2.0 MySQL schema. -- RustBox 0.2.0 MySQL schema.
-- One physical database contains global sessions plus isolated per-account tables. -- One physical database contains global sessions plus isolated per-account tables.
-- Application tables are materialized by replacing {{p}} with a collision-checked prefix. -- Application tables are materialized by replacing {{p}} with a collision-checked prefix.
-- Squashed compatibility baseline: V046. Current runtime schema: V047. -- Squashed compatibility baseline: V046. Current runtime schema: V048.
-- RUSTBOX:GLOBAL-BEGIN -- RUSTBOX:GLOBAL-BEGIN
CREATE TABLE IF NOT EXISTS rustbox_accounts ( CREATE TABLE IF NOT EXISTS rustbox_accounts (
@@ -247,6 +247,7 @@ CREATE TABLE {{p}}folders (
unread_count BIGINT NOT NULL DEFAULT 0, unread_count BIGINT NOT NULL DEFAULT 0,
uid_validity BIGINT NOT NULL DEFAULT 0, uid_validity BIGINT NOT NULL DEFAULT 0,
highest_modseq BIGINT NOT NULL DEFAULT 0, highest_modseq BIGINT NOT NULL DEFAULT 0,
counter_generation BIGINT NOT NULL DEFAULT 0,
updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)),
messages_updated_at VARCHAR(64) messages_updated_at VARCHAR(64)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1 @@
ALTER TABLE {{p}}folders ADD COLUMN counter_generation BIGINT NOT NULL DEFAULT 0;
+2 -1
View File
@@ -1,7 +1,7 @@
-- RustBox 0.2.0 PostgreSQL schema. -- RustBox 0.2.0 PostgreSQL schema.
-- One physical database contains global sessions plus isolated per-account tables. -- One physical database contains global sessions plus isolated per-account tables.
-- Application tables are materialized by replacing {{p}} with a collision-checked prefix. -- Application tables are materialized by replacing {{p}} with a collision-checked prefix.
-- Squashed compatibility baseline: V046. Current runtime schema: V047. -- Squashed compatibility baseline: V046. Current runtime schema: V048.
-- RUSTBOX:GLOBAL-BEGIN -- RUSTBOX:GLOBAL-BEGIN
CREATE TABLE IF NOT EXISTS rustbox_accounts ( CREATE TABLE IF NOT EXISTS rustbox_accounts (
@@ -248,6 +248,7 @@ CREATE TABLE {{p}}folders (
unread_count BIGINT NOT NULL DEFAULT 0, unread_count BIGINT NOT NULL DEFAULT 0,
uid_validity BIGINT NOT NULL DEFAULT 0, uid_validity BIGINT NOT NULL DEFAULT 0,
highest_modseq BIGINT NOT NULL DEFAULT 0, highest_modseq BIGINT NOT NULL DEFAULT 0,
counter_generation BIGINT NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
messages_updated_at TEXT messages_updated_at TEXT
); );
@@ -0,0 +1 @@
ALTER TABLE {{p}}folders ADD COLUMN counter_generation INTEGER NOT NULL DEFAULT 0;
+2 -1
View File
@@ -1,7 +1,7 @@
-- RustBox 0.2.0 SQLite schema. -- RustBox 0.2.0 SQLite schema.
-- One physical database contains global sessions plus isolated per-account tables. -- One physical database contains global sessions plus isolated per-account tables.
-- Application tables are materialized from the tenant section by replacing {{p}}. -- Application tables are materialized from the tenant section by replacing {{p}}.
-- Squashed compatibility baseline: V046. Current runtime schema: V047. -- Squashed compatibility baseline: V046. Current runtime schema: V048.
-- RUSTBOX:GLOBAL-BEGIN -- RUSTBOX:GLOBAL-BEGIN
CREATE TABLE IF NOT EXISTS rustbox_accounts ( CREATE TABLE IF NOT EXISTS rustbox_accounts (
@@ -184,6 +184,7 @@ CREATE TABLE {{p}}folders (
unread_count INTEGER NOT NULL DEFAULT 0, unread_count INTEGER NOT NULL DEFAULT 0,
uid_validity INTEGER NOT NULL DEFAULT 0, uid_validity INTEGER NOT NULL DEFAULT 0,
highest_modseq INTEGER NOT NULL DEFAULT 0, highest_modseq INTEGER NOT NULL DEFAULT 0,
counter_generation INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
messages_updated_at TEXT); messages_updated_at TEXT);
+63 -18
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.6 - packaged production configuration (DEB/RPM) # RustBox 0.2.8 - packaged production configuration (DEB/RPM)
# #
# This file contains all supported RustBox configuration options. # This file contains all supported RustBox configuration options.
# Commented options are optional and do not change the packaged defaults. # Commented options are optional and do not change the packaged defaults.
@@ -39,10 +39,8 @@ default_interface_skin = "apple-mail"
sieve_enabled = true sieve_enabled = true
diagnostics_enabled = true diagnostics_enabled = true
about_enabled = true about_enabled = true
# Browser-facing CSP/frame/referrer/permissions headers. Off by default for compatibility. # Content-type-aware browser security headers. Off by default for compatibility.
security_headers_enabled = false security_headers_enabled = false
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Database # Database
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -212,20 +210,6 @@ pgp_allow_private_export = true
# #
# pgp_enabled = false # pgp_enabled = false
# -----------------------------------------------------------------------------
# Blob storage
# -----------------------------------------------------------------------------
#
# Blob storage is independent from database_backend.
#
# PostgreSQL/MySQL can use a local filesystem in single-node mode.
#
# Cluster deployments must use:
# - S3-compatible object storage, or
# - a genuinely shared POSIX filesystem such as NFS/CephFS/GlusterFS.
#
[storage] [storage]
# filesystem | s3 # filesystem | s3
@@ -284,6 +268,23 @@ retry_timeout_secs = 60
# kms_key_id = "..." # kms_key_id = "..."
# -----------------------------------------------------------------------------
# IMAP synchronization / automatic body cache
# -----------------------------------------------------------------------------
[sync]
# Periodic fallback/background IMAP reconciliation. Active folders use IMAP IDLE,
# while this pass refreshes counters/flags/headers in the rest of the mailbox and
# also runs automatic deep indexing when enabled. Lower values increase IMAP load.
interval_secs = 30
# Maximum full message bodies fetched from IMAP per periodic pass when deep index is
# effective. Larger batches catch up faster but increase bandwidth, application database/
# search-storage usage and indexing CPU/I/O bursts.
deep_index_batch_size = 10
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Search # Search
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -327,6 +328,13 @@ allow_insecure_http = false
request_timeout_secs = 10 request_timeout_secs = 10
# Server-wide automatic full-body cache/index switch. Production default is false
# because enabling it proactively downloads and stores full message bodies. When
# true, all accounts are deep-indexed; when false, an account may still opt in via
# its existing deep_index preference.
deep_index = false
# Search outbox worker cadence/batch; these do NOT control IMAP body downloads.
worker_interval_secs = 2 worker_interval_secs = 2
worker_batch_size = 100 worker_batch_size = 100
worker_max_attempts = 12 worker_max_attempts = 12
@@ -378,6 +386,7 @@ worker_max_attempts = 12
# required = true # required = true
# allow_insecure_http = false # allow_insecure_http = false
# request_timeout_secs = 10 # request_timeout_secs = 10
# deep_index = false
# worker_interval_secs = 2 # worker_interval_secs = 2
# worker_batch_size = 100 # worker_batch_size = 100
# worker_max_attempts = 12 # worker_max_attempts = 12
@@ -431,3 +440,39 @@ worker_max_attempts = 12
# redis_sentinel_master = "mymaster" # redis_sentinel_master = "mymaster"
# redis_sentinel_password = "sentinel-secret" # redis_sentinel_password = "sentinel-secret"
# redis_password = "secret" # redis_password = "secret"
# -----------------------------------------------------------------------------
# Blob storage
# -----------------------------------------------------------------------------
#
# Blob storage is independent from database_backend.
#
# PostgreSQL/MySQL can use a local filesystem in single-node mode.
#
# Cluster deployments must use:
# - S3-compatible object storage, or
# - a genuinely shared POSIX filesystem such as NFS/CephFS/GlusterFS.
#
# -----------------------------------------------------------------------------
# Administrator system diagnostics endpoint
# -----------------------------------------------------------------------------
# Disabled by default. When enabled, the endpoint performs live probes of SQL,
# session persistence/Redis, filesystem/S3, Tantivy/OpenSearch and reports
# process/runtime/IMAP-IDLE/security telemetry. Returned configuration is redacted:
# passwords, auth tokens, S3 secrets, session keys and URL credentials are never emitted.
# Access is an AND condition: source IP/CIDR must be allowed and token/basic auth must pass.
[diagnostics_endpoint]
enabled = false
path = "/api/system/diagnostics"
# Fail-safe default. Add only monitoring/admin networks that really need access.
allowed_networks = ["127.0.0.1/32", "::1/128"]
auth = "token" # token | basic
# Prefer injecting these with a secret manager/environment rather than storing here.
# token = "at-least-24-random-characters"
# basic_username = "rustbox-monitor"
# basic_password = "long-random-password"
include_user_identifiers = false
probe_timeout_secs = 5
security_window_secs = 900
+1 -1
View File
@@ -8,7 +8,7 @@ Type=simple
User=rustbox User=rustbox
Group=rustbox Group=rustbox
Environment=RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml Environment=RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml
#Environment=RUST_LOG=info #Environment=RUST_LOG=warn,rustbox=info,tower_http=info
WorkingDirectory=/var/lib/rustbox WorkingDirectory=/var/lib/rustbox
ExecStart=/usr/local/bin/rustbox ExecStart=/usr/local/bin/rustbox
Restart=on-failure Restart=on-failure
+219 -8
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Static/offline release checks for RustBox 0.2.6. """Static/offline release checks for RustBox 0.2.8.
These checks intentionally avoid pretending to replace `cargo test`: they validate These checks intentionally avoid pretending to replace `cargo test`: they validate
configuration, SQL migrations, Docker examples, query-catalog parity and release configuration, SQL migrations, Docker examples, query-catalog parity and release
@@ -49,14 +49,14 @@ def extract_section(text: str, begin: str, end: str) -> str:
# Release/toolchain metadata. # Release/toolchain metadata.
cargo = tomllib.loads(read("Cargo.toml")) cargo = tomllib.loads(read("Cargo.toml"))
require(cargo["package"]["version"] == "0.2.6", "Cargo package version is 0.2.6") require(cargo["package"]["version"] == "0.2.8", "Cargo package version is 0.2.8")
require(cargo["package"]["rust-version"] == "1.99.1", "Cargo rust-version is 1.99.1") require(cargo["package"]["rust-version"] == "1.99.1", "Cargo rust-version is 1.99.1")
toolchain = tomllib.loads(read("rust-toolchain.toml")) toolchain = tomllib.loads(read("rust-toolchain.toml"))
require(toolchain["toolchain"]["channel"] == "1.99.1", "rust-toolchain pins Rust 1.99.1") require(toolchain["toolchain"]["channel"] == "1.99.1", "rust-toolchain pins Rust 1.99.1")
require((ROOT / "Cargo.lock").is_file(), "Cargo.lock is retained for reproducible application builds") require((ROOT / "Cargo.lock").is_file(), "Cargo.lock is retained for reproducible application builds")
lock = tomllib.loads(read("Cargo.lock")) lock = tomllib.loads(read("Cargo.lock"))
lock_rustbox = next((pkg for pkg in lock.get("package", []) if pkg.get("name") == "rustbox"), {}) lock_rustbox = next((pkg for pkg in lock.get("package", []) if pkg.get("name") == "rustbox"), {})
require(lock_rustbox.get("version") == "0.2.6", "Cargo.lock RustBox package version is 0.2.6") require(lock_rustbox.get("version") == "0.2.8", "Cargo.lock RustBox package version is 0.2.8")
require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script generates Cargo.lock when missing") require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script generates Cargo.lock when missing")
release_script = read("scripts/build-release.sh") release_script = read("scripts/build-release.sh")
release_script_flat = " ".join(release_script.replace("\\", " ").split()) release_script_flat = " ".join(release_script.replace("\\", " ").split())
@@ -69,14 +69,24 @@ require("make_zip.py" in package_script and "sha256sum" in package_script, "rele
require("cargo generate-lockfile" in read(".github/workflows/ci.yml"), "CI regenerates Cargo.lock before --locked checks") require("cargo generate-lockfile" in read(".github/workflows/ci.yml"), "CI regenerates Cargo.lock before --locked checks")
require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate") require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate")
require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate") require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate")
require(read("README.md").startswith("# RustBox 0.2.6"), "README identifies RustBox 0.2.6") require(read("README.md").startswith("# RustBox 0.2.8"), "README identifies RustBox 0.2.8")
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.6"), "release manifest identifies RustBox 0.2.6") require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.8"), "release manifest identifies RustBox 0.2.8")
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.6"), "release validation identifies RustBox 0.2.6") require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.8"), "release validation identifies RustBox 0.2.8")
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.6"), "implementation status identifies RustBox 0.2.6") require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.8"), "implementation status identifies RustBox 0.2.8")
require("V048" in read("RELEASE-MANIFEST.txt") and "V049" in read("RELEASE-MANIFEST.txt"), "release manifest documents V048 and reserves V049")
require("V048" in read("migrations/README.md") and "V049" in read("migrations/README.md"), "migration guide documents V048 and reserves V049")
require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists") require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists")
require('imap_host = ""' not in read("config.dev.toml"), "development config does not override IMAP host with an invalid empty value") require('imap_host = ""' not in read("config.dev.toml"), "development config does not override IMAP host with an invalid empty value")
require(not re.search(r"(?m)^IMAP_HOST=\s*$", read(".env.example")), "environment example does not set an invalid empty IMAP host") require(not re.search(r"(?m)^IMAP_HOST=\s*$", read(".env.example")), "environment example does not set an invalid empty IMAP host")
require("pgp_enabled: bool" in read("src/routes/auth.rs"), "auth policy exposes OpenPGP availability") dev_sh = read("dev.sh")
require('ENV_FILE="${RUSTBOX_ENV_FILE:-$ROOT/.env}"' in dev_sh and '. "$ENV_FILE"' in dev_sh, "development launcher loads project .env")
require(dev_sh.find('. "$ENV_FILE"') < dev_sh.find('cargo run --quiet --bin rustbox -- --check-config'), "development launcher loads .env before config validation")
require('warn,rustbox=debug,tower_http=info' in dev_sh, "development launcher keeps dependencies at WARN while RustBox stays DEBUG")
require('case "${RUST_LOG:-}" in' in dev_sh and 'trace)' in dev_sh and 'info)' in dev_sh, "development launcher scopes legacy/simple global RUST_LOG levels")
require('RUST_LOG=warn,rustbox=debug,tower_http=info' in read(".env.example"), "development environment example does not enable noisy dependency INFO logs")
main_rs = read("src/main.rs")
require('"debug" => "warn,rustbox=debug,tower_http=info"' in main_rs and 'EnvFilter::new(default_log_filter(log_level))' in main_rs, "config log_level controls RustBox verbosity without globally enabling dependency DEBUG/INFO")
require("pub struct SessionPolicyResponse" in read("src/routes/auth.rs") and "pgp_enabled: bool" in read("src/routes/auth.rs"), "authenticated session policy exposes OpenPGP availability")
require("setOpenPgpAvailability" in read("src/web/js/72-openpgp-identities.js"), "UI hides administratively disabled OpenPGP") require("setOpenPgpAvailability" in read("src/web/js/72-openpgp-identities.js"), "UI hides administratively disabled OpenPGP")
storage_rs = read("src/storage/mod.rs") storage_rs = read("src/storage/mod.rs")
require( require(
@@ -95,9 +105,143 @@ config_rs = read("src/config.rs")
routes_mod = read("src/routes/mod.rs") routes_mod = read("src/routes/mod.rs")
require("pub sieve_enabled: bool" in config_rs and "pub diagnostics_enabled: bool" in config_rs and "pub about_enabled: bool" in config_rs, "configuration exposes Sieve, diagnostics and About feature gates") require("pub sieve_enabled: bool" in config_rs and "pub diagnostics_enabled: bool" in config_rs and "pub about_enabled: bool" in config_rs, "configuration exposes Sieve, diagnostics and About feature gates")
require("pub security_headers_enabled: bool" in config_rs and "security_headers_enabled: false" in config_rs, "security headers are configurable and disabled by default") require("pub security_headers_enabled: bool" in config_rs and "security_headers_enabled: false" in config_rs, "security headers are configurable and disabled by default")
require("pub struct SyncConfig" in config_rs and "pub interval_secs: u64" in config_rs and "pub deep_index_batch_size: u32" in config_rs, "IMAP sync cadence and deep-index batch size are configurable")
require("pub deep_index: bool" in config_rs and "deep_index: false" in config_rs, "server-wide automatic deep index is configurable and production-safe by default")
system_diag_rs = read("src/routes/system_diagnostics.rs")
session_rs = read("src/auth/session.rs")
session_persistence_rs = read("src/session_store/mod.rs")
require("pub struct DiagnosticsEndpointConfig" in config_rs and "pub diagnostics_endpoint: DiagnosticsEndpointConfig" in config_rs, "administrator diagnostics endpoint has a dedicated configuration block")
require('default_diagnostics_allowed_networks' in config_rs and '"127.0.0.1/32"' in config_rs and '"::1/128"' in config_rs, "administrator diagnostics allowlist defaults to IPv4/IPv6 loopback")
require('diagnostics_endpoint.path must be an absolute /api/system/' in config_rs and 'diagnostics_endpoint.token must contain at least 24 characters' in config_rs and 'diagnostics_endpoint.basic_password must contain at least 16 characters' in config_rs, "administrator diagnostics validates namespace and authentication strength")
require('if config.diagnostics_endpoint.enabled' in routes_mod and 'get(system_diagnostics::system_diagnostics)' in routes_mod, "administrator diagnostics route is registered only when enabled")
require('diagnostics_client_ip' in system_diag_rs and 'proxy headers received from an untrusted peer' in system_diag_rs, "administrator diagnostics fails closed on untrusted reverse-proxy headers")
require('parsed_diagnostics_allowed_networks' in system_diag_rs and 'return forbidden();' in system_diag_rs, "administrator diagnostics enforces source IP/CIDR allowlist before probes")
require('constant_time_secret_eq' in system_diag_rs and 'basic_credentials' in system_diag_rs and 'bearer_token' in system_diag_rs, "administrator diagnostics supports protected token and Basic authentication")
require('HeaderValue::from_static("no-store")' in system_diag_rs and 'harden_diagnostics_response' in system_diag_rs, "administrator diagnostics responses are explicitly non-cacheable")
require('"database": db_diag' in system_diag_rs and '"storage": {' in system_diag_rs and '"search": search_status' in system_diag_rs and '"sessions": {' in system_diag_rs, "administrator diagnostics reports database, storage, search and session state")
require('bruteForceSignal' in system_diag_rs and 'diagnosticsAttackSignal' in system_diag_rs and 'MAX_SECURITY_EVENTS' in system_diag_rs, "administrator diagnostics includes bounded brute-force/access-denial telemetry")
require('pub struct SessionPersistenceDiagnostics' in session_persistence_rs and 'diagnostics_snapshot' in session_rs, "administrator diagnostics reports local and persisted session state")
require('user_hash: Option<String>' in session_rs and 'include_user_identifiers.then' in session_rs, "administrator diagnostics omits stable user identifiers unless explicitly enabled")
require('schema_aligned: Option<bool>' in read("src/db/pool.rs") and 'expected_schema_version' in read("src/db/pool.rs"), "administrator diagnostics reports database schema alignment")
require('connected_clients' in read("src/session_store/redis_store.rs") and 'used_memory_bytes' in read("src/session_store/redis_store.rs") and 'master_link_status' in read("src/session_store/redis_store.rs"), "administrator diagnostics reports Redis runtime INFO without credentials")
require('"idleConnections"' in system_diag_rs and 'diagnostics_snapshot' in read("src/realtime/idle.rs"), "administrator diagnostics reports active IMAP IDLE connections")
require('cluster_status.eq_ignore_ascii_case("green")' in read("src/search/engine.rs") and 'cluster_status.eq_ignore_ascii_case("red")' in read("src/search/engine.rs"), "administrator diagnostics treats OpenSearch health conservatively")
require('middleware::from_fn(system_diagnostics::observe_requests)' in routes_mod and routes_mod.count('if config.diagnostics_endpoint.enabled') >= 2, "administrator diagnostics telemetry middleware is installed only when the endpoint is enabled")
require('sanitize_url' in system_diag_rs and 'secretKeyConfigured' in system_diag_rs and 'passwordConfigured' in system_diag_rs and 'credentialConfigured' in system_diag_rs, "administrator diagnostics redacts configured credentials and DSN userinfo")
require('/proc/' not in system_diag_rs and 'systemMemoryTotalKiB' not in system_diag_rs and 'loadAverage' not in system_diag_rs and 'availableProcessors' not in system_diag_rs, "administrator diagnostics avoids host-specific /proc and machine-wide resource metrics")
require((ROOT / "docs/SYSTEM_DIAGNOSTICS.md").is_file() and "disabled by default" in read("docs/SYSTEM_DIAGNOSTICS.md") and "HTTP `403`" in read("docs/SYSTEM_DIAGNOSTICS.md"), "administrator diagnostics security and operations are documented")
require("if config.sieve_enabled" in routes_mod and "if config.diagnostics_enabled" in routes_mod, "disabled Sieve and diagnostics routes are not registered") require("if config.sieve_enabled" in routes_mod and "if config.diagnostics_enabled" in routes_mod, "disabled Sieve and diagnostics routes are not registered")
require(".fallback(|| async { axum::http::StatusCode::NOT_FOUND })" in routes_mod, "API namespace has an explicit 404 fallback before the SPA fallback") require(".fallback(|| async { axum::http::StatusCode::NOT_FOUND })" in routes_mod, "API namespace has an explicit 404 fallback before the SPA fallback")
require("if config.security_headers_enabled" in routes_mod and "add_security_headers" in routes_mod, "security-header middleware is opt-in") require("if config.security_headers_enabled" in routes_mod and "add_security_headers" in routes_mod, "security-header middleware is opt-in")
ui_rs = read("src/ui.rs")
pgp_rs = read("src/routes/pgp.rs")
contacts_rs = read("src/routes/contacts.rs")
require("X_CONTENT_TYPE_OPTIONS" not in ui_rs and "x-content-type-options" not in pgp_rs and "x-content-type-options" not in contacts_rs, "security headers are centralized behind the opt-in middleware")
require("response_content_type_is" in routes_mod, "security-header middleware classifies response content types")
require("headers.insert(\n headers.insert(" not in routes_mod, "security-header middleware has no duplicated insert fragment")
require("if is_html || is_css" in routes_mod, "Referrer-Policy is limited to HTML/CSS responses")
require("if is_html {" in routes_mod, "document-only browser policies are limited to HTML responses")
require("script-src 'self'; script-src-attr 'none'" in routes_mod, "HTML CSP permits only same-origin external scripts and disables script attributes")
require("style-src 'self'; style-src-attr 'unsafe-inline'" in routes_mod and "style-src 'self' 'unsafe-inline'" not in routes_mod, "HTML CSP limits unsafe-inline to dynamic style attributes rather than stylesheet blocks")
require("sha256-" not in routes_mod and "script-src 'self' 'unsafe-inline'" not in routes_mod, "HTML CSP does not rely on inline-script hashes or unsafe-inline")
require('headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"))' in routes_mod, "API security middleware forces private responses to no-store")
require(
"HeaderValue::from_static(\"frame-ancestors 'none'\")" in routes_mod
and 'headers.insert("x-frame-options", HeaderValue::from_static("DENY"))' in routes_mod,
"browser API security profile includes explicit frame denial",
)
html_security_start = routes_mod.find("if is_html {")
html_security_end = routes_mod.find("\n response", html_security_start)
require(
html_security_start >= 0
and html_security_end > html_security_start
and routes_mod.count('"permissions-policy"') == 1
and routes_mod.count('"cross-origin-opener-policy"') == 1
and html_security_start < routes_mod.find('"permissions-policy"') < html_security_end
and html_security_start < routes_mod.find('"cross-origin-opener-policy"') < html_security_end,
"API security profile keeps Permissions-Policy and COOP on documents only",
)
require('"cross-origin-resource-policy"' in routes_mod and 'HeaderValue::from_static("same-origin")' in routes_mod, "security middleware applies same-origin CORP where appropriate")
route_tests = read("src/routes/tests.rs")
auth_routes = read("src/routes/auth.rs")
auth_js = read("src/web/js/20-preferences-auth.js")
require("security_headers_are_opt_in_and_content_type_aware" in route_tests, "security-header regression test covers content-type-aware behavior")
require("auth_policy_exposes_only_login_requirements_before_authentication" in route_tests, "public auth-policy minimization has a regression test")
require("pub struct SessionPolicyResponse" in auth_routes and '.route("/session-policy", get(auth::session_policy))' in routes_mod, "feature policy is exposed only behind the authenticated session route")
require('request("/auth/session-policy"' in auth_js and "async function loadSessionPolicy()" in auth_js, "frontend loads optional feature policy only after authentication")
require("script-src 'self' 'unsafe-inline'" in route_tests, "security regression test explicitly rejects unsafe-inline script policy")
security_test_start = route_tests.find("async fn security_headers_are_opt_in_and_content_type_aware()")
security_test_end = route_tests.find("\n#[tokio::test]", security_test_start + 1)
security_test = route_tests[security_test_start:security_test_end]
require(
'api.headers()["content-security-policy"]' in security_test
and "frame-ancestors 'none'" in security_test
and 'api.headers()["x-frame-options"]' in security_test
and '"DENY"' in security_test,
"API regression test enforces browser-facing anti-framing headers",
)
require(
'api.headers().get("permissions-policy").is_none()' in route_tests
and 'api.headers().get("cross-origin-opener-policy").is_none()' in route_tests,
"API regression test omits HTML-only Permissions-Policy and COOP",
)
imap_client_rs = read("src/imap/client.rs")
idle_rs = read("src/realtime/idle.rs")
realtime_sync_rs = read("src/realtime/sync.rs")
require("config.sync.interval_secs" in realtime_sync_rs and "SYNC_INTERVAL_SECS" not in realtime_sync_rs, "periodic IMAP sync interval comes from configuration")
require("config.sync.deep_index_batch_size" in realtime_sync_rs and "DEEP_INDEX_BATCH" not in realtime_sync_rs, "automatic body-cache batch size comes from configuration")
require("config.search.deep_index" in realtime_sync_rs and "force_deep_index" in realtime_sync_rs, "server-wide deep-index setting participates in effective account behavior")
messages_route_rs = read("src/routes/messages/mod.rs")
folders_route_rs = read("src/routes/folders.rs")
folders_js = read("src/web/js/30-folders-tags.js")
reader_js = read("src/web/js/40-messages-reader.js")
realtime_js = read("src/web/js/80-settings-realtime.js")
require('uid_search("UNSEEN")' in imap_client_rs and "folder_status_extended_on_session" in imap_client_rs, "IMAP folder status has an authoritative UNSEEN search fallback")
require('uid_search("UNSEEN")' in idle_rs and "server_unseen" in idle_rs, "IDLE reconciliation counts unseen UIDs instead of using SELECT UNSEEN as a count")
require("tracing::info!(\n tracing::info!(" not in idle_rs, "IDLE handler has no duplicated tracing macro fragment")
require("set_folder_counts" in realtime_sync_rs and "status.unseen != folder.unread_count" in realtime_sync_rs, "periodic sync repairs server folder counters before fast-path skips")
require("MailEvent::FolderStatus" in messages_route_rs and "MessageFlagChange" in messages_route_rs, "message flag mutations publish targeted flag and folder-status events")
folder_counts_route_rs = read("src/routes/folder_counts.rs")
folders_db_rs = read("src/db/folders.rs")
require("pub counter_generation: u64" in folders_db_rs and "expected_generation: u64" in folders_db_rs, "folder counter CAS uses a monotonic database generation")
require("counter_generation_blocks_aba_stale_writer" in folders_db_rs, "folder counter generation has an ABA stale-writer regression test")
require("set_folder_counts_if_unchanged" in folder_counts_route_rs and "for attempt in 0..2" in folder_counts_route_rs, "post-mutation folder counters use CAS with a fresh STATUS retry")
require("reconcile_after_mutation" in messages_route_rs and "counter_snapshot" in messages_route_rs, "message mutations reconcile authoritative counters without stale overwrites")
folder_mgmt_rs = read("src/routes/folder_mgmt.rs")
require("reconcile_after_mutation" in folder_mgmt_rs, "mark-all-read uses the race-safe counter reconciler")
require("get_all_uids_and_flags" in folder_mgmt_rs and "through_uid" in folder_mgmt_rs, "mark-all-read snapshots a UID cutoff so later arrivals stay unread locally")
require("cached_uids" in folder_mgmt_rs and "for uid in &cached_uids" in folder_mgmt_rs, "empty-folder cleanup removes only messages captured before the server mutation")
require("routes_folder_mgmt::Q_002" not in folder_mgmt_rs, "mark-all-read no longer blindly overwrites unread counters with zero")
require("set_folder_counts_if_unchanged" in folders_route_rs, "forced folder refresh protects authoritative counters with compare-and-set")
require("folder_statuses_extended" in folders_route_rs and "pending_sync_count" in folders_route_rs, "folder refresh batches authoritative IMAP status and exposes sync backlog")
require("folder_refresh_uses_server_counts_without_relisting_fresh_folder_tree" in route_tests, "folder refresh regression test covers stale unread count versus authoritative IMAP STATUS")
require("if !cache_fresh" in folders_route_rs and "if query.refresh || !cache_fresh" in folders_route_rs, "folder tree LIST uses TTL while forced refresh updates counters through STATUS")
require("is_noselect" in folders_route_rs and "is_noselect" in realtime_sync_rs, "folder status refresh skips IMAP N\\Noselect hierarchy nodes")
require("folder-count-wide" in folders_js and "folder-count-compact" in folders_js, "folder UI exposes responsive total/unread counters")
index_html = read("src/web/index.html")
require(index_html.find('id="indexing-indicator"') < index_html.find('id="contacts-btn"'), "synchronization/index activity indicator is positioned left of Contacts")
require("folder-separator" in folders_js and ".folder-count .folder-separator" in read("src/web/css/30-mail.css"), "folder total/unread separator is styled independently from the unread value")
require("#message-count" in folders_js and "folderName === state.folder" in folders_js, "authoritative folder totals update the active-folder message count without a list rerender")
require("applyLocalMessageFlags" in reader_js and "updateMessageRowState" in reader_js, "reader flag updates patch individual message rows")
require("applyOptimisticMessageFlag" in reader_js and "rollback = applyOptimisticMessageFlag" in reader_js, "read/unread actions update rows and counters optimistically with guarded rollback")
require("if (payload.eventId)" in realtime_js and "const duplicate = state.realtimeSeenEvents.has(idKey);" in realtime_js and realtime_js.find("if (payload.eventId)") < realtime_js.find("const fingerprint ="), "realtime event dedupe trusts unique eventId before fingerprint fallback")
require("\"eventId\": uuid::Uuid::new_v4().to_string()" in read("src/realtime/ws.rs"), "websocket envelopes carry unique eventId values for lossless client dedupe")
require("MailEvent::FolderAllRead" in read("src/routes/folder_mgmt.rs") and "applyRealtimeFolderAllRead" in realtime_js, "mark-all-read propagates as a targeted realtime event instead of forcing a list reload")
require("update_folder_sync_cursor" in read("src/db/folders.rs") and "Q_031" in read("src/queries/sqlite/queries.rs"), "folder flag-sync cursor is stored independently from authoritative counters")
require('uid_fetch("1:*", "UID FLAGS")' in idle_rs and idle_rs.count("complete.then_some") >= 3, "IDLE fallback reconciles flags and rejects partial IMAP streams")
require(re.search(r"index_message_with_connection\s*\(\s*&transaction", idle_rs) is not None, "IDLE header ingestion enqueues search indexing in the same database transaction")
require("delete_folder_messages(&transaction" in messages_route_rs and "db::header_sync::invalidate(&transaction)" in messages_route_rs and re.search(r"update_folder_sync_status\s*\(\s*&transaction", messages_route_rs) is not None, "UIDVALIDITY reset keeps cache invalidation and folder metadata in one database transaction")
require(
"db::folders::get_folder(conn, &status_name)" in messages_route_rs
and "total_count: folder_state.total_count" in messages_route_rs
and "unread_count: folder_state.unread_count" in messages_route_rs,
"background sync publishes committed folder counters instead of stale network snapshots",
)
require("IMAP cache incomplete" in realtime_js and "Search indexing" in realtime_js, "activity indicator reports IMAP cache and search-index backlog")
require('indicator.classList.toggle("hidden", !busy)' in realtime_js, "activity indicator is hidden whenever synchronization/indexing is idle")
require("setIndexingIndicator(true, 0)" in realtime_js and "setIndexingIndicator(false, 0)" in realtime_js, "manual search reindex drives the activity indicator immediately")
require("MailEvent::IndexingStatus" in read("src/routes/diagnostics.rs") and "Extension(event_bus): Extension<Arc<EventBus>>" in read("src/routes/diagnostics.rs"), "manual search reindex broadcasts indexing activity to connected tabs")
require("deep_index_enabled" in read("src/routes/diagnostics.rs") and "body cache disabled" in realtime_js, "search diagnostics explain whether automatic body caching is enabled")
require('.route("/settings/reset", post(settings::reset_all_settings))' in routes_mod, "authenticated settings reset route is registered") require('.route("/settings/reset", post(settings::reset_all_settings))' in routes_mod, "authenticated settings reset route is registered")
require("db::settings::reset_all" in read("src/routes/settings.rs"), "settings reset route delegates preference reset to the DB layer") require("db::settings::reset_all" in read("src/routes/settings.rs"), "settings reset route delegates preference reset to the DB layer")
require('$("#quick-filter-btn")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false)' in read("src/web/js/20-preferences-auth.js"), "Sieve quick-filter UI disappears when Sieve is disabled") require('$("#quick-filter-btn")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false)' in read("src/web/js/20-preferences-auth.js"), "Sieve quick-filter UI disappears when Sieve is disabled")
@@ -233,11 +377,41 @@ require(pg_s3["storage"]["backend"] == "s3" and pg_s3["search"]["backend"] == "o
require(my_nfs["cluster_mode"] is True and "data_dir" not in my_nfs, "MySQL/NFS cluster config omits data_dir") require(my_nfs["cluster_mode"] is True and "data_dir" not in my_nfs, "MySQL/NFS cluster config omits data_dir")
require(my_nfs["storage"]["backend"] == "filesystem" and my_nfs["storage"]["shared"] is True, "MySQL cluster marks POSIX/NFS storage shared") require(my_nfs["storage"]["backend"] == "filesystem" and my_nfs["storage"]["shared"] is True, "MySQL cluster marks POSIX/NFS storage shared")
require(my_nfs["search"]["backend"] == "opensearch", "MySQL/NFS cluster uses OpenSearch") require(my_nfs["search"]["backend"] == "opensearch", "MySQL/NFS cluster uses OpenSearch")
dev_config = tomllib.loads(read("config.dev.toml"))
prod_config = tomllib.loads(read("config.example.toml"))
packaged_config = tomllib.loads(read("packaging/rustbox.toml"))
require(dev_config["search"]["deep_index"] is True, "development config enables automatic full-body cache/index")
require(prod_config["search"]["deep_index"] is False and packaged_config["search"]["deep_index"] is False, "production configs keep automatic full-body cache/index disabled")
require(prod_config.get("pgp_enabled") is True and "pgp_enabled" not in prod_config.get("search", {}), "production example keeps root-level settings outside TOML search table")
require(packaged_config.get("database_backend") == "sqlite" and packaged_config.get("max_attachment_size_mb") == 50 and packaged_config.get("sieve_tls_mode") == "none" and "database_backend" not in packaged_config.get("search", {}), "packaged production config preserves top-level runtime settings outside TOML search table")
for diagnostics_config in [
"config.dev.toml",
"config.example.toml",
"packaging/rustbox.toml",
"docker/configs/single-sqlite.toml",
"docker/configs/postgres-s3-opensearch.toml",
"docker/configs/ha-garage.toml",
"docker/configs/mysql-nfs-opensearch.toml",
]:
parsed = tomllib.loads(read(diagnostics_config))
diagnostics = parsed.get("diagnostics_endpoint", {})
require(
diagnostics.get("enabled") is False
and diagnostics.get("allowed_networks") == ["127.0.0.1/32", "::1/128"]
and diagnostics.get("path") == "/api/system/diagnostics",
f"diagnostics config is disabled and loopback-only by default: {diagnostics_config}",
)
require(dev_config["sync"]["interval_secs"] == 30 and dev_config["sync"]["deep_index_batch_size"] == 10, "development config documents 30s/10-message sync defaults")
require(prod_config["sync"]["interval_secs"] == 30 and prod_config["sync"]["deep_index_batch_size"] == 10, "production example documents 30s/10-message sync defaults")
require(packaged_config["sync"]["interval_secs"] == 30 and packaged_config["sync"]["deep_index_batch_size"] == 10, "packaged production config documents 30s/10-message sync defaults")
ha = tomllib.loads(read("docker/configs/ha-garage.toml")) ha = tomllib.loads(read("docker/configs/ha-garage.toml"))
require(ha["cluster_mode"] is True and ha["session_backend"] == "redis", "HA/Garage config enables cluster mode with Redis-compatible sessions") require(ha["cluster_mode"] is True and ha["session_backend"] == "redis", "HA/Garage config enables cluster mode with Redis-compatible sessions")
require(ha["redis_url"] == "redis://valkey:6379/", "HA/Garage config targets Valkey for shared sessions") require(ha["redis_url"] == "redis://valkey:6379/", "HA/Garage config targets Valkey for shared sessions")
require(ha["storage"]["backend"] == "s3" and ha["storage"]["endpoint"] == "http://garage:3900", "HA/Garage config stores blobs in Garage S3") require(ha["storage"]["backend"] == "s3" and ha["storage"]["endpoint"] == "http://garage:3900", "HA/Garage config stores blobs in Garage S3")
require(ha["search"]["backend"] == "opensearch", "HA/Garage config uses OpenSearch") require(ha["search"]["backend"] == "opensearch", "HA/Garage config uses OpenSearch")
require(ha["search"]["deep_index"] is True, "development HA example enables automatic deep index")
require(single["search"]["deep_index"] is False and pg_s3["search"]["deep_index"] is False and my_nfs["search"]["deep_index"] is False, "production Docker configs disable automatic full-body cache/index by default")
require(all(cfg["sync"]["interval_secs"] > 0 and cfg["sync"]["deep_index_batch_size"] > 0 for cfg in (single, pg_s3, my_nfs, ha)), "Docker configs provide positive IMAP sync/deep-index tuning values")
dockerfile = read("docker/Dockerfile") dockerfile = read("docker/Dockerfile")
require("FROM rust:1.99.1-trixie AS builder" in dockerfile, "Docker builder pins Rust 1.99.1 on Debian Trixie") require("FROM rust:1.99.1-trixie AS builder" in dockerfile, "Docker builder pins Rust 1.99.1 on Debian Trixie")
require("FROM debian:trixie-slim" in dockerfile, "Docker runtime uses Debian Trixie slim") require("FROM debian:trixie-slim" in dockerfile, "Docker runtime uses Debian Trixie slim")
@@ -295,6 +469,8 @@ try:
require(outbox is not None, "fresh SQLite schema has search_outbox") require(outbox is not None, "fresh SQLite schema has search_outbox")
outbox_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__search_outbox)")} outbox_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__search_outbox)")}
require("claimed_until" in outbox_columns, "fresh SQLite search_outbox separates worker lease from retry availability") require("claimed_until" in outbox_columns, "fresh SQLite search_outbox separates worker lease from retry availability")
folder_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__folders)")}
require("counter_generation" in folder_columns, "fresh SQLite schema has V048 folder counter generation")
sqlite_header_sync = "INSERT INTO u_test__user_meta (key, value) VALUES ('header_sync_generation', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1" sqlite_header_sync = "INSERT INTO u_test__user_meta (key, value) VALUES ('header_sync_generation', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1"
conn.execute(sqlite_header_sync) conn.execute(sqlite_header_sync)
conn.execute(sqlite_header_sync) conn.execute(sqlite_header_sync)
@@ -338,10 +514,44 @@ try:
except Exception as exc: except Exception as exc:
errors.append(f"SQLite V047 migration execution failed: {exc}") errors.append(f"SQLite V047 migration execution failed: {exc}")
# V048 upgrade execution from a V047-style folders table, preserving existing counters.
try:
conn = sqlite3.connect(":memory:")
conn.executescript(
"CREATE TABLE u_test__folders ("
"name TEXT PRIMARY KEY, delimiter TEXT, parent TEXT, flags TEXT NOT NULL DEFAULT '', "
"is_subscribed INTEGER NOT NULL DEFAULT 1, total_count INTEGER NOT NULL DEFAULT 0, "
"unread_count INTEGER NOT NULL DEFAULT 0, uid_validity INTEGER NOT NULL DEFAULT 0, "
"highest_modseq INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL, messages_updated_at TEXT);"
"INSERT INTO u_test__folders "
"(name, total_count, unread_count, uid_validity, highest_modseq, updated_at) VALUES "
"('INBOX', 3444, 12, 77, 9001, '2026-11-01T00:00:00Z');"
)
migration = read("migrations/sqlite/V048__folder_counter_generation.sql").replace("{{p}}", "u_test__")
conn.executescript(migration)
columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__folders)")}
require("counter_generation" in columns, "V048 adds counter_generation to SQLite folders")
row = conn.execute(
"SELECT name, total_count, unread_count, uid_validity, highest_modseq, counter_generation "
"FROM u_test__folders WHERE name='INBOX'"
).fetchone()
require(
row == ('INBOX', 3444, 12, 77, 9001, 0),
"V048 preserves existing folder counters and initializes generation at zero",
)
conn.close()
except Exception as exc:
errors.append(f"SQLite V048 migration execution failed: {exc}")
# Migration/query parity across SQL engines. # Migration/query parity across SQL engines.
for dialect in ("sqlite", "mysql", "pgsql"): for dialect in ("sqlite", "mysql", "pgsql"):
require((ROOT / f"migrations/{dialect}/V047__blob_keys_and_search_outbox.sql").is_file(), f"V047 migration exists for {dialect}") require((ROOT / f"migrations/{dialect}/V047__blob_keys_and_search_outbox.sql").is_file(), f"V047 migration exists for {dialect}")
require((ROOT / f"migrations/{dialect}/V048__folder_counter_generation.sql").is_file(), f"V048 migration exists for {dialect}")
schema_text = read(f"migrations/{dialect}/schema.sql")
require("Current runtime schema: V048" in schema_text and "counter_generation" in schema_text, f"fresh {dialect} schema includes V048 folder counter generation")
queries = read(f"src/queries/{dialect}/queries.rs") queries = read(f"src/queries/{dialect}/queries.rs")
folder_block = re.search(r"pub mod db_folders\s*\{(.*?)\n\}", queries, re.S)
require(folder_block is not None and "Q_032" in folder_block.group(1) and "counter_generation = counter_generation + 1" in folder_block.group(1), f"folder counter CAS increments generation for {dialect}")
block_match = re.search(r"pub mod db_search_outbox\s*\{(.*?)\n\}", queries, re.S) block_match = re.search(r"pub mod db_search_outbox\s*\{(.*?)\n\}", queries, re.S)
require(block_match is not None, f"search outbox query module exists for {dialect}") require(block_match is not None, f"search outbox query module exists for {dialect}")
if block_match: if block_match:
@@ -351,6 +561,7 @@ for dialect in ("sqlite", "mysql", "pgsql"):
pool = read("src/db/pool.rs") pool = read("src/db/pool.rs")
for dialect in ("SQLITE", "MYSQL", "PGSQL"): for dialect in ("SQLITE", "MYSQL", "PGSQL"):
require(f"{dialect}_MIGRATIONS" in pool and "V047__blob_keys_and_search_outbox.sql" in pool, f"runtime migration catalog includes V047 ({dialect.lower()})") require(f"{dialect}_MIGRATIONS" in pool and "V047__blob_keys_and_search_outbox.sql" in pool, f"runtime migration catalog includes V047 ({dialect.lower()})")
require(f"{dialect}_MIGRATIONS" in pool and "V048__folder_counter_generation.sql" in pool, f"runtime migration catalog includes V048 ({dialect.lower()})")
require("const BASE_SCHEMA_VERSION: u32 = 46" in pool, "legacy squashed baseline stays V046") require("const BASE_SCHEMA_VERSION: u32 = 46" in pool, "legacy squashed baseline stays V046")
require("latest_schema_version" in pool, "runtime schema version is computed from migrations") require("latest_schema_version" in pool, "runtime schema version is computed from migrations")
require( require(
+62 -1
View File
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -5,9 +6,10 @@ use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use dashmap::DashMap; use dashmap::DashMap;
use rand::RngCore; use rand::RngCore;
use serde::Serialize;
use crate::session_store::crypto::SessionCrypto; use crate::session_store::crypto::SessionCrypto;
use crate::session_store::{PersistedSession, SessionPersistence}; use crate::session_store::{PersistedSession, SessionPersistence, SessionPersistenceDiagnostics};
pub type SessionId = String; pub type SessionId = String;
pub type BrowserId = String; pub type BrowserId = String;
@@ -54,6 +56,29 @@ pub struct AccountSession {
pub user_agent: String, pub user_agent: String,
} }
#[derive(Debug, Clone, Serialize)]
pub struct DiagnosticSession {
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_hash: Option<String>,
pub imap_host: String,
pub imap_port: u16,
pub smtp_host: String,
pub smtp_port: u16,
pub created_at_unix: u64,
pub last_accessed_unix: u64,
pub user_agent: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionStoreDiagnostics {
pub local_cached_sessions: usize,
pub local_cached_browsers: usize,
pub local_unique_users: usize,
pub sessions: Vec<DiagnosticSession>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct SessionStore { pub struct SessionStore {
sessions: Arc<DashMap<SessionId, AccountSession>>, sessions: Arc<DashMap<SessionId, AccountSession>>,
@@ -480,6 +505,34 @@ impl SessionStore {
self.sessions.is_empty() self.sessions.is_empty()
} }
pub fn diagnostics_snapshot(&self, include_user_identifiers: bool) -> SessionStoreDiagnostics {
let mut users = HashSet::new();
let mut sessions = Vec::new();
for entry in self.sessions.iter() {
let session = entry.value();
users.insert(session.user_hash.clone());
sessions.push(DiagnosticSession {
email: include_user_identifiers.then(|| session.email.clone()),
user_hash: include_user_identifiers.then(|| session.user_hash.clone()),
imap_host: session.imap_host.clone(),
imap_port: session.imap_port,
smtp_host: session.smtp_host.clone(),
smtp_port: session.smtp_port,
created_at_unix: session.created_at_unix,
last_accessed_unix: session.last_accessed_unix,
user_agent: session.user_agent.chars().take(256).collect(),
});
}
sessions.sort_by_key(|session| std::cmp::Reverse(session.last_accessed_unix));
sessions.truncate(100);
SessionStoreDiagnostics {
local_cached_sessions: self.sessions.len(),
local_cached_browsers: self.browsers.len(),
local_unique_users: users.len(),
sessions,
}
}
fn cache_persisted(&self, record: &PersistedSession) -> Result<AccountSession, String> { fn cache_persisted(&self, record: &PersistedSession) -> Result<AccountSession, String> {
let crypto = self let crypto = self
.crypto .crypto
@@ -747,6 +800,14 @@ impl SessionStore {
removed removed
} }
pub async fn persistence_diagnostics(&self) -> Result<SessionPersistenceDiagnostics, String> {
let persistence = self
.persistence
.as_ref()
.ok_or_else(|| "session persistence is not configured".to_string())?;
persistence.diagnostics(Self::unix_now()).await
}
pub async fn purge_expired_shared(&self) { pub async fn purge_expired_shared(&self) {
self.purge_expired(); self.purge_expired();
if let Some(persistence) = self.persistence.as_ref() if let Some(persistence) = self.persistence.as_ref()
+208 -1
View File
@@ -4,7 +4,7 @@ use figment::{
Figment, Figment,
providers::{Env, Format, Toml}, providers::{Env, Format, Toml},
}; };
use ipnet::IpNet; use ipnet::{IpNet, Ipv4Net, Ipv6Net};
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@@ -72,6 +72,64 @@ impl Default for StorageConfig {
} }
} }
#[derive(Debug, Deserialize, Clone)]
pub struct SyncConfig {
#[serde(default = "default_sync_interval_secs")]
pub interval_secs: u64,
#[serde(default = "default_deep_index_batch_size")]
pub deep_index_batch_size: u32,
}
impl Default for SyncConfig {
fn default() -> Self {
Self {
interval_secs: default_sync_interval_secs(),
deep_index_batch_size: default_deep_index_batch_size(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct DiagnosticsEndpointConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_diagnostics_endpoint_path")]
pub path: String,
#[serde(default = "default_diagnostics_allowed_networks")]
pub allowed_networks: Vec<String>,
#[serde(default = "default_diagnostics_auth_mode")]
pub auth: String,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub basic_username: Option<String>,
#[serde(default)]
pub basic_password: Option<String>,
#[serde(default)]
pub include_user_identifiers: bool,
#[serde(default = "default_diagnostics_probe_timeout_secs")]
pub probe_timeout_secs: u64,
#[serde(default = "default_diagnostics_security_window_secs")]
pub security_window_secs: u64,
}
impl Default for DiagnosticsEndpointConfig {
fn default() -> Self {
Self {
enabled: false,
path: default_diagnostics_endpoint_path(),
allowed_networks: default_diagnostics_allowed_networks(),
auth: default_diagnostics_auth_mode(),
token: None,
basic_username: None,
basic_password: None,
include_user_identifiers: false,
probe_timeout_secs: default_diagnostics_probe_timeout_secs(),
security_window_secs: default_diagnostics_security_window_secs(),
}
}
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct SearchConfig { pub struct SearchConfig {
#[serde(default = "default_search_backend")] #[serde(default = "default_search_backend")]
@@ -92,6 +150,8 @@ pub struct SearchConfig {
pub allow_insecure_http: bool, pub allow_insecure_http: bool,
#[serde(default = "default_search_request_timeout_secs")] #[serde(default = "default_search_request_timeout_secs")]
pub request_timeout_secs: u64, pub request_timeout_secs: u64,
#[serde(default)]
pub deep_index: bool,
#[serde(default = "default_search_worker_interval_secs")] #[serde(default = "default_search_worker_interval_secs")]
pub worker_interval_secs: u64, pub worker_interval_secs: u64,
#[serde(default = "default_search_worker_batch_size")] #[serde(default = "default_search_worker_batch_size")]
@@ -112,6 +172,7 @@ impl Default for SearchConfig {
required: false, required: false,
allow_insecure_http: false, allow_insecure_http: false,
request_timeout_secs: default_search_request_timeout_secs(), request_timeout_secs: default_search_request_timeout_secs(),
deep_index: false,
worker_interval_secs: default_search_worker_interval_secs(), worker_interval_secs: default_search_worker_interval_secs(),
worker_batch_size: default_search_worker_batch_size(), worker_batch_size: default_search_worker_batch_size(),
worker_max_attempts: default_search_worker_max_attempts(), worker_max_attempts: default_search_worker_max_attempts(),
@@ -197,6 +258,12 @@ pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub search: SearchConfig, pub search: SearchConfig,
#[serde(default)]
pub sync: SyncConfig,
#[serde(default)]
pub diagnostics_endpoint: DiagnosticsEndpointConfig,
#[serde(default = "default_session_timeout_hours")] #[serde(default = "default_session_timeout_hours")]
pub session_timeout_hours: u64, pub session_timeout_hours: u64,
@@ -363,6 +430,34 @@ fn default_search_request_timeout_secs() -> u64 {
10 10
} }
fn default_sync_interval_secs() -> u64 {
30
}
fn default_deep_index_batch_size() -> u32 {
10
}
fn default_diagnostics_endpoint_path() -> String {
"/api/system/diagnostics".to_string()
}
fn default_diagnostics_allowed_networks() -> Vec<String> {
vec!["127.0.0.1/32".to_string(), "::1/128".to_string()]
}
fn default_diagnostics_auth_mode() -> String {
"token".to_string()
}
fn default_diagnostics_probe_timeout_secs() -> u64 {
5
}
fn default_diagnostics_security_window_secs() -> u64 {
900
}
fn default_search_worker_interval_secs() -> u64 { fn default_search_worker_interval_secs() -> u64 {
2 2
} }
@@ -479,6 +574,8 @@ impl Default for AppConfig {
cluster_mode: false, cluster_mode: false,
storage: StorageConfig::default(), storage: StorageConfig::default(),
search: SearchConfig::default(), search: SearchConfig::default(),
sync: SyncConfig::default(),
diagnostics_endpoint: DiagnosticsEndpointConfig::default(),
session_timeout_hours: default_session_timeout_hours(), session_timeout_hours: default_session_timeout_hours(),
remember_me_enabled: default_remember_me_enabled(), remember_me_enabled: default_remember_me_enabled(),
remember_me_days: default_remember_me_days(), remember_me_days: default_remember_me_days(),
@@ -565,6 +662,30 @@ impl AppConfig {
.collect() .collect()
} }
pub fn parsed_diagnostics_allowed_networks(&self) -> Result<Vec<IpNet>, String> {
self.diagnostics_endpoint
.allowed_networks
.iter()
.map(|entry| {
let trimmed = entry.trim();
if let Ok(network) = trimmed.parse::<IpNet>() {
return Ok(network);
}
let ip = trimmed
.parse::<IpAddr>()
.map_err(|e| format!("invalid diagnostics allowed network '{trimmed}': {e}"))?;
Ok(match ip {
IpAddr::V4(value) => {
IpNet::V4(Ipv4Net::new(value, 32).expect("valid IPv4 prefix"))
}
IpAddr::V6(value) => {
IpNet::V6(Ipv6Net::new(value, 128).expect("valid IPv6 prefix"))
}
})
})
.collect()
}
pub fn parsed_outgoing_headers(&self) -> Result<Vec<(String, String)>, String> { pub fn parsed_outgoing_headers(&self) -> Result<Vec<(String, String)>, String> {
const RESERVED: &[&str] = &[ const RESERVED: &[&str] = &[
"from", "from",
@@ -743,6 +864,86 @@ impl AppConfig {
return Err("Redis sessions require REDIS_URL or REDIS_SENTINEL_NODES + REDIS_SENTINEL_MASTER".to_string()); return Err("Redis sessions require REDIS_URL or REDIS_SENTINEL_NODES + REDIS_SENTINEL_MASTER".to_string());
} }
} }
if self.diagnostics_endpoint.enabled {
let path = self.diagnostics_endpoint.path.trim();
if !path.starts_with("/api/system/") || path.contains('?') || path.contains('#') {
return Err("diagnostics_endpoint.path must be an absolute /api/system/... path without query/fragment".to_string());
}
if self.diagnostics_endpoint.allowed_networks.is_empty() {
return Err(
"diagnostics_endpoint.allowed_networks cannot be empty when enabled"
.to_string(),
);
}
self.parsed_diagnostics_allowed_networks()?;
if self.diagnostics_endpoint.probe_timeout_secs == 0
|| self.diagnostics_endpoint.probe_timeout_secs > 60
{
return Err(
"diagnostics_endpoint.probe_timeout_secs must be between 1 and 60".to_string(),
);
}
if self.diagnostics_endpoint.security_window_secs < 60
|| self.diagnostics_endpoint.security_window_secs > 86_400
{
return Err(
"diagnostics_endpoint.security_window_secs must be between 60 and 86400"
.to_string(),
);
}
match self
.diagnostics_endpoint
.auth
.trim()
.to_ascii_lowercase()
.as_str()
{
"token" => {
let token = self
.diagnostics_endpoint
.token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or("diagnostics_endpoint.token is required when auth=token")?;
if token.len() < 24 {
return Err(
"diagnostics_endpoint.token must contain at least 24 characters"
.to_string(),
);
}
}
"basic" => {
let username = self
.diagnostics_endpoint
.basic_username
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or("diagnostics_endpoint.basic_username is required when auth=basic")?;
let password = self
.diagnostics_endpoint
.basic_password
.as_deref()
.filter(|value| !value.is_empty())
.ok_or("diagnostics_endpoint.basic_password is required when auth=basic")?;
if username.contains(':') {
return Err(
"diagnostics_endpoint.basic_username cannot contain ':'".to_string()
);
}
if password.len() < 16 {
return Err("diagnostics_endpoint.basic_password must contain at least 16 characters".to_string());
}
}
_ => {
return Err(
"diagnostics_endpoint.auth must be one of: token, basic".to_string()
);
}
}
}
let storage_backend = self.effective_storage_backend(); let storage_backend = self.effective_storage_backend();
if !matches!(storage_backend.as_str(), "filesystem" | "s3") { if !matches!(storage_backend.as_str(), "filesystem" | "s3") {
return Err("storage.backend must be one of: filesystem, s3".to_string()); return Err("storage.backend must be one of: filesystem, s3".to_string());
@@ -871,6 +1072,12 @@ impl AppConfig {
{ {
return Err("search timeout/worker settings must be greater than zero".to_string()); return Err("search timeout/worker settings must be greater than zero".to_string());
} }
if self.sync.interval_secs == 0 || self.sync.deep_index_batch_size == 0 {
return Err(
"sync.interval_secs and sync.deep_index_batch_size must be greater than zero"
.to_string(),
);
}
if self.cluster_mode && self.database_backend == "sqlite" { if self.cluster_mode && self.database_backend == "sqlite" {
return Err( return Err(
"CLUSTER_MODE requires a shared mysql/pgsql application database; Redis only replaces session persistence" "CLUSTER_MODE requires a shared mysql/pgsql application database; Redis only replaces session persistence"
+124 -21
View File
@@ -12,6 +12,7 @@ pub struct CachedFolder {
pub unread_count: u32, pub unread_count: u32,
pub uid_validity: u32, pub uid_validity: u32,
pub highest_modseq: u64, pub highest_modseq: u64,
pub counter_generation: u64,
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -68,6 +69,7 @@ pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
.query_map([], |row| { .query_map([], |row| {
let is_subscribed_int: i32 = row.get(4)?; let is_subscribed_int: i32 = row.get(4)?;
let highest_modseq_int: i64 = row.get(8)?; let highest_modseq_int: i64 = row.get(8)?;
let counter_generation_int: i64 = row.get(9)?;
Ok(CachedFolder { Ok(CachedFolder {
name: row.get(0)?, name: row.get(0)?,
delimiter: row.get(1)?, delimiter: row.get(1)?,
@@ -78,6 +80,7 @@ pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
unread_count: row.get(6)?, unread_count: row.get(6)?,
uid_validity: row.get(7)?, uid_validity: row.get(7)?,
highest_modseq: highest_modseq_int as u64, highest_modseq: highest_modseq_int as u64,
counter_generation: counter_generation_int as u64,
}) })
}) })
.map_err(|e| format!("Failed to query folders: {e}"))?; .map_err(|e| format!("Failed to query folders: {e}"))?;
@@ -118,6 +121,7 @@ pub fn touch_all_folders(conn: &Connection) -> Result<(), String> {
Ok(()) Ok(())
} }
#[cfg(test)]
pub fn update_folder_status( pub fn update_folder_status(
conn: &Connection, conn: &Connection,
name: &str, name: &str,
@@ -147,21 +151,6 @@ pub fn update_folder_sync_status(
Ok(()) Ok(())
} }
pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), String> {
let unread: u32 = conn
.query_row(
crate::dbq!(db_folders::Q_008),
params![folder_name],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count unread messages: {e}"))?;
conn.execute(crate::dbq!(db_folders::Q_009), params![unread, folder_name])
.map_err(|e| format!("Failed to update unread_count: {e}"))?;
Ok(())
}
pub fn set_unread_count( pub fn set_unread_count(
conn: &Connection, conn: &Connection,
folder_name: &str, folder_name: &str,
@@ -175,15 +164,74 @@ pub fn set_unread_count(
Ok(()) Ok(())
} }
pub fn mark_folder_messages_fresh(conn: &Connection, folder_name: &str) -> Result<(), String> { pub fn set_folder_counts(
conn.execute(crate::dbq!(db_folders::Q_029), params![folder_name]) conn: &Connection,
.map_err(|e| format!("Failed to mark folder messages fresh: {e}"))?; folder_name: &str,
total_count: u32,
unread_count: u32,
) -> Result<(), String> {
conn.execute(
crate::dbq!(db_folders::Q_030),
params![total_count, unread_count, folder_name],
)
.map_err(|e| format!("Failed to set folder counts: {e}"))?;
Ok(()) Ok(())
} }
pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) -> Result<(), String> { pub fn update_folder_sync_cursor(
conn.execute(crate::dbq!(db_folders::Q_010), params![delta, folder_name]) conn: &Connection,
.map_err(|e| format!("Failed to adjust unread_count: {e}"))?; folder_name: &str,
uid_validity: u32,
highest_modseq: u64,
) -> Result<(), String> {
conn.execute(
crate::dbq!(db_folders::Q_031),
params![uid_validity, highest_modseq as i64, folder_name],
)
.map_err(|e| format!("Failed to update folder sync cursor: {e}"))?;
Ok(())
}
pub fn set_folder_counts_if_unchanged(
conn: &Connection,
folder_name: &str,
expected_generation: u64,
total_count: u32,
unread_count: u32,
) -> Result<bool, String> {
let changed = conn
.execute(
crate::dbq!(db_folders::Q_032),
params![
total_count,
unread_count,
folder_name,
expected_generation as i64
],
)
.map_err(|e| format!("Failed to conditionally set folder counts: {e}"))?;
Ok(changed > 0)
}
pub fn pending_message_sync_count(conn: &Connection) -> Result<u32, String> {
let mut pending = 0u64;
for folder in get_all_folders(conn)? {
if folder
.flags
.split(',')
.any(|flag| flag.trim().eq_ignore_ascii_case("\\Noselect"))
{
continue;
}
let cached = crate::db::messages::count_messages(conn, &folder.name)?;
pending = pending.saturating_add(folder.total_count.saturating_sub(cached) as u64);
}
Ok(pending.min(u32::MAX as u64) as u32)
}
pub fn mark_folder_messages_fresh(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute(crate::dbq!(db_folders::Q_029), params![folder_name])
.map_err(|e| format!("Failed to mark folder messages fresh: {e}"))?;
Ok(()) Ok(())
} }
@@ -237,6 +285,7 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>,
let result = conn.query_row(crate::dbq!(db_folders::Q_015), params![name], |row| { let result = conn.query_row(crate::dbq!(db_folders::Q_015), params![name], |row| {
let is_subscribed_int: i32 = row.get(4)?; let is_subscribed_int: i32 = row.get(4)?;
let highest_modseq_int: i64 = row.get(8)?; let highest_modseq_int: i64 = row.get(8)?;
let counter_generation_int: i64 = row.get(9)?;
Ok(CachedFolder { Ok(CachedFolder {
name: row.get(0)?, name: row.get(0)?,
delimiter: row.get(1)?, delimiter: row.get(1)?,
@@ -247,6 +296,7 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>,
unread_count: row.get(6)?, unread_count: row.get(6)?,
uid_validity: row.get(7)?, uid_validity: row.get(7)?,
highest_modseq: highest_modseq_int as u64, highest_modseq: highest_modseq_int as u64,
counter_generation: counter_generation_int as u64,
}) })
}); });
@@ -433,6 +483,59 @@ mod tests {
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap()); assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
} }
#[test]
fn conditional_folder_counts_reject_stale_writer() {
let conn = open_test_db();
upsert_folder(&conn, "INBOX", None, None, "", true, 3444, 12, 1, 50).unwrap();
let generation = get_folder(&conn, "INBOX")
.unwrap()
.unwrap()
.counter_generation;
assert!(set_folder_counts_if_unchanged(&conn, "INBOX", generation, 3444, 11).unwrap());
assert!(!set_folder_counts_if_unchanged(&conn, "INBOX", generation, 3444, 10).unwrap());
let folder = get_folder(&conn, "INBOX").unwrap().unwrap();
assert_eq!(folder.total_count, 3444);
assert_eq!(folder.unread_count, 11);
}
#[test]
fn counter_generation_blocks_aba_stale_writer() {
let conn = open_test_db();
upsert_folder(&conn, "INBOX", None, None, "", true, 3444, 12, 1, 50).unwrap();
let generation_12 = get_folder(&conn, "INBOX")
.unwrap()
.unwrap()
.counter_generation;
assert!(set_folder_counts_if_unchanged(&conn, "INBOX", generation_12, 3444, 11).unwrap());
let generation_11 = get_folder(&conn, "INBOX")
.unwrap()
.unwrap()
.counter_generation;
assert!(set_folder_counts_if_unchanged(&conn, "INBOX", generation_11, 3444, 12).unwrap());
assert!(!set_folder_counts_if_unchanged(&conn, "INBOX", generation_12, 3444, 10).unwrap());
let folder = get_folder(&conn, "INBOX").unwrap().unwrap();
assert_eq!((folder.total_count, folder.unread_count), (3444, 12));
assert!(folder.counter_generation > generation_12);
}
#[test]
fn sync_cursor_update_does_not_change_authoritative_counts() {
let conn = open_test_db();
upsert_folder(&conn, "INBOX", None, None, "", true, 3444, 12, 1, 50).unwrap();
update_folder_sync_cursor(&conn, "INBOX", 2, 75).unwrap();
let folder = get_folder(&conn, "INBOX").unwrap().unwrap();
assert_eq!(folder.total_count, 3444);
assert_eq!(folder.unread_count, 12);
assert_eq!(folder.uid_validity, 2);
assert_eq!(folder.highest_modseq, 75);
}
#[test] #[test]
fn test_remove_stale_folders() { fn test_remove_stale_folders() {
let conn = open_test_db(); let conn = open_test_db();
+5
View File
@@ -476,6 +476,11 @@ pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(Stri
Ok(result) Ok(result)
} }
pub fn count_unindexed_messages(conn: &Connection) -> Result<u32, String> {
conn.query_row(crate::dbq!(db_messages::Q_015), [], |row| row.get(0))
.map_err(|e| format!("Failed to count unindexed messages: {e}"))
}
pub fn get_all_uids_and_flags( pub fn get_all_uids_and_flags(
conn: &Connection, conn: &Connection,
folder: &str, folder: &str,
+234 -15
View File
@@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex, OnceLock, mpsc};
use rusqlite::OptionalExtension as _; use rusqlite::OptionalExtension as _;
use rusqlite::types::{ToSql, ToSqlOutput, Value}; use rusqlite::types::{ToSql, ToSqlOutput, Value};
use serde::Serialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use sqlx::pool::PoolConnection; use sqlx::pool::PoolConnection;
use sqlx::{Executor as _, MySql, MySqlPool, PgPool, Postgres, Row as SqlxRow}; use sqlx::{Executor as _, MySql, MySqlPool, PgPool, Postgres, Row as SqlxRow};
@@ -20,18 +21,36 @@ const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql");
const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql"); const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql");
const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql"); const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql");
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[( const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
47, (
include_str!("../../migrations/sqlite/V047__blob_keys_and_search_outbox.sql"), 47,
)]; include_str!("../../migrations/sqlite/V047__blob_keys_and_search_outbox.sql"),
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[( ),
47, (
include_str!("../../migrations/mysql/V047__blob_keys_and_search_outbox.sql"), 48,
)]; include_str!("../../migrations/sqlite/V048__folder_counter_generation.sql"),
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[( ),
47, ];
include_str!("../../migrations/pgsql/V047__blob_keys_and_search_outbox.sql"), const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
)]; (
47,
include_str!("../../migrations/mysql/V047__blob_keys_and_search_outbox.sql"),
),
(
48,
include_str!("../../migrations/mysql/V048__folder_counter_generation.sql"),
),
];
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
(
47,
include_str!("../../migrations/pgsql/V047__blob_keys_and_search_outbox.sql"),
),
(
48,
include_str!("../../migrations/pgsql/V048__folder_counter_generation.sql"),
),
];
const TENANT_TABLES: &[&str] = &[ const TENANT_TABLES: &[&str] = &[
"calendar_events", "calendar_events",
@@ -93,6 +112,26 @@ const IMPORT_TABLES: &[&str] = &[
"user_meta", "user_meta",
]; ];
#[derive(Debug, Clone, Serialize)]
pub struct DatabaseDiagnostics {
pub backend: String,
pub status: String,
pub latency_ms: u128,
pub pool_size: Option<u32>,
pub idle_connections: Option<usize>,
pub database_bytes: Option<u64>,
pub engine_version: Option<String>,
pub account_count: Option<u64>,
pub schema_version_min: Option<u32>,
pub schema_version_max: Option<u32>,
pub schema_aligned: Option<bool>,
pub expected_schema_version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
type SqliteDiagnosticsProbe = (u64, Option<u32>, Option<u32>, Option<u64>, String);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DatabaseBackend { pub enum DatabaseBackend {
Sqlite, Sqlite,
@@ -312,6 +351,188 @@ pub fn configured_backend() -> DatabaseBackend {
} }
} }
pub async fn diagnostics() -> DatabaseDiagnostics {
let started = std::time::Instant::now();
let expected = latest_schema_version();
match DATABASE.get() {
Some(ConfiguredDatabase::Sqlite { path }) => {
let path = path.clone();
let result = tokio::task::spawn_blocking(move || -> Result<SqliteDiagnosticsProbe, String> {
let conn = rusqlite::Connection::open(&path)
.map_err(|e| format!("failed to open SQLite diagnostics connection: {e}"))?;
conn.query_row("SELECT 1", [], |_row| Ok(()))
.map_err(|e| format!("SQLite probe failed: {e}"))?;
let engine_version: String = conn
.query_row("SELECT sqlite_version()", [], |row| row.get(0))
.map_err(|e| format!("SQLite version probe failed: {e}"))?;
let (count, min_version, max_version): (i64, Option<i64>, Option<i64>) = conn
.query_row(
"SELECT COUNT(*), MIN(schema_version), MAX(schema_version) FROM rustbox_accounts",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.map_err(|e| format!("SQLite account diagnostics failed: {e}"))?;
let bytes = std::fs::metadata(&path).ok().map(|meta| meta.len());
Ok((
count.max(0) as u64,
min_version.and_then(|value| u32::try_from(value).ok()),
max_version.and_then(|value| u32::try_from(value).ok()),
bytes,
engine_version,
))
}).await;
match result {
Ok(Ok((count, min_version, max_version, bytes, engine_version))) => {
DatabaseDiagnostics {
backend: "sqlite".to_string(),
status: "ok".to_string(),
latency_ms: started.elapsed().as_millis(),
pool_size: None,
idle_connections: None,
database_bytes: bytes,
engine_version: Some(engine_version),
account_count: Some(count),
schema_version_min: min_version,
schema_version_max: max_version,
schema_aligned: Some(
count == 0
|| (min_version == Some(expected) && max_version == Some(expected)),
),
expected_schema_version: expected,
error: None,
}
}
other => DatabaseDiagnostics {
backend: "sqlite".to_string(),
status: "error".to_string(),
latency_ms: started.elapsed().as_millis(),
pool_size: None,
idle_connections: None,
database_bytes: None,
engine_version: None,
account_count: None,
schema_version_min: None,
schema_version_max: None,
schema_aligned: None,
expected_schema_version: expected,
error: Some(match other {
Ok(Err(error)) => error,
Err(error) => format!("SQLite diagnostics task failed: {error}"),
Ok(Ok(_)) => unreachable!(),
}),
},
}
}
Some(ConfiguredDatabase::Mysql { pool, .. }) => {
let result: Result<(i64, Option<i64>, Option<i64>, String), String> = async {
sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(pool)
.await
.map_err(|e| format!("MySQL probe failed: {e}"))?;
let engine_version: String = sqlx::query_scalar("SELECT VERSION()")
.fetch_one(pool)
.await
.map_err(|e| format!("MySQL version probe failed: {e}"))?;
let (count, min, max): (i64, Option<i64>, Option<i64>) = sqlx::query_as("SELECT COUNT(*), MIN(schema_version), MAX(schema_version) FROM rustbox_accounts")
.fetch_one(pool)
.await
.map_err(|e| format!("MySQL account diagnostics failed: {e}"))?;
Ok((count, min, max, engine_version))
}.await;
let (status, account_count, min_version, max_version, engine_version, error) =
match result {
Ok((count, min, max, version)) => (
"ok",
Some(count.max(0) as u64),
min.and_then(|v| u32::try_from(v).ok()),
max.and_then(|v| u32::try_from(v).ok()),
Some(version),
None,
),
Err(error) => ("error", None, None, None, None, Some(error)),
};
DatabaseDiagnostics {
backend: "mysql".to_string(),
status: status.to_string(),
latency_ms: started.elapsed().as_millis(),
pool_size: Some(pool.size()),
idle_connections: Some(pool.num_idle()),
database_bytes: None,
engine_version,
account_count,
schema_version_min: min_version,
schema_version_max: max_version,
schema_aligned: account_count.map(|count| {
count == 0 || (min_version == Some(expected) && max_version == Some(expected))
}),
expected_schema_version: expected,
error,
}
}
Some(ConfiguredDatabase::Pgsql { pool, .. }) => {
let result: Result<(i64, Option<i64>, Option<i64>, String), String> = async {
sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(pool)
.await
.map_err(|e| format!("PostgreSQL probe failed: {e}"))?;
let engine_version: String = sqlx::query_scalar("SHOW server_version")
.fetch_one(pool)
.await
.map_err(|e| format!("PostgreSQL version probe failed: {e}"))?;
let (count, min, max): (i64, Option<i64>, Option<i64>) = sqlx::query_as("SELECT COUNT(*), MIN(schema_version), MAX(schema_version) FROM rustbox_accounts")
.fetch_one(pool)
.await
.map_err(|e| format!("PostgreSQL account diagnostics failed: {e}"))?;
Ok((count, min, max, engine_version))
}.await;
let (status, account_count, min_version, max_version, engine_version, error) =
match result {
Ok((count, min, max, version)) => (
"ok",
Some(count.max(0) as u64),
min.and_then(|v| u32::try_from(v).ok()),
max.and_then(|v| u32::try_from(v).ok()),
Some(version),
None,
),
Err(error) => ("error", None, None, None, None, Some(error)),
};
DatabaseDiagnostics {
backend: "pgsql".to_string(),
status: status.to_string(),
latency_ms: started.elapsed().as_millis(),
pool_size: Some(pool.size()),
idle_connections: Some(pool.num_idle()),
database_bytes: None,
engine_version,
account_count,
schema_version_min: min_version,
schema_version_max: max_version,
schema_aligned: account_count.map(|count| {
count == 0 || (min_version == Some(expected) && max_version == Some(expected))
}),
expected_schema_version: expected,
error,
}
}
None => DatabaseDiagnostics {
backend: "unconfigured".to_string(),
status: "error".to_string(),
latency_ms: started.elapsed().as_millis(),
pool_size: None,
idle_connections: None,
database_bytes: None,
engine_version: None,
account_count: None,
schema_version_min: None,
schema_version_max: None,
schema_aligned: None,
expected_schema_version: expected,
error: Some("database is not configured".to_string()),
},
}
}
pub fn list_account_user_hashes() -> Result<Vec<String>, String> { pub fn list_account_user_hashes() -> Result<Vec<String>, String> {
match DATABASE.get() { match DATABASE.get() {
Some(ConfiguredDatabase::Sqlite { path }) => { Some(ConfiguredDatabase::Sqlite { path }) => {
@@ -1838,7 +2059,7 @@ fn tenant_prefix(user_hash: &str) -> Result<String, String> {
Ok(format!("u_{}__", &normalized[..24])) Ok(format!("u_{}__", &normalized[..24]))
} }
fn latest_schema_version() -> u32 { pub fn latest_schema_version() -> u32 {
let migrations = match configured_backend() { let migrations = match configured_backend() {
DatabaseBackend::Sqlite => SQLITE_MIGRATIONS, DatabaseBackend::Sqlite => SQLITE_MIGRATIONS,
DatabaseBackend::Mysql => MYSQL_MIGRATIONS, DatabaseBackend::Mysql => MYSQL_MIGRATIONS,
@@ -2108,8 +2329,6 @@ fn ensure_server_tenant(
let tenant = tenant_schema(PGSQL_SCHEMA, &prefix)?; let tenant = tenant_schema(PGSQL_SCHEMA, &prefix)?;
let legacy_path = legacy_path.clone(); let legacy_path = legacy_path.clone();
runtime_wait(runtime, async move { runtime_wait(runtime, async move {
// PostgreSQL advisory locks are session-scoped, so they work across all RustBox nodes
// that share this database and disappear automatically with the connection.
let lock_name = format!("rustbox:{prefix}"); let lock_name = format!("rustbox:{prefix}");
let mut lock_conn = pool.acquire().await.map_err(|e| { let mut lock_conn = pool.acquire().await.map_err(|e| {
format!("failed to acquire PostgreSQL provisioning connection: {e}") format!("failed to acquire PostgreSQL provisioning connection: {e}")
+91 -15
View File
@@ -19,6 +19,60 @@ pub(crate) use super::connection::connect;
#[path = "mock.rs"] #[path = "mock.rs"]
pub mod mock; pub mod mock;
async fn folder_status_extended_on_session(
session: &mut async_imap::Session<super::connection::ImapStream>,
folder: &str,
) -> Result<FolderStatusExtended, ImapError> {
if let Ok(mailbox) = session
.status(
folder,
"(MESSAGES UIDNEXT UIDVALIDITY UNSEEN HIGHESTMODSEQ)",
)
.await
&& let Some(unseen) = mailbox.unseen
{
return Ok(FolderStatusExtended {
uid_validity: mailbox.uid_validity.unwrap_or(0),
exists: mailbox.exists,
uid_next: mailbox.uid_next.unwrap_or(0),
unseen,
highest_modseq: mailbox.highest_modseq.unwrap_or(0),
});
}
if let Ok(mailbox) = session
.status(folder, "(MESSAGES UIDNEXT UIDVALIDITY UNSEEN)")
.await
&& let Some(unseen) = mailbox.unseen
{
return Ok(FolderStatusExtended {
uid_validity: mailbox.uid_validity.unwrap_or(0),
exists: mailbox.exists,
uid_next: mailbox.uid_next.unwrap_or(0),
unseen,
highest_modseq: mailbox.highest_modseq.unwrap_or(0),
});
}
let mailbox = match session.select_condstore(folder).await {
Ok(mailbox) => mailbox,
Err(_) => session.select(folder).await.map_err(map_imap_error)?,
};
let unseen = session
.uid_search("UNSEEN")
.await
.map_err(map_imap_error)?
.len() as u32;
Ok(FolderStatusExtended {
uid_validity: mailbox.uid_validity.unwrap_or(0),
exists: mailbox.exists,
uid_next: mailbox.uid_next.unwrap_or(0),
unseen,
highest_modseq: mailbox.highest_modseq.unwrap_or(0),
})
}
#[allow(dead_code)] #[allow(dead_code)]
#[allow(clippy::double_must_use)] #[allow(clippy::double_must_use)]
#[async_trait] #[async_trait]
@@ -170,6 +224,21 @@ pub trait ImapClient: Send + Sync {
folder: &str, folder: &str,
) -> Result<FolderStatusExtended, ImapError>; ) -> Result<FolderStatusExtended, ImapError>;
async fn folder_statuses_extended(
&self,
creds: &ImapCredentials,
folders: &[String],
) -> Result<Vec<(String, FolderStatusExtended)>, ImapError> {
let mut statuses = Vec::with_capacity(folders.len());
for folder in folders {
statuses.push((
folder.clone(),
self.folder_status_extended(creds, folder).await?,
));
}
Ok(statuses)
}
async fn fetch_changed_flags( async fn fetch_changed_flags(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -1073,25 +1142,32 @@ impl ImapClient for RealImapClient {
folder: &str, folder: &str,
) -> Result<FolderStatusExtended, ImapError> { ) -> Result<FolderStatusExtended, ImapError> {
let mut session = self.pool.checkout(creds).await?; let mut session = self.pool.checkout(creds).await?;
let result = folder_status_extended_on_session(&mut session, folder).await;
if result.is_ok() {
session.recycle();
}
result
}
let mailbox = session async fn folder_statuses_extended(
.status( &self,
folder, creds: &ImapCredentials,
"(MESSAGES UIDNEXT UIDVALIDITY UNSEEN HIGHESTMODSEQ)", folders: &[String],
) ) -> Result<Vec<(String, FolderStatusExtended)>, ImapError> {
.await let mut session = self.pool.checkout(creds).await?;
.map_err(map_imap_error)?; let mut statuses = Vec::with_capacity(folders.len());
let result = FolderStatusExtended { for folder in folders {
uid_validity: mailbox.uid_validity.unwrap_or(0), match folder_status_extended_on_session(&mut session, folder).await {
exists: mailbox.exists, Ok(status) => statuses.push((folder.clone(), status)),
uid_next: mailbox.uid_next.unwrap_or(0), Err(error) => {
unseen: mailbox.unseen.unwrap_or(0), tracing::debug!(folder = %folder, error = %error, "Skipping folder STATUS refresh");
highest_modseq: mailbox.highest_modseq.unwrap_or(0), }
}; }
}
session.recycle(); session.recycle();
Ok(result) Ok(statuses)
} }
async fn fetch_changed_flags( async fn fetch_changed_flags(
+14 -1
View File
@@ -186,9 +186,22 @@ async fn run_search_reindex(
Ok(()) Ok(())
} }
fn default_log_filter(log_level: &str) -> String {
let app_level = log_level.trim().to_ascii_lowercase();
match app_level.as_str() {
"trace" => "warn,rustbox=trace,tower_http=debug".to_string(),
"debug" => "warn,rustbox=debug,tower_http=info".to_string(),
"info" => "warn,rustbox=info,tower_http=info".to_string(),
"warn" => "warn,rustbox=warn,tower_http=warn".to_string(),
"error" => "error,rustbox=error,tower_http=error".to_string(),
"off" => "off".to_string(),
_ => "warn,rustbox=info,tower_http=info".to_string(),
}
}
fn init_tracing(log_dir: Option<&str>, log_level: &str) -> Result<(), Box<dyn std::error::Error>> { fn init_tracing(log_dir: Option<&str>, log_level: &str) -> Result<(), Box<dyn std::error::Error>> {
let env_filter = EnvFilter::try_from_default_env() let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(log_level.trim().to_ascii_lowercase())); .unwrap_or_else(|_| EnvFilter::new(default_log_filter(log_level)));
if let Some(log_dir) = log_dir.map(str::trim).filter(|value| !value.is_empty()) { if let Some(log_dir) = log_dir.map(str::trim).filter(|value| !value.is_empty()) {
std::fs::create_dir_all(log_dir)?; std::fs::create_dir_all(log_dir)?;
let log_path = std::path::Path::new(log_dir).join("rustbox.log"); let log_path = std::path::Path::new(log_dir).join("rustbox.log");
+23 -12
View File
@@ -187,24 +187,25 @@ pub mod db_folders {
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
delimiter=VALUES(delimiter), parent=VALUES(parent), flags=VALUES(flags), delimiter=VALUES(delimiter), parent=VALUES(parent), flags=VALUES(flags),
is_subscribed=VALUES(is_subscribed), total_count=VALUES(total_count), unread_count=VALUES(unread_count), is_subscribed=VALUES(is_subscribed), total_count=VALUES(total_count), unread_count=VALUES(unread_count),
uid_validity=VALUES(uid_validity), highest_modseq=VALUES(highest_modseq), updated_at=CURRENT_TIMESTAMP"; uid_validity=VALUES(uid_validity), highest_modseq=VALUES(highest_modseq),
counter_generation=counter_generation + 1, updated_at=CURRENT_TIMESTAMP";
pub const Q_002: &str = "INSERT IGNORE INTO folders pub const Q_002: &str = "INSERT IGNORE INTO folders
(name, delimiter, flags, is_subscribed, updated_at) (name, delimiter, flags, is_subscribed, updated_at)
VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP)"; VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP)";
pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed, pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq total_count, unread_count, uid_validity, highest_modseq, counter_generation
FROM folders FROM folders
ORDER BY name"; ORDER BY name";
pub const Q_004: &str = "DELETE FROM folders"; pub const Q_004: &str = "DELETE FROM folders";
pub const Q_005: &str = "UPDATE folders SET updated_at = CURRENT_TIMESTAMP"; pub const Q_005: &str = "UPDATE folders SET updated_at = CURRENT_TIMESTAMP";
pub const Q_006: &str = "UPDATE folders SET uid_validity = ?, total_count = ?, messages_updated_at = CURRENT_TIMESTAMP pub const Q_006: &str = "UPDATE folders SET uid_validity = ?, total_count = ?, counter_generation = counter_generation + 1, messages_updated_at = CURRENT_TIMESTAMP
WHERE name = ?"; WHERE name = ?";
pub const Q_007: &str = pub const Q_007: &str =
"UPDATE folders SET uid_validity = ?, total_count = ?, highest_modseq = ? "UPDATE folders SET uid_validity = ?, total_count = ?, highest_modseq = ?, counter_generation = counter_generation + 1
WHERE name = ?"; WHERE name = ?";
pub const Q_008: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM messages WHERE folder = ? AND LOCATE(CONCAT(CHAR(92), 'Seen'), flags) = 0"; pub const Q_008: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM messages WHERE folder = ? AND LOCATE(CONCAT(CHAR(92), 'Seen'), flags) = 0";
pub const Q_009: &str = "UPDATE folders SET unread_count = ? WHERE name = ?"; pub const Q_009: &str = "UPDATE folders SET unread_count = ?, counter_generation = counter_generation + 1 WHERE name = ?";
pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS SIGNED) + ?) WHERE name = ?"; pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS SIGNED) + ?), counter_generation = counter_generation + 1 WHERE name = ?";
pub const Q_011: &str = "SELECT EXISTS( pub const Q_011: &str = "SELECT EXISTS(
SELECT 1 FROM folders SELECT 1 FROM folders
WHERE updated_at > DATE_FORMAT(DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? SECOND), '%Y-%m-%d %H:%i:%s') WHERE updated_at > DATE_FORMAT(DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? SECOND), '%Y-%m-%d %H:%i:%s')
@@ -216,14 +217,14 @@ pub mod db_folders {
pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = ?"; pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = ?";
pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = ?"; pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = ?";
pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed, pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq total_count, unread_count, uid_validity, highest_modseq, counter_generation
FROM folders FROM folders
WHERE name = ?"; WHERE name = ?";
pub const Q_016: &str = "DELETE FROM messages WHERE folder = ?"; pub const Q_016: &str = "DELETE FROM messages WHERE folder = ?";
pub const Q_017: &str = "DELETE FROM folders WHERE name = ?"; pub const Q_017: &str = "DELETE FROM folders WHERE name = ?";
pub const Q_018: &str = "SELECT 1"; pub const Q_018: &str = "SELECT 1";
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at) pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, updated_at, messages_updated_at)
SELECT ?, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, CURRENT_TIMESTAMP, messages_updated_at SELECT ?, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, CURRENT_TIMESTAMP, messages_updated_at
FROM folders WHERE name = ?"; FROM folders WHERE name = ?";
pub const Q_020: &str = "UPDATE messages SET folder = ? WHERE folder = ?"; pub const Q_020: &str = "UPDATE messages SET folder = ? WHERE folder = ?";
pub const Q_021: &str = "UPDATE message_tags SET message_folder = ? WHERE message_folder = ?"; pub const Q_021: &str = "UPDATE message_tags SET message_folder = ? WHERE message_folder = ?";
@@ -235,9 +236,13 @@ pub mod db_folders {
pub const Q_026: &str = pub const Q_026: &str =
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'"; "UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'";
pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'"; pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'";
pub const Q_028: &str = "UPDATE folders SET unread_count = ? WHERE name = ?"; pub const Q_028: &str = "UPDATE folders SET unread_count = ?, counter_generation = counter_generation + 1 WHERE name = ?";
pub const Q_029: &str = pub const Q_029: &str =
"UPDATE folders SET messages_updated_at = CURRENT_TIMESTAMP WHERE name = ?"; "UPDATE folders SET messages_updated_at = CURRENT_TIMESTAMP WHERE name = ?";
pub const Q_030: &str = "UPDATE folders SET total_count = ?, unread_count = ?, counter_generation = counter_generation + 1 WHERE name = ?";
pub const Q_031: &str =
"UPDATE folders SET uid_validity = ?, highest_modseq = ? WHERE name = ?";
pub const Q_032: &str = "UPDATE folders SET total_count = ?, unread_count = ?, counter_generation = counter_generation + 1 WHERE name = ? AND counter_generation = ?";
pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})"; pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
} }
@@ -310,6 +315,12 @@ pub mod db_messages {
LIMIT ?"; LIMIT ?";
pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = ?"; pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = ?";
pub const Q_014: &str = "SELECT CAST(SUM(size) AS SIGNED) FROM messages"; pub const Q_014: &str = "SELECT CAST(SUM(size) AS SIGNED) FROM messages";
pub const Q_015: &str = "SELECT COUNT(*) FROM messages
WHERE body_cached = 0
AND lower(folder) NOT IN ('trash', 'junk', 'spam')
AND lower(folder) NOT LIKE '%/trash'
AND lower(folder) NOT LIKE '%/junk'
AND lower(folder) NOT LIKE '%/spam'";
pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS} pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages FROM messages
WHERE folder = ? WHERE folder = ?
@@ -463,9 +474,9 @@ pub mod routes_diagnostics {
pub mod routes_folder_mgmt { pub mod routes_folder_mgmt {
pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN ?2 ELSE CONCAT(flags, ',', ?2) END WHERE folder = ?1 AND LOCATE(?2, flags) = 0"; pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN ?2 ELSE CONCAT(flags, ',', ?2) END WHERE folder = ?1 AND LOCATE(?2, flags) = 0";
pub const Q_002: &str = "UPDATE folders SET unread_count = 0 WHERE name = ?"; pub const Q_002: &str = "UPDATE folders SET unread_count = 0, counter_generation = counter_generation + 1 WHERE name = ?";
pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = ?"; pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = ?";
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = CURRENT_TIMESTAMP WHERE name = ?"; pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, counter_generation = counter_generation + 1, messages_updated_at = CURRENT_TIMESTAMP WHERE name = ?";
} }
pub mod routes_messages_mod { pub mod routes_messages_mod {
+23 -12
View File
@@ -187,25 +187,26 @@ pub mod db_folders {
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP::text) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP::text)
ON CONFLICT (name) DO UPDATE SET delimiter=excluded.delimiter, parent=excluded.parent, flags=excluded.flags, ON CONFLICT (name) DO UPDATE SET delimiter=excluded.delimiter, parent=excluded.parent, flags=excluded.flags,
is_subscribed=excluded.is_subscribed, total_count=excluded.total_count, unread_count=excluded.unread_count, is_subscribed=excluded.is_subscribed, total_count=excluded.total_count, unread_count=excluded.unread_count,
uid_validity=excluded.uid_validity, highest_modseq=excluded.highest_modseq, updated_at=CURRENT_TIMESTAMP::text"; uid_validity=excluded.uid_validity, highest_modseq=excluded.highest_modseq,
counter_generation=folders.counter_generation + 1, updated_at=CURRENT_TIMESTAMP::text";
pub const Q_002: &str = "INSERT INTO folders pub const Q_002: &str = "INSERT INTO folders
(name, delimiter, flags, is_subscribed, updated_at) (name, delimiter, flags, is_subscribed, updated_at)
VALUES ($1, $2, $3, 1, CURRENT_TIMESTAMP::text) ON CONFLICT (name) DO NOTHING"; VALUES ($1, $2, $3, 1, CURRENT_TIMESTAMP::text) ON CONFLICT (name) DO NOTHING";
pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed, pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq total_count, unread_count, uid_validity, highest_modseq, counter_generation
FROM folders FROM folders
ORDER BY name"; ORDER BY name";
pub const Q_004: &str = "DELETE FROM folders"; pub const Q_004: &str = "DELETE FROM folders";
pub const Q_005: &str = "UPDATE folders SET updated_at = CURRENT_TIMESTAMP::text"; pub const Q_005: &str = "UPDATE folders SET updated_at = CURRENT_TIMESTAMP::text";
pub const Q_006: &str = "UPDATE folders SET uid_validity = $1, total_count = $2, messages_updated_at = CURRENT_TIMESTAMP::text pub const Q_006: &str = "UPDATE folders SET uid_validity = $1, total_count = $2, counter_generation = counter_generation + 1, messages_updated_at = CURRENT_TIMESTAMP::text
WHERE name = $3"; WHERE name = $3";
pub const Q_007: &str = pub const Q_007: &str =
"UPDATE folders SET uid_validity = $1, total_count = $2, highest_modseq = $3 "UPDATE folders SET uid_validity = $1, total_count = $2, highest_modseq = $3, counter_generation = counter_generation + 1
WHERE name = $4"; WHERE name = $4";
pub const Q_008: &str = pub const Q_008: &str =
"SELECT COUNT(*) FROM messages WHERE folder = $1 AND strpos(flags, chr(92) || 'Seen') = 0"; "SELECT COUNT(*) FROM messages WHERE folder = $1 AND strpos(flags, chr(92) || 'Seen') = 0";
pub const Q_009: &str = "UPDATE folders SET unread_count = $1 WHERE name = $2"; pub const Q_009: &str = "UPDATE folders SET unread_count = $1, counter_generation = counter_generation + 1 WHERE name = $2";
pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS INTEGER) + $1) WHERE name = $2"; pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS INTEGER) + $1), counter_generation = counter_generation + 1 WHERE name = $2";
pub const Q_011: &str = "SELECT EXISTS( pub const Q_011: &str = "SELECT EXISTS(
SELECT 1 FROM folders SELECT 1 FROM folders
WHERE updated_at > to_char(CURRENT_TIMESTAMP - ($1 * INTERVAL '1 second'), 'YYYY-MM-DD HH24:MI:SS') WHERE updated_at > to_char(CURRENT_TIMESTAMP - ($1 * INTERVAL '1 second'), 'YYYY-MM-DD HH24:MI:SS')
@@ -217,14 +218,14 @@ pub mod db_folders {
pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = $1"; pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = $1";
pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = $1"; pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = $1";
pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed, pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq total_count, unread_count, uid_validity, highest_modseq, counter_generation
FROM folders FROM folders
WHERE name = $1"; WHERE name = $1";
pub const Q_016: &str = "DELETE FROM messages WHERE folder = $1"; pub const Q_016: &str = "DELETE FROM messages WHERE folder = $1";
pub const Q_017: &str = "DELETE FROM folders WHERE name = $1"; pub const Q_017: &str = "DELETE FROM folders WHERE name = $1";
pub const Q_018: &str = "SELECT 1"; pub const Q_018: &str = "SELECT 1";
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at) pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, updated_at, messages_updated_at)
SELECT $1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, CURRENT_TIMESTAMP::text, messages_updated_at SELECT $1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, CURRENT_TIMESTAMP::text, messages_updated_at
FROM folders WHERE name = $2"; FROM folders WHERE name = $2";
pub const Q_020: &str = "UPDATE messages SET folder = $1 WHERE folder = $2"; pub const Q_020: &str = "UPDATE messages SET folder = $1 WHERE folder = $2";
pub const Q_021: &str = "UPDATE message_tags SET message_folder = $1 WHERE message_folder = $2"; pub const Q_021: &str = "UPDATE message_tags SET message_folder = $1 WHERE message_folder = $2";
@@ -236,9 +237,13 @@ pub mod db_folders {
pub const Q_026: &str = pub const Q_026: &str =
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'"; "UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'";
pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'"; pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'";
pub const Q_028: &str = "UPDATE folders SET unread_count = $1 WHERE name = $2"; pub const Q_028: &str = "UPDATE folders SET unread_count = $1, counter_generation = counter_generation + 1 WHERE name = $2";
pub const Q_029: &str = pub const Q_029: &str =
"UPDATE folders SET messages_updated_at = CURRENT_TIMESTAMP::text WHERE name = $1"; "UPDATE folders SET messages_updated_at = CURRENT_TIMESTAMP::text WHERE name = $1";
pub const Q_030: &str = "UPDATE folders SET total_count = $1, unread_count = $2, counter_generation = counter_generation + 1 WHERE name = $3";
pub const Q_031: &str =
"UPDATE folders SET uid_validity = $1, highest_modseq = $2 WHERE name = $3";
pub const Q_032: &str = "UPDATE folders SET total_count = $1, unread_count = $2, counter_generation = counter_generation + 1 WHERE name = $3 AND counter_generation = $4";
pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})"; pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
} }
@@ -310,6 +315,12 @@ pub mod db_messages {
LIMIT $1"; LIMIT $1";
pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = $1"; pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = $1";
pub const Q_014: &str = "SELECT SUM(size)::bigint FROM messages"; pub const Q_014: &str = "SELECT SUM(size)::bigint FROM messages";
pub const Q_015: &str = "SELECT COUNT(*) FROM messages
WHERE body_cached = 0
AND lower(folder) NOT IN ('trash', 'junk', 'spam')
AND lower(folder) NOT LIKE '%/trash'
AND lower(folder) NOT LIKE '%/junk'
AND lower(folder) NOT LIKE '%/spam'";
pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS} pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages FROM messages
WHERE folder = $1 WHERE folder = $1
@@ -467,9 +478,9 @@ pub mod routes_diagnostics {
pub mod routes_folder_mgmt { pub mod routes_folder_mgmt {
pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN $2 ELSE flags || ',' || $2 END WHERE folder = $1 AND POSITION($2 IN flags) = 0"; pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN $2 ELSE flags || ',' || $2 END WHERE folder = $1 AND POSITION($2 IN flags) = 0";
pub const Q_002: &str = "UPDATE folders SET unread_count = 0 WHERE name = $1"; pub const Q_002: &str = "UPDATE folders SET unread_count = 0, counter_generation = counter_generation + 1 WHERE name = $1";
pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = $1"; pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = $1";
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = CURRENT_TIMESTAMP::text WHERE name = $1"; pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, counter_generation = counter_generation + 1, messages_updated_at = CURRENT_TIMESTAMP::text WHERE name = $1";
} }
pub mod routes_messages_mod { pub mod routes_messages_mod {
+28 -13
View File
@@ -178,28 +178,33 @@ pub mod db_folder_preferences {
} }
pub mod db_folders { pub mod db_folders {
pub const Q_001: &str = "INSERT OR REPLACE INTO folders pub const Q_001: &str = "INSERT INTO folders
(name, delimiter, parent, flags, is_subscribed, (name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq, updated_at) total_count, unread_count, uid_validity, highest_modseq, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))"; VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))
ON CONFLICT(name) DO UPDATE SET
delimiter=excluded.delimiter, parent=excluded.parent, flags=excluded.flags,
is_subscribed=excluded.is_subscribed, total_count=excluded.total_count, unread_count=excluded.unread_count,
uid_validity=excluded.uid_validity, highest_modseq=excluded.highest_modseq,
counter_generation=folders.counter_generation + 1, updated_at=datetime('now')";
pub const Q_002: &str = "INSERT OR IGNORE INTO folders pub const Q_002: &str = "INSERT OR IGNORE INTO folders
(name, delimiter, flags, is_subscribed, updated_at) (name, delimiter, flags, is_subscribed, updated_at)
VALUES (?1, ?2, ?3, 1, datetime('now'))"; VALUES (?1, ?2, ?3, 1, datetime('now'))";
pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed, pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq total_count, unread_count, uid_validity, highest_modseq, counter_generation
FROM folders FROM folders
ORDER BY name"; ORDER BY name";
pub const Q_004: &str = "DELETE FROM folders"; pub const Q_004: &str = "DELETE FROM folders";
pub const Q_005: &str = "UPDATE folders SET updated_at = datetime('now')"; pub const Q_005: &str = "UPDATE folders SET updated_at = datetime('now')";
pub const Q_006: &str = "UPDATE folders SET uid_validity = ?1, total_count = ?2, messages_updated_at = datetime('now') pub const Q_006: &str = "UPDATE folders SET uid_validity = ?1, total_count = ?2, counter_generation = counter_generation + 1, messages_updated_at = datetime('now')
WHERE name = ?3"; WHERE name = ?3";
pub const Q_007: &str = pub const Q_007: &str =
"UPDATE folders SET uid_validity = ?1, total_count = ?2, highest_modseq = ?3 "UPDATE folders SET uid_validity = ?1, total_count = ?2, highest_modseq = ?3, counter_generation = counter_generation + 1
WHERE name = ?4"; WHERE name = ?4";
pub const Q_008: &str = pub const Q_008: &str =
"SELECT COUNT(*) FROM messages WHERE folder = ?1 AND flags NOT LIKE '%\\Seen%'"; "SELECT COUNT(*) FROM messages WHERE folder = ?1 AND flags NOT LIKE '%\\Seen%'";
pub const Q_009: &str = "UPDATE folders SET unread_count = ?1 WHERE name = ?2"; pub const Q_009: &str = "UPDATE folders SET unread_count = ?1, counter_generation = counter_generation + 1 WHERE name = ?2";
pub const Q_010: &str = "UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1) WHERE name = ?2"; pub const Q_010: &str = "UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1), counter_generation = counter_generation + 1 WHERE name = ?2";
pub const Q_011: &str = "SELECT EXISTS( pub const Q_011: &str = "SELECT EXISTS(
SELECT 1 FROM folders SELECT 1 FROM folders
WHERE updated_at > datetime('now', printf('-%d seconds', ?1)) WHERE updated_at > datetime('now', printf('-%d seconds', ?1))
@@ -211,14 +216,14 @@ pub mod db_folders {
pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1"; pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1";
pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = ?1"; pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = ?1";
pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed, pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq total_count, unread_count, uid_validity, highest_modseq, counter_generation
FROM folders FROM folders
WHERE name = ?1"; WHERE name = ?1";
pub const Q_016: &str = "DELETE FROM messages WHERE folder = ?1"; pub const Q_016: &str = "DELETE FROM messages WHERE folder = ?1";
pub const Q_017: &str = "DELETE FROM folders WHERE name = ?1"; pub const Q_017: &str = "DELETE FROM folders WHERE name = ?1";
pub const Q_018: &str = "PRAGMA defer_foreign_keys = ON;"; pub const Q_018: &str = "PRAGMA defer_foreign_keys = ON;";
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at) pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, updated_at, messages_updated_at)
SELECT ?1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, datetime('now'), messages_updated_at SELECT ?1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, datetime('now'), messages_updated_at
FROM folders WHERE name = ?2"; FROM folders WHERE name = ?2";
pub const Q_020: &str = "UPDATE messages SET folder = ?1 WHERE folder = ?2"; pub const Q_020: &str = "UPDATE messages SET folder = ?1 WHERE folder = ?2";
pub const Q_021: &str = "UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2"; pub const Q_021: &str = "UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2";
@@ -230,9 +235,13 @@ pub mod db_folders {
pub const Q_026: &str = pub const Q_026: &str =
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'"; "UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'";
pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'"; pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'";
pub const Q_028: &str = "UPDATE folders SET unread_count = ?1 WHERE name = ?2"; pub const Q_028: &str = "UPDATE folders SET unread_count = ?1, counter_generation = counter_generation + 1 WHERE name = ?2";
pub const Q_029: &str = pub const Q_029: &str =
"UPDATE folders SET messages_updated_at = datetime('now') WHERE name = ?1"; "UPDATE folders SET messages_updated_at = datetime('now') WHERE name = ?1";
pub const Q_030: &str = "UPDATE folders SET total_count = ?1, unread_count = ?2, counter_generation = counter_generation + 1 WHERE name = ?3";
pub const Q_031: &str =
"UPDATE folders SET uid_validity = ?1, highest_modseq = ?2 WHERE name = ?3";
pub const Q_032: &str = "UPDATE folders SET total_count = ?1, unread_count = ?2, counter_generation = counter_generation + 1 WHERE name = ?3 AND counter_generation = ?4";
pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})"; pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
} }
@@ -299,6 +308,12 @@ pub mod db_messages {
LIMIT ?1"; LIMIT ?1";
pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = ?1"; pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = ?1";
pub const Q_014: &str = "SELECT SUM(size) FROM messages"; pub const Q_014: &str = "SELECT SUM(size) FROM messages";
pub const Q_015: &str = "SELECT COUNT(*) FROM messages
WHERE body_cached = 0
AND lower(folder) NOT IN ('trash', 'junk', 'spam')
AND lower(folder) NOT LIKE '%/trash'
AND lower(folder) NOT LIKE '%/junk'
AND lower(folder) NOT LIKE '%/spam'";
pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS} pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages FROM messages
WHERE folder = ?1 WHERE folder = ?1
@@ -457,9 +472,9 @@ pub mod routes_diagnostics {
pub mod routes_folder_mgmt { pub mod routes_folder_mgmt {
pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN ?2 ELSE flags || ',' || ?2 END WHERE folder = ?1 AND instr(flags, ?2) = 0"; pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN ?2 ELSE flags || ',' || ?2 END WHERE folder = ?1 AND instr(flags, ?2) = 0";
pub const Q_002: &str = "UPDATE folders SET unread_count = 0 WHERE name = ?1"; pub const Q_002: &str = "UPDATE folders SET unread_count = 0, counter_generation = counter_generation + 1 WHERE name = ?1";
pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = ?1"; pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = ?1";
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = datetime('now') WHERE name = ?1"; pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, counter_generation = counter_generation + 1, messages_updated_at = datetime('now') WHERE name = ?1";
} }
pub mod routes_messages_mod { pub mod routes_messages_mod {
+28
View File
@@ -3,6 +3,12 @@ use std::collections::HashMap;
use serde::Serialize; use serde::Serialize;
use tokio::sync::{RwLock, broadcast}; use tokio::sync::{RwLock, broadcast};
#[derive(Debug, Clone, Serialize)]
pub struct MessageFlagChange {
pub uid: u32,
pub flags: Vec<String>,
}
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", content = "data")] #[serde(tag = "type", content = "data")]
#[allow(dead_code)] #[allow(dead_code)]
@@ -17,10 +23,32 @@ pub enum MailEvent {
FlagsChanged { FlagsChanged {
folder: String, folder: String,
changes: Vec<MessageFlagChange>,
},
FolderAllRead {
folder: String,
through_uid: Option<u32>,
},
FolderStatus {
folder: String,
total_count: u32,
unread_count: u32,
}, },
FolderUpdated, FolderUpdated,
SyncStatus {
active: bool,
folder: Option<String>,
pending: u32,
},
SyncInventory {
pending: u32,
},
IndexingStatus { IndexingStatus {
active: bool, active: bool,
pending: u32, pending: u32,
+334 -111
View File
@@ -2,16 +2,25 @@ use std::sync::Arc;
use dashmap::DashMap; use dashmap::DashMap;
use futures::StreamExt; use futures::StreamExt;
use serde::Serialize;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::imap::client::ImapCredentials; use crate::imap::client::ImapCredentials;
use crate::imap::connection::ImapStream; use crate::imap::connection::ImapStream;
use crate::imap::parse::{decode_rfc2047, flag_to_string, has_attachments, imap_address_to_email}; use crate::imap::parse::{decode_rfc2047, flag_to_string, has_attachments, imap_address_to_email};
use crate::realtime::events::{EventBus, MailEvent}; use crate::realtime::events::{EventBus, MailEvent, MessageFlagChange};
use crate::search::engine::SearchEngine; use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex};
use crate::{db, imap}; use crate::{db, imap};
#[derive(Debug, Clone, Serialize)]
pub struct IdleTaskDiagnostics {
#[serde(skip_serializing_if = "Option::is_none")]
pub user_hash: Option<String>,
pub folder: String,
pub subscribers: usize,
}
pub struct IdleManager { pub struct IdleManager {
tasks: DashMap<(String, String), IdleTask>, tasks: DashMap<(String, String), IdleTask>,
} }
@@ -90,6 +99,21 @@ impl IdleManager {
pub fn subscriber_count(&self) -> usize { pub fn subscriber_count(&self) -> usize {
self.tasks.iter().map(|task| task.value().subscribers).sum() self.tasks.iter().map(|task| task.value().subscribers).sum()
} }
pub fn diagnostics_snapshot(&self, include_user_identifiers: bool) -> Vec<IdleTaskDiagnostics> {
let mut tasks = self
.tasks
.iter()
.map(|task| IdleTaskDiagnostics {
user_hash: include_user_identifiers.then(|| task.key().0.clone()),
folder: task.key().1.clone(),
subscribers: task.value().subscribers,
})
.collect::<Vec<_>>();
tasks.sort_by(|left, right| left.folder.cmp(&right.folder));
tasks.truncate(200);
tasks
}
} }
impl Default for IdleManager { impl Default for IdleManager {
@@ -201,12 +225,28 @@ async fn run_idle_session(
}, },
) )
.await; .await;
} else if result.flags_updated > 0 || result.deleted_count > 0 { }
if result.flags_updated > 0 || result.deleted_count > 0 {
event_bus event_bus
.publish( .publish(
user_hash, user_hash,
MailEvent::FlagsChanged { MailEvent::FlagsChanged {
folder: folder.to_string(), folder: folder.to_string(),
changes: result.flag_changes,
},
)
.await;
}
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
&& let Ok(Some(folder_state)) = db::folders::get_folder(&conn, folder)
{
event_bus
.publish(
user_hash,
MailEvent::FolderStatus {
folder: folder.to_string(),
total_count: folder_state.total_count,
unread_count: folder_state.unread_count,
}, },
) )
.await; .await;
@@ -249,6 +289,7 @@ struct ReconcileResult {
latest_uid: Option<u32>, latest_uid: Option<u32>,
latest_sender: Option<String>, latest_sender: Option<String>,
latest_subject: Option<String>, latest_subject: Option<String>,
flag_changes: Vec<MessageFlagChange>,
} }
impl ReconcileResult { impl ReconcileResult {
@@ -260,6 +301,7 @@ impl ReconcileResult {
latest_uid: None, latest_uid: None,
latest_sender: None, latest_sender: None,
latest_subject: None, latest_subject: None,
flag_changes: Vec::new(),
} }
} }
} }
@@ -271,28 +313,42 @@ async fn fetch_new_after_idle(
config: &AppConfig, config: &AppConfig,
search_engine: &SearchEngine, search_engine: &SearchEngine,
) -> (async_imap::Session<ImapStream>, ReconcileResult) { ) -> (async_imap::Session<ImapStream>, ReconcileResult) {
let (cached_modseq, max_cached_uid, cached_count, cached_uid_validity, header_sync) = let (
match db::pool::open_user_db(&config.data_dir, user_hash) { cached_modseq,
Ok(conn) => { max_cached_uid,
let header_sync = match db::header_sync::HeaderSync::capture(&conn) { cached_count,
Ok(sync) => sync, cached_counter_generation,
Err(e) => { cached_uid_validity,
tracing::warn!(error = %e, "IDLE fetch: failed to read header generation"); header_sync,
return (session, ReconcileResult::empty()); ) = match db::pool::open_user_db(&config.data_dir, user_hash) {
} Ok(conn) => {
}; let header_sync = match db::header_sync::HeaderSync::capture(&conn) {
let cached = db::folders::get_folder(&conn, folder).ok().flatten(); Ok(sync) => sync,
let modseq = cached.as_ref().map(|f| f.highest_modseq).unwrap_or(0); Err(e) => {
let validity = cached.as_ref().map(|f| f.uid_validity).unwrap_or(0); tracing::warn!(error = %e, "IDLE fetch: failed to read header generation");
let max_uid = db::messages::max_uid(&conn, folder).unwrap_or(0); return (session, ReconcileResult::empty());
let count = db::messages::count_messages(&conn, folder).unwrap_or(0); }
(modseq, max_uid, count, validity, header_sync) };
} let cached = db::folders::get_folder(&conn, folder).ok().flatten();
Err(e) => { let modseq = cached.as_ref().map(|f| f.highest_modseq).unwrap_or(0);
tracing::warn!(error = %e, "IDLE fetch: failed to open DB"); let validity = cached.as_ref().map(|f| f.uid_validity).unwrap_or(0);
return (session, ReconcileResult::empty()); let counter_generation = cached.as_ref().map(|f| f.counter_generation).unwrap_or(0);
} let max_uid = db::messages::max_uid(&conn, folder).unwrap_or(0);
}; let count = db::messages::count_messages(&conn, folder).unwrap_or(0);
(
modseq,
max_uid,
count,
counter_generation,
validity,
header_sync,
)
}
Err(e) => {
tracing::warn!(error = %e, "IDLE fetch: failed to open DB");
return (session, ReconcileResult::empty());
}
};
let mailbox = match session.select_condstore(folder).await { let mailbox = match session.select_condstore(folder).await {
Ok(mb) => mb, Ok(mb) => mb,
@@ -303,7 +359,13 @@ async fn fetch_new_after_idle(
}; };
let server_exists = mailbox.exists; let server_exists = mailbox.exists;
let server_unseen = mailbox.unseen; let server_unseen = match session.uid_search("UNSEEN").await {
Ok(uids) => Some(uids.len() as u32),
Err(error) => {
tracing::debug!(folder = %folder, error = %error, "IDLE UNSEEN search failed");
None
}
};
let server_modseq = mailbox.highest_modseq.unwrap_or(0); let server_modseq = mailbox.highest_modseq.unwrap_or(0);
let uid_validity = mailbox.uid_validity.unwrap_or(0); let uid_validity = mailbox.uid_validity.unwrap_or(0);
@@ -314,6 +376,18 @@ async fn fetch_new_after_idle(
return (session, ReconcileResult::empty()); return (session, ReconcileResult::empty());
} }
if let Some(unseen) = server_unseen
&& let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
{
let _ = db::folders::set_folder_counts_if_unchanged(
&conn,
folder,
cached_counter_generation,
server_exists,
unseen,
);
}
if server_modseq > 0 && server_modseq == cached_modseq && server_exists == cached_count { if server_modseq > 0 && server_modseq == cached_modseq && server_exists == cached_count {
tracing::debug!( tracing::debug!(
user_hash = %user_hash, user_hash = %user_hash,
@@ -331,6 +405,7 @@ async fn fetch_new_after_idle(
Ok(stream) => { Ok(stream) => {
let mut stream = std::pin::pin!(stream); let mut stream = std::pin::pin!(stream);
let mut items = Vec::new(); let mut items = Vec::new();
let mut complete = true;
while let Some(result) = stream.next().await { while let Some(result) = stream.next().await {
match result { match result {
Ok(fetch) => { Ok(fetch) => {
@@ -345,11 +420,12 @@ async fn fetch_new_after_idle(
error = %e, error = %e,
"IDLE fetch: error reading CHANGEDSINCE stream" "IDLE fetch: error reading CHANGEDSINCE stream"
); );
complete = false;
break; break;
} }
} }
} }
Some(items) complete.then_some(items)
} }
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
@@ -378,18 +454,42 @@ async fn fetch_new_after_idle(
let sync_keywords = db::mail_preferences::get_preferences(&conn) let sync_keywords = db::mail_preferences::get_preferences(&conn)
.map(|prefs| prefs.sync_imap_keywords) .map(|prefs| prefs.sync_imap_keywords)
.unwrap_or(false); .unwrap_or(false);
for (uid, flags) in &flag_changes { match header_sync.begin_write(&conn) {
let mut sorted = flags.clone(); Ok(transaction) => {
sorted.sort(); for (uid, flags) in &flag_changes {
let flags_csv = sorted.join(","); let mut sorted = flags.clone();
if db::messages::update_message_flags(&conn, folder, *uid, &flags_csv).is_ok() { sorted.sort();
if sync_keywords let flags_csv = sorted.join(",");
&& let Err(error) = if db::messages::update_message_flags(
db::tags::import_imap_keywords(&conn, *uid, folder, &sorted) &transaction,
{ folder,
tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed"); *uid,
&flags_csv,
)
.is_ok()
{
if sync_keywords
&& let Err(error) = db::tags::import_imap_keywords(
&transaction,
*uid,
folder,
&sorted,
)
{
tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed");
}
flags_updated += 1;
}
} }
flags_updated += 1; if let Err(error) = transaction.commit() {
tracing::debug!(error = %error, "IDLE flag reconciliation commit failed");
flags_updated = 0;
flag_changes.clear();
}
}
Err(error) => {
tracing::debug!(error = %error, "IDLE flag reconciliation discarded stale cache generation");
flag_changes.clear();
} }
} }
} }
@@ -399,12 +499,15 @@ async fn fetch_new_after_idle(
let uid_range = format!("{}:*", max_cached_uid + 1); let uid_range = format!("{}:*", max_cached_uid + 1);
new_count = fetch_and_store_headers( new_count = fetch_and_store_headers(
&mut session, &mut session,
user_hash, HeaderFetchContext {
folder, user_hash,
config, folder,
config,
search_engine,
header_sync,
},
&uid_range, &uid_range,
max_cached_uid, max_cached_uid,
header_sync,
) )
.await; .await;
} }
@@ -430,22 +533,16 @@ async fn fetch_new_after_idle(
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
&& let Ok(transaction) = header_sync.begin_write(&conn) && let Ok(transaction) = header_sync.begin_write(&conn)
{ {
let _ = db::folders::update_folder_sync_status( let _ = db::folders::update_folder_sync_cursor(
&conn, &transaction,
folder, folder,
uid_validity, uid_validity,
server_exists,
server_modseq, server_modseq,
); );
if let Some(unseen) = server_unseen {
let _ = db::folders::set_unread_count(&conn, folder, unseen);
} else {
let _ = db::folders::refresh_unread_count(&conn, folder);
}
if has_new && new_count == 0 { if has_new && new_count == 0 {
let _ = db::folders::invalidate_folder_freshness(&conn, folder); let _ = db::folders::invalidate_folder_freshness(&transaction, folder);
} else { } else {
let _ = db::folders::mark_folder_messages_fresh(&conn, folder); let _ = db::folders::mark_folder_messages_fresh(&transaction, folder);
} }
let _ = transaction.commit(); let _ = transaction.commit();
} }
@@ -470,53 +567,136 @@ async fn fetch_new_after_idle(
latest_uid, latest_uid,
latest_sender, latest_sender,
latest_subject, latest_subject,
flag_changes: flag_changes
.into_iter()
.map(|(uid, mut flags)| {
flags.sort();
MessageFlagChange { uid, flags }
})
.collect(),
}, },
); );
} }
} }
let cached_uids: std::collections::HashSet<u32> = let cached_state = match db::pool::open_user_db(&config.data_dir, user_hash) {
match db::pool::open_user_db(&config.data_dir, user_hash) { Ok(conn) => db::messages::get_all_uids_and_flags(&conn, folder).unwrap_or_default(),
Ok(conn) => { Err(e) => {
let uids_flags = tracing::warn!(error = %e, "IDLE fetch: failed to open DB for fallback");
db::messages::get_all_uids_and_flags(&conn, folder).unwrap_or_default(); return (session, ReconcileResult::empty());
uids_flags.iter().map(|(uid, _)| *uid).collect() }
} };
Err(e) => { let cached_uids = cached_state
tracing::warn!(error = %e, "IDLE fetch: failed to open DB for fallback"); .iter()
return (session, ReconcileResult::empty()); .map(|(uid, _)| *uid)
} .collect::<std::collections::HashSet<_>>();
}; let cached_flags = cached_state
.into_iter()
.map(|(uid, flags)| {
let mut normalized = flags
.split(',')
.filter(|flag| !flag.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
normalized.sort();
normalized.dedup();
(uid, normalized)
})
.collect::<std::collections::HashMap<_, _>>();
let imap_uids: Option<std::collections::HashSet<u32>> = let imap_state: Option<std::collections::HashMap<u32, Vec<String>>> = match session
match session.uid_fetch("1:*", "UID").await { .uid_fetch("1:*", "UID FLAGS")
Ok(stream) => { .await
let mut stream = std::pin::pin!(stream); {
let mut uids = std::collections::HashSet::new(); Ok(stream) => {
while let Some(result) = stream.next().await { let mut stream = std::pin::pin!(stream);
match result { let mut state = std::collections::HashMap::new();
Ok(fetch) => { let mut complete = true;
if let Some(uid) = fetch.uid { while let Some(result) = stream.next().await {
uids.insert(uid); match result {
} Ok(fetch) => {
} if let Some(uid) = fetch.uid {
Err(e) => { let mut flags = fetch
tracing::warn!(error = %e, "IDLE fetch: error reading UID stream"); .flags()
break; .map(|flag| flag_to_string(&flag))
.collect::<Vec<_>>();
flags.sort();
flags.dedup();
state.insert(uid, flags);
} }
} }
Err(e) => {
tracing::warn!(error = %e, "IDLE fetch: error reading UID/FLAGS stream");
complete = false;
break;
}
} }
Some(uids)
} }
Err(e) => { complete.then_some(state)
tracing::warn!(error = %e, "IDLE fetch: uid_fetch(UIDs) failed"); }
None Err(e) => {
} tracing::warn!(error = %e, "IDLE fetch: uid_fetch(UID FLAGS) failed");
}; None
}
};
let Some(imap_uids) = imap_uids else { let Some(imap_state) = imap_state else {
return (session, ReconcileResult::empty()); return (session, ReconcileResult::empty());
}; };
let imap_uids = imap_state
.keys()
.copied()
.collect::<std::collections::HashSet<_>>();
let mut flag_changes = cached_flags
.iter()
.filter_map(|(uid, cached)| {
imap_state
.get(uid)
.filter(|server| *server != cached)
.map(|server| (*uid, server.clone()))
})
.collect::<Vec<_>>();
flag_changes.sort_by_key(|(uid, _)| *uid);
let mut flags_updated = 0u32;
if !flag_changes.is_empty()
&& let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
{
let sync_keywords = db::mail_preferences::get_preferences(&conn)
.map(|prefs| prefs.sync_imap_keywords)
.unwrap_or(false);
match header_sync.begin_write(&conn) {
Ok(transaction) => {
for (uid, flags) in &flag_changes {
if db::messages::update_message_flags(
&transaction,
folder,
*uid,
&flags.join(","),
)
.is_ok()
{
if sync_keywords
&& let Err(error) =
db::tags::import_imap_keywords(&transaction, *uid, folder, flags)
{
tracing::debug!(uid = uid, error = %error, "IDLE fallback keyword import failed");
}
flags_updated += 1;
}
}
if let Err(error) = transaction.commit() {
tracing::debug!(error = %error, "IDLE fallback flag reconciliation commit failed");
flags_updated = 0;
flag_changes.clear();
}
}
Err(error) => {
tracing::debug!(error = %error, "IDLE fallback flag reconciliation discarded stale cache generation");
flag_changes.clear();
}
}
}
let has_new = imap_uids.iter().any(|&uid| uid > max_cached_uid); let has_new = imap_uids.iter().any(|&uid| uid > max_cached_uid);
let mut new_count = 0u32; let mut new_count = 0u32;
@@ -524,12 +704,15 @@ async fn fetch_new_after_idle(
let uid_range = format!("{}:*", max_cached_uid + 1); let uid_range = format!("{}:*", max_cached_uid + 1);
new_count = fetch_and_store_headers( new_count = fetch_and_store_headers(
&mut session, &mut session,
user_hash, HeaderFetchContext {
folder, user_hash,
config, folder,
config,
search_engine,
header_sync,
},
&uid_range, &uid_range,
max_cached_uid, max_cached_uid,
header_sync,
) )
.await; .await;
} }
@@ -553,22 +736,16 @@ async fn fetch_new_after_idle(
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
&& let Ok(transaction) = header_sync.begin_write(&conn) && let Ok(transaction) = header_sync.begin_write(&conn)
{ {
let _ = db::folders::update_folder_sync_status( let _ = db::folders::update_folder_sync_cursor(
&conn, &transaction,
folder, folder,
uid_validity, uid_validity,
server_exists,
server_modseq, server_modseq,
); );
if let Some(unseen) = server_unseen {
let _ = db::folders::set_unread_count(&conn, folder, unseen);
} else {
let _ = db::folders::refresh_unread_count(&conn, folder);
}
if has_new && new_count == 0 { if has_new && new_count == 0 {
let _ = db::folders::invalidate_folder_freshness(&conn, folder); let _ = db::folders::invalidate_folder_freshness(&transaction, folder);
} else { } else {
let _ = db::folders::mark_folder_messages_fresh(&conn, folder); let _ = db::folders::mark_folder_messages_fresh(&transaction, folder);
} }
let _ = transaction.commit(); let _ = transaction.commit();
} }
@@ -579,10 +756,11 @@ async fn fetch_new_after_idle(
(None, None, None) (None, None, None)
}; };
if new_count > 0 || deleted_count > 0 { if flags_updated > 0 || new_count > 0 || deleted_count > 0 {
tracing::info!( tracing::info!(
user_hash = %user_hash, user_hash = %user_hash,
folder = %folder, folder = %folder,
flags_updated = flags_updated,
new = new_count, new = new_count,
deleted = deleted_count, deleted = deleted_count,
"Reconciled cache after IDLE (full scan)" "Reconciled cache after IDLE (full scan)"
@@ -593,11 +771,15 @@ async fn fetch_new_after_idle(
session, session,
ReconcileResult { ReconcileResult {
new_count, new_count,
flags_updated: 0, flags_updated,
deleted_count, deleted_count,
latest_uid, latest_uid,
latest_sender, latest_sender,
latest_subject, latest_subject,
flag_changes: flag_changes
.into_iter()
.map(|(uid, flags)| MessageFlagChange { uid, flags })
.collect(),
}, },
) )
} }
@@ -634,15 +816,27 @@ fn lookup_latest_message(
} }
} }
struct HeaderFetchContext<'a> {
user_hash: &'a str,
folder: &'a str,
config: &'a AppConfig,
search_engine: &'a SearchEngine,
header_sync: db::header_sync::HeaderSync,
}
async fn fetch_and_store_headers( async fn fetch_and_store_headers(
session: &mut async_imap::Session<ImapStream>, session: &mut async_imap::Session<ImapStream>,
user_hash: &str, context: HeaderFetchContext<'_>,
folder: &str,
config: &AppConfig,
uid_range: &str, uid_range: &str,
min_uid: u32, min_uid: u32,
header_sync: db::header_sync::HeaderSync,
) -> u32 { ) -> u32 {
let HeaderFetchContext {
user_hash,
folder,
config,
search_engine,
header_sync,
} = context;
let fetches: Option<Vec<_>> = match session let fetches: Option<Vec<_>> = match session
.uid_fetch( .uid_fetch(
uid_range, uid_range,
@@ -653,16 +847,18 @@ async fn fetch_and_store_headers(
Ok(stream) => { Ok(stream) => {
let mut stream = std::pin::pin!(stream); let mut stream = std::pin::pin!(stream);
let mut items = Vec::new(); let mut items = Vec::new();
let mut complete = true;
while let Some(result) = stream.next().await { while let Some(result) = stream.next().await {
match result { match result {
Ok(fetch) => items.push(fetch), Ok(fetch) => items.push(fetch),
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "IDLE fetch: error reading header stream"); tracing::warn!(error = %e, "IDLE fetch: error reading header stream");
complete = false;
break; break;
} }
} }
} }
Some(items) complete.then_some(items)
} }
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "IDLE fetch: uid_fetch(headers) failed"); tracing::warn!(error = %e, "IDLE fetch: uid_fetch(headers) failed");
@@ -684,6 +880,11 @@ async fn fetch_and_store_headers(
let sync_keywords = db::mail_preferences::get_preferences(&conn) let sync_keywords = db::mail_preferences::get_preferences(&conn)
.map(|prefs| prefs.sync_imap_keywords) .map(|prefs| prefs.sync_imap_keywords)
.unwrap_or(false); .unwrap_or(false);
let user_index = if search_engine.is_enabled() && !UserIndex::is_excluded_folder(folder) {
search_engine.open_user_index(user_hash).ok()
} else {
None
};
for fetch in &fetches { for fetch in &fetches {
let uid = match fetch.uid { let uid = match fetch.uid {
Some(u) if u > min_uid => u, Some(u) if u > min_uid => u,
@@ -787,7 +988,7 @@ async fn fetch_and_store_headers(
let flags_csv = flags.join(","); let flags_csv = flags.join(",");
if let Err(e) = db::messages::upsert_message( if let Err(e) = db::messages::upsert_message(
&conn, &transaction,
folder, folder,
uid, uid,
message_id.as_deref(), message_id.as_deref(),
@@ -808,7 +1009,7 @@ async fn fetch_and_store_headers(
tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to upsert message"); tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to upsert message");
} else { } else {
if let Err(e) = db::contacts::populate_known_addresses( if let Err(e) = db::contacts::populate_known_addresses(
&conn, &transaction,
from_address, from_address,
from_name, from_name,
&to_json, &to_json,
@@ -817,10 +1018,30 @@ async fn fetch_and_store_headers(
tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to populate known addresses"); tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to populate known addresses");
} }
if sync_keywords if sync_keywords
&& let Err(error) = db::tags::import_imap_keywords(&conn, uid, folder, &flags) && let Err(error) =
db::tags::import_imap_keywords(&transaction, uid, folder, &flags)
{ {
tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed"); tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed");
} }
if let Some(index) = user_index.as_ref()
&& let Err(error) = index.index_message_with_connection(
&transaction,
&IndexableMessage {
uid,
folder: folder.to_string(),
subject: subject_str.to_string(),
from_address: from_address.to_string(),
from_name: from_name.to_string(),
to_addresses: to_json.clone(),
body_text: String::new(),
date_epoch: db::messages::parse_date_to_epoch_public(date_str),
has_attachments: has_attach,
},
)
{
tracing::warn!(uid = uid, error = %error, "IDLE fetch: failed to enqueue search indexing");
return 0;
}
new_count += 1; new_count += 1;
} }
} }
@@ -888,6 +1109,7 @@ async fn fetch_uids_and_remove_deleted(
Ok(stream) => { Ok(stream) => {
let mut stream = std::pin::pin!(stream); let mut stream = std::pin::pin!(stream);
let mut uids = std::collections::HashSet::new(); let mut uids = std::collections::HashSet::new();
let mut complete = true;
while let Some(result) = stream.next().await { while let Some(result) = stream.next().await {
match result { match result {
Ok(fetch) => { Ok(fetch) => {
@@ -900,11 +1122,12 @@ async fn fetch_uids_and_remove_deleted(
error = %e, error = %e,
"IDLE fetch: error reading UID stream for deletion check" "IDLE fetch: error reading UID stream for deletion check"
); );
complete = false;
break; break;
} }
} }
} }
Some(uids) complete.then_some(uids)
} }
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
+132 -39
View File
@@ -5,12 +5,10 @@ use crate::config::AppConfig;
use crate::db; use crate::db;
use crate::email_theme; use crate::email_theme;
use crate::imap::client::{ImapClient, ImapCredentials}; use crate::imap::client::{ImapClient, ImapCredentials};
use crate::realtime::events::{EventBus, MailEvent}; use crate::realtime::events::{EventBus, MailEvent, MessageFlagChange};
use crate::search::engine::UserIndex; use crate::search::engine::UserIndex;
use crate::search::engine::{IndexableMessage, SearchEngine}; use crate::search::engine::{IndexableMessage, SearchEngine};
const SYNC_INTERVAL_SECS: u64 = 30;
pub async fn sync_loop( pub async fn sync_loop(
user_hash: String, user_hash: String,
creds: ImapCredentials, creds: ImapCredentials,
@@ -19,7 +17,8 @@ pub async fn sync_loop(
event_bus: Arc<EventBus>, event_bus: Arc<EventBus>,
search_engine: Arc<SearchEngine>, search_engine: Arc<SearchEngine>,
) { ) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(SYNC_INTERVAL_SECS)); let mut interval =
tokio::time::interval(std::time::Duration::from_secs(config.sync.interval_secs));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop { loop {
@@ -62,28 +61,81 @@ async fn run_sync(
uid_validity: f.uid_validity, uid_validity: f.uid_validity,
highest_modseq: f.highest_modseq, highest_modseq: f.highest_modseq,
total_count: f.total_count, total_count: f.total_count,
unread_count: f.unread_count,
counter_generation: f.counter_generation,
flags: f.flags,
}) })
.collect::<Vec<_>>()) .collect::<Vec<_>>())
}) })
.await?; .await?;
let mut any_changes = false; let mut any_changes = false;
let folder_names = folder_snapshots
.iter()
.filter(|folder| !folder.is_noselect())
.map(|folder| folder.name.clone())
.collect::<Vec<_>>();
let mut batched_statuses = imap_client
.folder_statuses_extended(creds, &folder_names)
.await
.unwrap_or_default()
.into_iter()
.collect::<HashMap<_, _>>();
for folder in &folder_snapshots { for folder in &folder_snapshots {
let folder_name = &folder.name; let folder_name = &folder.name;
if folder.is_noselect() {
continue;
}
let status = match imap_client.folder_status_extended(creds, folder_name).await { let status = if let Some(status) = batched_statuses.remove(folder_name) {
Ok(s) => s, status
Err(e) => { } else {
tracing::debug!( match imap_client.folder_status_extended(creds, folder_name).await {
folder = %folder_name, Ok(status) => status,
error = %e, Err(error) => {
"Skipping folder sync (STATUS failed)" tracing::debug!(
); folder = %folder_name,
continue; error = %error,
"Skipping folder sync (STATUS failed)"
);
continue;
}
} }
}; };
if status.exists != folder.total_count || status.unseen != folder.unread_count {
let name = folder_name.clone();
let expected_generation = folder.counter_generation;
let total_count = status.exists;
let unread_count = status.unseen;
let applied = database
.run(move |conn| {
db::folders::set_folder_counts_if_unchanged(
conn,
&name,
expected_generation,
total_count,
unread_count,
)
})
.await?;
if applied {
event_bus
.publish(
user_hash,
MailEvent::FolderStatus {
folder: folder_name.clone(),
total_count,
unread_count,
},
)
.await;
} else {
tracing::debug!(folder = %folder_name, "Skipped stale periodic STATUS counters");
}
}
if folder.uid_validity != 0 && status.uid_validity != folder.uid_validity { if folder.uid_validity != 0 && status.uid_validity != folder.uid_validity {
tracing::info!( tracing::info!(
folder = %folder_name, folder = %folder_name,
@@ -162,7 +214,13 @@ async fn run_sync(
} }
if any_changes { if any_changes {
event_bus.publish(user_hash, MailEvent::FolderUpdated).await; tracing::debug!(user_hash = %user_hash, "Periodic sync applied message changes");
}
if let Ok(pending) = database.run(db::folders::pending_message_sync_count).await {
event_bus
.publish(user_hash, MailEvent::SyncInventory { pending })
.await;
} }
if search_engine.is_enabled() if search_engine.is_enabled()
@@ -182,8 +240,6 @@ async fn run_sync(
Ok(()) Ok(())
} }
const DEEP_INDEX_BATCH: u32 = 10;
async fn index_message_bodies( async fn index_message_bodies(
user_hash: &str, user_hash: &str,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -193,12 +249,18 @@ async fn index_message_bodies(
search_engine: Arc<SearchEngine>, search_engine: Arc<SearchEngine>,
) -> Result<(), String> { ) -> Result<(), String> {
let database = db::pool::UserDb::new(&config.data_dir, user_hash); let database = db::pool::UserDb::new(&config.data_dir, user_hash);
let unindexed = database let force_deep_index = config.search.deep_index;
.run(|conn| { let deep_index_batch_size = config.sync.deep_index_batch_size;
if !db::display_preferences::get_preferences(conn)?.deep_index { let (pending_before, unindexed) = database
return Ok(Vec::new()); .run(move |conn| {
let account_deep_index = db::display_preferences::get_preferences(conn)?.deep_index;
if !force_deep_index && !account_deep_index {
return Ok((0, Vec::new()));
} }
db::messages::get_unindexed_messages(conn, DEEP_INDEX_BATCH) Ok((
db::messages::count_unindexed_messages(conn)?,
db::messages::get_unindexed_messages(conn, deep_index_batch_size)?,
))
}) })
.await?; .await?;
@@ -208,7 +270,7 @@ async fn index_message_bodies(
user_hash, user_hash,
MailEvent::IndexingStatus { MailEvent::IndexingStatus {
active: false, active: false,
pending: 0, pending: pending_before,
}, },
) )
.await; .await;
@@ -220,7 +282,7 @@ async fn index_message_bodies(
user_hash, user_hash,
MailEvent::IndexingStatus { MailEvent::IndexingStatus {
active: true, active: true,
pending: unindexed.len() as u32, pending: pending_before,
}, },
) )
.await; .await;
@@ -297,12 +359,16 @@ async fn index_message_bodies(
} }
.await; .await;
let pending_after = database
.run(db::messages::count_unindexed_messages)
.await
.unwrap_or(pending_before);
event_bus event_bus
.publish( .publish(
user_hash, user_hash,
MailEvent::IndexingStatus { MailEvent::IndexingStatus {
active: false, active: false,
pending: 0, pending: pending_after,
}, },
) )
.await; .await;
@@ -314,6 +380,17 @@ struct FolderSnapshot {
uid_validity: u32, uid_validity: u32,
highest_modseq: u64, highest_modseq: u64,
total_count: u32, total_count: u32,
unread_count: u32,
counter_generation: u64,
flags: String,
}
impl FolderSnapshot {
fn is_noselect(&self) -> bool {
self.flags
.split(',')
.any(|flag| flag.trim().eq_ignore_ascii_case("\\Noselect"))
}
} }
fn store_headers( fn store_headers(
@@ -551,7 +628,7 @@ async fn sync_condstore(
let search_engine_for_write = Arc::clone(search_engine); let search_engine_for_write = Arc::clone(search_engine);
let search_user_hash = user_hash.to_owned(); let search_user_hash = user_hash.to_owned();
let headers_for_write = new_headers; let headers_for_write = new_headers;
let (folder_changed, non_new_change) = database let (folder_changed, non_new_change, flag_changes) = database
.run(move |conn| { .run(move |conn| {
let sync_keywords = db::mail_preferences::get_preferences(conn) let sync_keywords = db::mail_preferences::get_preferences(conn)
.map(|prefs| prefs.sync_imap_keywords) .map(|prefs| prefs.sync_imap_keywords)
@@ -560,9 +637,14 @@ async fn sync_condstore(
.unchecked_transaction() .unchecked_transaction()
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let mut non_new_change = !changed.is_empty(); let mut non_new_change = !changed.is_empty();
let mut flag_changes = Vec::with_capacity(changed.len());
for (uid, mut flags) in changed { for (uid, mut flags) in changed {
flags.sort(); flags.sort();
db::messages::update_message_flags(&transaction, &name, uid, &flags.join(","))?; db::messages::update_message_flags(&transaction, &name, uid, &flags.join(","))?;
flag_changes.push(MessageFlagChange {
uid,
flags: flags.clone(),
});
if sync_keywords { if sync_keywords {
db::tags::import_imap_keywords(&transaction, uid, &name, &flags)?; db::tags::import_imap_keywords(&transaction, uid, &name, &flags)?;
} }
@@ -588,19 +670,18 @@ async fn sync_condstore(
&search_engine_for_write, &search_engine_for_write,
&search_user_hash, &search_user_hash,
)?; )?;
let modseq = if new_modseq > 0 { let modseq = if new_modseq > 0 {
new_modseq new_modseq
} else { } else {
status.highest_modseq cached_modseq
}; };
db::folders::update_folder_sync_status( db::folders::update_folder_sync_cursor(
&transaction, &transaction,
&name, &name,
status.uid_validity, status.uid_validity,
status.exists,
modseq, modseq,
)?; )?;
db::folders::set_unread_count(&transaction, &name, status.unseen)?;
if has_unfetched_new { if has_unfetched_new {
db::folders::invalidate_folder_freshness(&transaction, &name)?; db::folders::invalidate_folder_freshness(&transaction, &name)?;
} else if cache_initialized { } else if cache_initialized {
@@ -610,6 +691,7 @@ async fn sync_condstore(
Ok(( Ok((
non_new_change || !headers_for_write.is_empty(), non_new_change || !headers_for_write.is_empty(),
non_new_change, non_new_change,
flag_changes,
)) ))
}) })
.await?; .await?;
@@ -635,6 +717,7 @@ async fn sync_condstore(
user_hash, user_hash,
MailEvent::FlagsChanged { MailEvent::FlagsChanged {
folder: folder_name.to_string(), folder: folder_name.to_string(),
changes: flag_changes,
}, },
) )
.await; .await;
@@ -703,16 +786,21 @@ async fn sync_full(
let status = status.clone(); let status = status.clone();
database database
.run(move |conn| { .run(move |conn| {
db::folders::update_folder_sync_status( let safe_modseq = if status.exists == 0 {
status.highest_modseq
} else {
0
};
db::folders::update_folder_sync_cursor(
conn, conn,
&name, &name,
status.uid_validity, status.uid_validity,
status.exists, safe_modseq,
status.highest_modseq,
)?; )?;
db::folders::set_unread_count(conn, &name, status.unseen)?; if has_unfetched_new || status.exists > 0 {
if has_unfetched_new {
db::folders::invalidate_folder_freshness(conn, &name)?; db::folders::invalidate_folder_freshness(conn, &name)?;
} else {
db::folders::mark_folder_messages_fresh(conn, &name)?;
} }
Ok(()) Ok(())
}) })
@@ -740,7 +828,7 @@ async fn sync_full(
let search_engine_for_write = Arc::clone(search_engine); let search_engine_for_write = Arc::clone(search_engine);
let search_user_hash = user_hash.to_owned(); let search_user_hash = user_hash.to_owned();
let headers_for_write = new_headers; let headers_for_write = new_headers;
let non_new_change = database let (non_new_change, flag_changes) = database
.run(move |conn| { .run(move |conn| {
let folder_name = &name; let folder_name = &name;
let sync_keywords = db::mail_preferences::get_preferences(conn) let sync_keywords = db::mail_preferences::get_preferences(conn)
@@ -758,6 +846,7 @@ async fn sync_full(
.collect(); .collect();
let mut non_new_change = false; let mut non_new_change = false;
let mut flag_changes = Vec::new();
for (uid, cached_flags_csv) in &cached { for (uid, cached_flags_csv) in &cached {
match imap_map.get(uid) { match imap_map.get(uid) {
None => { None => {
@@ -788,6 +877,10 @@ async fn sync_full(
*uid, *uid,
&imap_flags_csv, &imap_flags_csv,
)?; )?;
flag_changes.push(MessageFlagChange {
uid: *uid,
flags: imap_flags.clone(),
});
non_new_change = true; non_new_change = true;
} }
if sync_keywords { if sync_keywords {
@@ -809,21 +902,20 @@ async fn sync_full(
&search_engine_for_write, &search_engine_for_write,
&search_user_hash, &search_user_hash,
)?; )?;
db::folders::update_folder_sync_status(
db::folders::update_folder_sync_cursor(
&transaction, &transaction,
folder_name, folder_name,
status.uid_validity, status.uid_validity,
status.exists,
status.highest_modseq, status.highest_modseq,
)?; )?;
db::folders::set_unread_count(&transaction, folder_name, status.unseen)?;
if has_unfetched_new { if has_unfetched_new {
db::folders::invalidate_folder_freshness(&transaction, folder_name)?; db::folders::invalidate_folder_freshness(&transaction, folder_name)?;
} else if cache_initialized { } else if cache_initialized {
db::folders::mark_folder_messages_fresh(&transaction, folder_name)?; db::folders::mark_folder_messages_fresh(&transaction, folder_name)?;
} }
transaction.commit().map_err(|error| error.to_string())?; transaction.commit().map_err(|error| error.to_string())?;
Ok(non_new_change) Ok((non_new_change, flag_changes))
}) })
.await?; .await?;
@@ -848,6 +940,7 @@ async fn sync_full(
user_hash, user_hash,
MailEvent::FlagsChanged { MailEvent::FlagsChanged {
folder: folder_name.to_string(), folder: folder_name.to_string(),
changes: flag_changes,
}, },
) )
.await; .await;
+15 -7
View File
@@ -18,19 +18,27 @@ pub const BROWSER_COOKIE: &str = "rustbox_browser";
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AuthPolicyResponse { pub struct AuthPolicyResponse {
remember_me_enabled: bool, remember_me_enabled: bool,
remember_me_days: u64, }
session_timeout_hours: u64,
pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<AuthPolicyResponse> {
Json(AuthPolicyResponse {
remember_me_enabled: config.remember_me_enabled,
})
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPolicyResponse {
pgp_enabled: bool, pgp_enabled: bool,
sieve_enabled: bool, sieve_enabled: bool,
diagnostics_enabled: bool, diagnostics_enabled: bool,
about_enabled: bool, about_enabled: bool,
} }
pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<AuthPolicyResponse> { pub async fn session_policy(
Json(AuthPolicyResponse { Extension(config): Extension<Arc<AppConfig>>,
remember_me_enabled: config.remember_me_enabled, ) -> Json<SessionPolicyResponse> {
remember_me_days: config.remember_me_days, Json(SessionPolicyResponse {
session_timeout_hours: config.session_timeout_hours,
pgp_enabled: config.pgp_enabled, pgp_enabled: config.pgp_enabled,
sieve_enabled: config.sieve_enabled, sieve_enabled: config.sieve_enabled,
diagnostics_enabled: config.diagnostics_enabled, diagnostics_enabled: config.diagnostics_enabled,
-1
View File
@@ -624,7 +624,6 @@ pub async fn get_contact_photo_handler(
Response::builder() Response::builder()
.header("content-type", content_type) .header("content-type", content_type)
.header("cache-control", "private, max-age=3600") .header("cache-control", "private, max-age=3600")
.header("x-content-type-options", "nosniff")
.body(axum::body::Body::from(data)) .body(axum::body::Body::from(data))
.map_err(|e| AppError::InternalError(format!("Failed to build photo response: {e}"))) .map_err(|e| AppError::InternalError(format!("Failed to build photo response: {e}")))
} }
+41 -9
View File
@@ -9,6 +9,7 @@ use crate::config::AppConfig;
use crate::db; use crate::db;
use crate::error::AppError; use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials}; use crate::imap::client::{ImapClient, ImapCredentials};
use crate::realtime::events::{EventBus, MailEvent};
use crate::realtime::idle::IdleManager; use crate::realtime::idle::IdleManager;
use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex}; use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex};
use crate::smtp::client::{SmtpClient, SmtpCredentials}; use crate::smtp::client::{SmtpClient, SmtpCredentials};
@@ -45,6 +46,7 @@ pub struct SmtpDiagnosticsResponse {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SearchDiagnosticsResponse { pub struct SearchDiagnosticsResponse {
backend: String, backend: String,
deep_index_enabled: bool,
indexed_documents: usize, indexed_documents: usize,
cached_messages: usize, cached_messages: usize,
cached_bodies: usize, cached_bodies: usize,
@@ -259,11 +261,14 @@ pub async fn search_diagnostics(
let data_dir = config.data_dir.clone(); let data_dir = config.data_dir.clone();
let user_hash = session.user_hash.clone(); let user_hash = session.user_hash.clone();
let db_user_hash = user_hash.clone(); let db_user_hash = user_hash.clone();
let force_deep_index = config.search.deep_index;
let (cached_messages, cached_bodies, excluded_messages, outbox) = let (deep_index_enabled, cached_messages, cached_bodies, excluded_messages, outbox) =
tokio::task::spawn_blocking(move || -> Result<_, String> { tokio::task::spawn_blocking(move || -> Result<_, String> {
let conn = db::pool::open_user_db(&data_dir, &db_user_hash) let conn = db::pool::open_user_db(&data_dir, &db_user_hash)
.map_err(|e| format!("Database error: {e}"))?; .map_err(|e| format!("Database error: {e}"))?;
let account_deep_index = db::display_preferences::get_preferences(&conn)?.deep_index;
let deep_index_enabled = force_deep_index || account_deep_index;
let sources = db::messages::list_index_source_messages(&conn)?; let sources = db::messages::list_index_source_messages(&conn)?;
let cached_messages = sources.len(); let cached_messages = sources.len();
let excluded_messages = sources let excluded_messages = sources
@@ -274,7 +279,13 @@ pub async fn search_diagnostics(
.query_row(crate::dbq!(routes_diagnostics::Q_003), [], |row| row.get(0)) .query_row(crate::dbq!(routes_diagnostics::Q_003), [], |row| row.get(0))
.map_err(|e| format!("Failed to count cached bodies: {e}"))?; .map_err(|e| format!("Failed to count cached bodies: {e}"))?;
let outbox = db::search_outbox::stats(&conn).unwrap_or_default(); let outbox = db::search_outbox::stats(&conn).unwrap_or_default();
Ok((cached_messages, cached_bodies, excluded_messages, outbox)) Ok((
deep_index_enabled,
cached_messages,
cached_bodies,
excluded_messages,
outbox,
))
}) })
.await .await
.map_err(|e| AppError::InternalError(format!("Search diagnostics task failed: {e}")))? .map_err(|e| AppError::InternalError(format!("Search diagnostics task failed: {e}")))?
@@ -293,6 +304,7 @@ pub async fn search_diagnostics(
let stats = SearchDiagnosticsResponse { let stats = SearchDiagnosticsResponse {
backend: search_engine.backend_name().to_string(), backend: search_engine.backend_name().to_string(),
deep_index_enabled,
indexed_documents, indexed_documents,
cached_messages, cached_messages,
cached_bodies, cached_bodies,
@@ -309,6 +321,7 @@ pub async fn rebuild_search_index(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
Extension(search_engine): Extension<Arc<SearchEngine>>, Extension(search_engine): Extension<Arc<SearchEngine>>,
Extension(event_bus): Extension<Arc<EventBus>>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
if !search_engine.is_enabled() { if !search_engine.is_enabled() {
return Err(AppError::ServiceUnavailable( return Err(AppError::ServiceUnavailable(
@@ -330,13 +343,32 @@ pub async fn rebuild_search_index(
.map_err(|e| AppError::InternalError(format!("Search rebuild task failed: {e}")))? .map_err(|e| AppError::InternalError(format!("Search rebuild task failed: {e}")))?
.map_err(AppError::InternalError)?; .map_err(AppError::InternalError)?;
let index = search_engine event_bus
.open_user_index(&user_hash) .publish(
.map_err(AppError::InternalError)?; &user_hash,
let indexed_documents = index MailEvent::IndexingStatus {
.rebuild_from_messages(&messages) active: true,
.await pending: source_messages.min(u32::MAX as usize) as u32,
.map_err(AppError::InternalError)?; },
)
.await;
let rebuild_result = match search_engine.open_user_index(&user_hash) {
Ok(index) => index.rebuild_from_messages(&messages).await,
Err(error) => Err(error),
};
event_bus
.publish(
&user_hash,
MailEvent::IndexingStatus {
active: false,
pending: 0,
},
)
.await;
let indexed_documents = rebuild_result.map_err(AppError::InternalError)?;
Ok(Json(SearchRebuildResponse { Ok(Json(SearchRebuildResponse {
status: "rebuilt", status: "rebuilt",
+120
View File
@@ -0,0 +1,120 @@
use crate::config::AppConfig;
use crate::db;
use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials};
pub(crate) type FolderCountSnapshot = Option<(u32, u32, u64)>;
pub(crate) fn snapshot(
config: &AppConfig,
user_hash: &str,
folder: &str,
) -> Result<FolderCountSnapshot, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let state = db::folders::get_folder(&conn, folder)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
Ok(state.map(|folder| {
(
folder.total_count,
folder.unread_count,
folder.counter_generation,
)
}))
}
pub(crate) fn current_counts(
config: &AppConfig,
user_hash: &str,
folder: &str,
) -> Result<Option<(u32, u32)>, AppError> {
Ok(snapshot(config, user_hash, folder)?.map(|(total, unread, _generation)| (total, unread)))
}
pub(crate) async fn reconcile_after_mutation(
config: &AppConfig,
user_hash: &str,
imap_client: &dyn ImapClient,
creds: &ImapCredentials,
folder: &str,
mut expected: FolderCountSnapshot,
) -> Result<Option<(u32, u32)>, AppError> {
for attempt in 0..2 {
let status = match imap_client.folder_status_extended(creds, folder).await {
Ok(status) => status,
Err(error) => {
tracing::debug!(
folder = %folder,
attempt,
error = %error,
"Could not refresh authoritative folder counters after IMAP mutation"
);
return Ok(None);
}
};
let conn = db::pool::open_user_db(&config.data_dir, user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let applied =
if let Some((_expected_total, _expected_unread, expected_generation)) = expected {
db::folders::set_folder_counts_if_unchanged(
&conn,
folder,
expected_generation,
status.exists,
status.unseen,
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
} else {
db::folders::set_folder_counts(&conn, folder, status.exists, status.unseen)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
true
};
if applied {
return Ok(Some((status.exists, status.unseen)));
}
expected = db::folders::get_folder(&conn, folder)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
.map(|state| {
(
state.total_count,
state.unread_count,
state.counter_generation,
)
});
}
current_counts(config, user_hash, folder)
}
pub(crate) fn set_if_snapshot_matches(
config: &AppConfig,
user_hash: &str,
folder: &str,
expected: FolderCountSnapshot,
total_count: u32,
unread_count: u32,
) -> Result<(u32, u32), AppError> {
let conn = db::pool::open_user_db(&config.data_dir, user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
if let Some((_expected_total, _expected_unread, expected_generation)) = expected {
let _ = db::folders::set_folder_counts_if_unchanged(
&conn,
folder,
expected_generation,
total_count,
unread_count,
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
let state = db::folders::get_folder(&conn, folder)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
Ok(state
.map(|state| (state.total_count, state.unread_count))
.unwrap_or((total_count, unread_count)))
}
+157 -24
View File
@@ -10,6 +10,8 @@ use crate::config::AppConfig;
use crate::db; use crate::db;
use crate::error::AppError; use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials}; use crate::imap::client::{ImapClient, ImapCredentials};
use crate::realtime::events::{EventBus, MailEvent};
use crate::routes::folder_counts;
use crate::search::engine::SearchEngine; use crate::search::engine::SearchEngine;
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -32,6 +34,14 @@ struct MessageResponse {
status: &'static str, status: &'static str,
} }
#[derive(Serialize)]
struct FolderCounterResponse {
status: &'static str,
total_count: u32,
unread_count: u32,
through_uid: Option<u32>,
}
const SYSTEM_FOLDERS: &[&str] = &["INBOX", "Sent", "Drafts", "Trash", "Junk", "Spam"]; const SYSTEM_FOLDERS: &[&str] = &["INBOX", "Sent", "Drafts", "Trash", "Junk", "Spam"];
fn is_system_folder(name: &str) -> bool { fn is_system_folder(name: &str) -> bool {
@@ -188,25 +198,96 @@ pub async fn mark_all_read(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>, Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Extension(event_bus): Extension<Arc<EventBus>>,
Path(name): Path<String>, Path(name): Path<String>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let creds = build_creds(&session, &config)?; let creds = build_creds(&session, &config)?;
let counter_snapshot = folder_counts::snapshot(&config, &session.user_hash, &name)?;
let cached_flags_snapshot = {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
db::messages::get_all_uids_and_flags(&conn, &name)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
};
let through_uid = cached_flags_snapshot.iter().map(|(uid, _)| *uid).max();
imap_client imap_client
.mark_all_read(&creds, &name) .mark_all_read(&creds, &name)
.await .await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP mark-all-read failed: {e}")))?; .map_err(|e| AppError::ServiceUnavailable(format!("IMAP mark-all-read failed: {e}")))?;
let authoritative_counts = folder_counts::reconcile_after_mutation(
&config,
&session.user_hash,
imap_client.as_ref(),
&creds,
&name,
counter_snapshot,
)
.await?;
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
conn.execute( for (uid, flags_csv) in cached_flags_snapshot {
crate::dbq!(routes_folder_mgmt::Q_001), let mut flags = flags_csv
crate::db::pool::params![&name, "\\Seen"], .split(',')
) .filter(|flag| !flag.is_empty())
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map(str::to_string)
conn.execute(crate::dbq!(routes_folder_mgmt::Q_002), [&name]) .collect::<Vec<_>>();
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; if !flags.iter().any(|flag| flag == "\\Seen") {
flags.push("\\Seen".to_string());
flags.sort();
flags.dedup();
db::messages::update_message_flags(&conn, &name, uid, &flags.join(","))
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
}
let (total_count, unread_count) = if let Some(counts) = authoritative_counts {
counts
} else if let Some((total_count, _unread_count, _generation)) = counter_snapshot {
folder_counts::set_if_snapshot_matches(
&config,
&session.user_hash,
&name,
counter_snapshot,
total_count,
0,
)?
} else {
db::folders::get_folder(&conn, &name)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
.map(|folder| (folder.total_count, folder.unread_count))
.unwrap_or((0, 0))
};
let (total_count, unread_count) =
folder_counts::current_counts(&config, &session.user_hash, &name)?
.unwrap_or((total_count, unread_count));
event_bus
.publish(
&session.user_hash,
MailEvent::FolderStatus {
folder: name.clone(),
total_count,
unread_count,
},
)
.await;
event_bus
.publish(
&session.user_hash,
MailEvent::FolderAllRead {
folder: name,
through_uid,
},
)
.await;
Ok(Json(MessageResponse { status: "read" }).into_response()) Ok(Json(FolderCounterResponse {
status: "read",
total_count,
unread_count,
through_uid,
})
.into_response())
} }
pub async fn empty_folder( pub async fn empty_folder(
@@ -214,21 +295,16 @@ pub async fn empty_folder(
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>, Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Extension(search_engine): Extension<Arc<SearchEngine>>, Extension(search_engine): Extension<Arc<SearchEngine>>,
Extension(event_bus): Extension<Arc<EventBus>>,
Path(name): Path<String>, Path(name): Path<String>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let creds = build_creds(&session, &config)?; let creds = build_creds(&session, &config)?;
imap_client let counter_snapshot = folder_counts::snapshot(&config, &session.user_hash, &name)?;
.empty_folder(&creds, &name)
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP empty-folder failed: {e}")))?;
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let cached_uids: Vec<u32> = {
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
let transaction = conn .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
.unchecked_transaction() let mut statement = conn
.map_err(|e| AppError::InternalError(format!("Database transaction error: {e}")))?;
let uids: Vec<u32> = {
let mut statement = transaction
.prepare(crate::dbq!(routes_folder_mgmt::Q_003)) .prepare(crate::dbq!(routes_folder_mgmt::Q_003))
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let rows = statement let rows = statement
@@ -236,16 +312,41 @@ pub async fn empty_folder(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
rows.filter_map(Result::ok).collect() rows.filter_map(Result::ok).collect()
}; };
db::messages::delete_folder_messages(&transaction, &name)
imap_client
.empty_folder(&creds, &name)
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP empty-folder failed: {e}")))?;
let authoritative_counts = folder_counts::reconcile_after_mutation(
&config,
&session.user_hash,
imap_client.as_ref(),
&creds,
&name,
counter_snapshot,
)
.await?;
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
transaction let transaction = conn
.execute(crate::dbq!(routes_folder_mgmt::Q_004), [&name]) .unchecked_transaction()
.map_err(|e| AppError::InternalError(format!("Database transaction error: {e}")))?;
for uid in &cached_uids {
db::messages::delete_message(&transaction, &name, *uid)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
db::header_sync::invalidate(&transaction)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
db::folders::invalidate_folder_freshness(&transaction, &name)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
if let Ok(index) = search_engine.open_user_index(&session.user_hash) { if let Ok(index) = search_engine.open_user_index(&session.user_hash) {
for uid in uids { for uid in &cached_uids {
index index
.delete_message_with_connection(&transaction, uid, &name) .delete_message_with_connection(&transaction, *uid, &name)
.map_err(|e| AppError::InternalError(format!("Search outbox error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Search outbox error: {e}")))?;
} }
} }
@@ -253,6 +354,38 @@ pub async fn empty_folder(
.commit() .commit()
.map_err(|e| AppError::InternalError(format!("Database commit error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database commit error: {e}")))?;
let (total_count, unread_count) = if let Some(counts) = authoritative_counts {
counts
} else if counter_snapshot.is_some() {
folder_counts::set_if_snapshot_matches(
&config,
&session.user_hash,
&name,
counter_snapshot,
0,
0,
)?
} else {
folder_counts::current_counts(&config, &session.user_hash, &name)?.unwrap_or((0, 0))
};
let (total_count, unread_count) =
folder_counts::current_counts(&config, &session.user_hash, &name)?
.unwrap_or((total_count, unread_count));
event_bus
.publish(
&session.user_hash,
MailEvent::FolderStatus {
folder: name.clone(),
total_count,
unread_count,
},
)
.await;
event_bus
.publish(&session.user_hash, MailEvent::FolderUpdated)
.await;
Ok(Json(MessageResponse { status: "emptied" }).into_response()) Ok(Json(MessageResponse { status: "emptied" }).into_response())
} }
+108 -12
View File
@@ -1,8 +1,9 @@
use std::sync::Arc; use std::sync::Arc;
use axum::extract::Query;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{Extension, Json}; use axum::{Extension, Json};
use serde::Serialize; use serde::{Deserialize, Serialize};
use crate::auth::session::SessionState; use crate::auth::session::SessionState;
use crate::config::AppConfig; use crate::config::AppConfig;
@@ -13,6 +14,8 @@ use crate::imap::client::{ImapClient, ImapCredentials};
#[derive(Serialize)] #[derive(Serialize)]
struct FoldersResponse { struct FoldersResponse {
folders: Vec<FolderEntry>, folders: Vec<FolderEntry>,
pending_sync_count: u32,
pending_index_count: u32,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -27,10 +30,23 @@ struct FolderEntry {
const FOLDER_LIST_TTL_SECS: u32 = 300; const FOLDER_LIST_TTL_SECS: u32 = 300;
fn is_noselect(flags: &str) -> bool {
flags
.split(',')
.any(|flag| flag.trim().eq_ignore_ascii_case("\\Noselect"))
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct ListFoldersQuery {
#[serde(default)]
refresh: bool,
}
pub async fn list_folders( pub async fn list_folders(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>, Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Query(query): Query<ListFoldersQuery>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -38,16 +54,16 @@ pub async fn list_folders(
let cache_fresh = db::folders::is_folder_cache_fresh(&conn, FOLDER_LIST_TTL_SECS) let cache_fresh = db::folders::is_folder_cache_fresh(&conn, FOLDER_LIST_TTL_SECS)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
if !cache_fresh { let creds = ImapCredentials {
let creds = ImapCredentials { host: session.imap_host.clone(),
host: session.imap_host.clone(), port: session.imap_port,
port: session.imap_port, tls: session.imap_tls,
tls: session.imap_tls, tls_verify: session.imap_tls_verify,
tls_verify: session.imap_tls_verify, email: session.email.clone(),
email: session.email.clone(), password: session.password.clone(),
password: session.password.clone(), };
};
if !cache_fresh {
let imap_folders = imap_client let imap_folders = imap_client
.list_folders(&creds) .list_folders(&creds)
.await .await
@@ -72,9 +88,84 @@ pub async fn list_folders(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
} }
let cached = db::folders::get_all_folders(&conn) let mut cached = db::folders::get_all_folders(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
if query.refresh || !cache_fresh {
let names = cached
.iter()
.filter(|folder| !is_noselect(&folder.flags))
.map(|folder| folder.name.clone())
.collect::<Vec<_>>();
for folder in cached.iter().filter(|folder| is_noselect(&folder.flags)) {
db::folders::set_folder_counts(&conn, &folder.name, 0, 0)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
let mut statuses = imap_client
.folder_statuses_extended(&creds, &names)
.await
.unwrap_or_default()
.into_iter()
.collect::<std::collections::HashMap<_, _>>();
for name in &names {
let status = if let Some(status) = statuses.remove(name) {
Some(status)
} else {
match imap_client.folder_status_extended(&creds, name).await {
Ok(status) => Some(status),
Err(error) => {
tracing::debug!(folder = %name, error = %error, "Folder STATUS refresh failed; keeping cached counters");
None
}
}
};
let Some(status) = status else { continue };
let cached_folder = cached
.iter()
.find(|folder| folder.name.as_str() == name.as_str());
if let Some(cached_folder) = cached_folder
&& cached_folder.uid_validity != 0
&& status.uid_validity != 0
&& cached_folder.uid_validity != status.uid_validity
{
db::folders::invalidate_folder_freshness(&conn, name)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
if let Some(cached_folder) = cached_folder {
let applied = db::folders::set_folder_counts_if_unchanged(
&conn,
name,
cached_folder.counter_generation,
status.exists,
status.unseen,
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
if !applied {
tracing::debug!(folder = %name, "Skipped stale folder STATUS counters");
}
} else {
db::folders::set_folder_counts(&conn, name, status.exists, status.unseen)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
}
cached = db::folders::get_all_folders(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
let pending_sync_count = db::folders::pending_message_sync_count(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let account_deep_index = db::display_preferences::get_preferences(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
.deep_index;
let pending_index_count = if config.search.deep_index || account_deep_index {
db::messages::count_unindexed_messages(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
} else {
0
};
let folders: Vec<FolderEntry> = cached let folders: Vec<FolderEntry> = cached
.into_iter() .into_iter()
.map(|f| { .map(|f| {
@@ -94,5 +185,10 @@ pub async fn list_folders(
}) })
.collect(); .collect();
Ok(Json(FoldersResponse { folders }).into_response()) Ok(Json(FoldersResponse {
folders,
pending_sync_count,
pending_index_count,
})
.into_response())
} }
+633 -129
View File
File diff suppressed because it is too large. Load diff
+98 -20
View File
@@ -6,6 +6,7 @@ pub mod contacts;
pub mod diagnostics; pub mod diagnostics;
pub mod display_preferences; pub mod display_preferences;
pub mod drafts; pub mod drafts;
pub mod folder_counts;
pub mod folder_mgmt; pub mod folder_mgmt;
pub mod folder_preferences; pub mod folder_preferences;
pub mod folders; pub mod folders;
@@ -22,6 +23,7 @@ pub mod send;
pub mod settings; pub mod settings;
pub mod sieve; pub mod sieve;
pub mod special_folders; pub mod special_folders;
pub mod system_diagnostics;
pub mod tags; pub mod tags;
pub mod trusted_senders; pub mod trusted_senders;
pub mod unsubscribe; pub mod unsubscribe;
@@ -32,7 +34,7 @@ use std::time::Duration;
use axum::body::Body; use axum::body::Body;
use axum::extract::{ConnectInfo, DefaultBodyLimit}; use axum::extract::{ConnectInfo, DefaultBodyLimit};
use axum::http::{HeaderValue, Request}; use axum::http::{HeaderValue, Request, header};
use axum::response::Response; use axum::response::Response;
use axum::routing::{delete, get, patch, post, put}; use axum::routing::{delete, get, patch, post, put};
use axum::{Extension, Router, middleware}; use axum::{Extension, Router, middleware};
@@ -104,29 +106,70 @@ impl KeyExtractor for ProxyAwareIpExtractor {
} }
} }
fn response_content_type_is(response: &Response, expected: &str) -> bool {
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.is_some_and(|value| value.trim().eq_ignore_ascii_case(expected))
}
async fn add_security_headers(request: Request<Body>, next: middleware::Next) -> Response { async fn add_security_headers(request: Request<Body>, next: middleware::Next) -> Response {
let request_path = request.uri().path().to_owned();
let is_api =
request_path == "/api" || request_path.ends_with("/api") || request_path.contains("/api/");
let mut response = next.run(request).await; let mut response = next.run(request).await;
let is_html = response_content_type_is(&response, "text/html")
|| response_content_type_is(&response, "application/xhtml+xml");
let is_css = response_content_type_is(&response, "text/css");
let headers = response.headers_mut(); let headers = response.headers_mut();
headers.insert( headers.insert(
"x-content-type-options", "x-content-type-options",
HeaderValue::from_static("nosniff"), HeaderValue::from_static("nosniff"),
); );
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer")); if is_api {
headers.insert( headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
"permissions-policy", headers.insert(
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), "cross-origin-resource-policy",
); HeaderValue::from_static("same-origin"),
headers.insert( );
"cross-origin-opener-policy", headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
HeaderValue::from_static("same-origin"), headers.insert(
); "content-security-policy",
headers.insert( HeaderValue::from_static("frame-ancestors 'none'"),
"content-security-policy", );
HeaderValue::from_static( } else if is_html || is_css {
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' ws: wss:", headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
), }
);
if request_path.contains("/assets/") || request_path.ends_with("/favicon.svg") {
headers.insert(
"cross-origin-resource-policy",
HeaderValue::from_static("same-origin"),
);
}
if is_html {
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
headers.insert(
"permissions-policy",
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
);
headers.insert(
"cross-origin-opener-policy",
HeaderValue::from_static("same-origin"),
);
headers.insert(
"content-security-policy",
HeaderValue::from_static(
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; script-src-attr 'none'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self' blob:",
),
);
}
response response
} }
@@ -176,6 +219,8 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
event_bus, event_bus,
idle_manager, idle_manager,
} = services; } = services;
let system_diagnostics_telemetry =
Arc::new(system_diagnostics::SystemDiagnosticsTelemetry::new());
let governor_conf = GovernorConfigBuilder::default() let governor_conf = GovernorConfigBuilder::default()
.key_extractor(ProxyAwareIpExtractor { .key_extractor(ProxyAwareIpExtractor {
trusted_proxies: config.parsed_trusted_proxies(), trusted_proxies: config.parsed_trusted_proxies(),
@@ -200,6 +245,7 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
let protected_auth = Router::new() let protected_auth = Router::new()
.route("/session", get(auth::get_session)) .route("/session", get(auth::get_session))
.route("/session-policy", get(auth::session_policy))
.route("/sessions", get(auth::list_active_sessions)) .route("/sessions", get(auth::list_active_sessions))
.route("/sessions/revoke-others", post(auth::revoke_other_sessions)) .route("/sessions/revoke-others", post(auth::revoke_other_sessions))
.route("/sessions/{id}", delete(auth::revoke_active_session)) .route("/sessions/{id}", delete(auth::revoke_active_session))
@@ -532,13 +578,27 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler)); let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler));
let api_router = Router::new() let mut api_router = Router::new()
.route("/health", get(health::health_check)) .route("/health", get(health::health_check))
.route("/ready", get(health::readiness_check)) .route("/ready", get(health::readiness_check))
.nest("/auth", auth_router) .nest("/auth", auth_router)
.merge(ws_route) .merge(ws_route)
.merge(protected_data) .merge(protected_data);
.fallback(|| async { axum::http::StatusCode::NOT_FOUND });
if config.diagnostics_endpoint.enabled {
let diagnostics_path = config
.diagnostics_endpoint
.path
.trim()
.strip_prefix("/api")
.unwrap_or(config.diagnostics_endpoint.path.trim());
api_router = api_router.route(
diagnostics_path,
get(system_diagnostics::system_diagnostics),
);
}
let api_router = api_router.fallback(|| async { axum::http::StatusCode::NOT_FOUND });
let inner = Router::new() let inner = Router::new()
.nest("/api", api_router) .nest("/api", api_router)
@@ -555,6 +615,24 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
} }
}; };
let router = if config.diagnostics_endpoint.enabled {
let system_diagnostics_dependencies =
Arc::new(system_diagnostics::SystemDiagnosticsDependencies::new(
config.clone(),
store.clone(),
search_engine.clone(),
storage.clone(),
idle_manager.clone(),
system_diagnostics_telemetry.clone(),
));
router
.layer(middleware::from_fn(system_diagnostics::observe_requests))
.layer(Extension(system_diagnostics_dependencies))
.layer(Extension(system_diagnostics_telemetry))
} else {
router
};
let router = router let router = router
.layer(Extension(idle_manager)) .layer(Extension(idle_manager))
.layer(Extension(event_bus)) .layer(Extension(event_bus))
-4
View File
@@ -378,10 +378,6 @@ pub async fn export_key(
HeaderValue::from_static("application/pgp-keys; charset=utf-8"), HeaderValue::from_static("application/pgp-keys; charset=utf-8"),
); );
headers.insert("cache-control", HeaderValue::from_static("no-store")); headers.insert("cache-control", HeaderValue::from_static("no-store"));
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert( headers.insert(
"content-disposition", "content-disposition",
HeaderValue::from_str(&format!("attachment; filename=\"{filename}\"")) HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
+896
View File
@@ -0,0 +1,896 @@
use std::collections::{HashMap, VecDeque};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
use axum::middleware;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use serde::Serialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::auth::session::SessionStore;
use crate::config::{AppConfig, TrustedProxy};
use crate::db;
use crate::realtime::idle::IdleManager;
use crate::search::engine::SearchEngine;
use crate::storage::BlobStorage;
const MAX_SECURITY_EVENTS: usize = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
enum SecurityEventKind {
LoginRejected,
RateLimited,
Forbidden,
ServerError,
DiagnosticsDenied,
}
#[derive(Debug, Clone)]
struct SecurityEvent {
at_unix: u64,
kind: SecurityEventKind,
source_ip: IpAddr,
path: String,
}
pub struct SystemDiagnosticsTelemetry {
started_at_unix: u64,
started: Instant,
total_requests: AtomicU64,
client_errors: AtomicU64,
server_errors: AtomicU64,
login_attempts: AtomicU64,
events: Mutex<VecDeque<SecurityEvent>>,
}
pub struct SystemDiagnosticsDependencies {
config: Arc<AppConfig>,
store: Arc<SessionStore>,
search: Arc<SearchEngine>,
storage: Arc<dyn BlobStorage>,
idle_manager: Arc<IdleManager>,
telemetry: Arc<SystemDiagnosticsTelemetry>,
}
impl SystemDiagnosticsDependencies {
pub fn new(
config: Arc<AppConfig>,
store: Arc<SessionStore>,
search: Arc<SearchEngine>,
storage: Arc<dyn BlobStorage>,
idle_manager: Arc<IdleManager>,
telemetry: Arc<SystemDiagnosticsTelemetry>,
) -> Self {
Self {
config,
store,
search,
storage,
idle_manager,
telemetry,
}
}
}
impl SystemDiagnosticsTelemetry {
pub fn new() -> Self {
Self {
started_at_unix: now_unix(),
started: Instant::now(),
total_requests: AtomicU64::new(0),
client_errors: AtomicU64::new(0),
server_errors: AtomicU64::new(0),
login_attempts: AtomicU64::new(0),
events: Mutex::new(VecDeque::with_capacity(MAX_SECURITY_EVENTS)),
}
}
fn record_event(&self, kind: SecurityEventKind, source_ip: IpAddr, path: &str) {
let mut events = self
.events
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if events.len() >= MAX_SECURITY_EVENTS {
events.pop_front();
}
events.push_back(SecurityEvent {
at_unix: now_unix(),
kind,
source_ip,
path: path.chars().take(256).collect(),
});
}
fn security_snapshot(&self, window_secs: u64) -> Value {
let now = now_unix();
let cutoff = now.saturating_sub(window_secs);
let events = self
.events
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let mut by_kind: HashMap<SecurityEventKind, u64> = HashMap::new();
let mut by_ip: HashMap<IpAddr, u64> = HashMap::new();
let mut recent = Vec::new();
for event in events.iter().filter(|event| event.at_unix >= cutoff) {
*by_kind.entry(event.kind).or_default() += 1;
*by_ip.entry(event.source_ip).or_default() += 1;
}
for event in events
.iter()
.rev()
.filter(|event| event.at_unix >= cutoff)
.take(50)
{
recent.push(json!({
"timestampUnix": event.at_unix,
"kind": event.kind,
"sourceIp": event.source_ip.to_string(),
"path": event.path,
}));
}
let mut top_sources: Vec<(IpAddr, u64)> = by_ip.into_iter().collect();
top_sources.sort_by_key(|source| std::cmp::Reverse(source.1));
top_sources.truncate(20);
let login_rejected = *by_kind.get(&SecurityEventKind::LoginRejected).unwrap_or(&0);
let rate_limited = *by_kind.get(&SecurityEventKind::RateLimited).unwrap_or(&0);
let forbidden = *by_kind.get(&SecurityEventKind::Forbidden).unwrap_or(&0);
let server_errors = *by_kind.get(&SecurityEventKind::ServerError).unwrap_or(&0);
let diagnostics_denied = *by_kind
.get(&SecurityEventKind::DiagnosticsDenied)
.unwrap_or(&0);
let brute_force_signal = login_rejected >= 10 || rate_limited >= 3;
let diagnostics_attack_signal = diagnostics_denied >= 5;
json!({
"windowSeconds": window_secs,
"loginRejected": login_rejected,
"rateLimited": rate_limited,
"forbidden": forbidden,
"serverErrors": server_errors,
"diagnosticsDenied": diagnostics_denied,
"bruteForceSignal": brute_force_signal,
"diagnosticsAttackSignal": diagnostics_attack_signal,
"securityAlert": brute_force_signal || diagnostics_attack_signal,
"topSources": top_sources.into_iter().map(|(ip, count)| json!({
"ip": ip.to_string(), "events": count
})).collect::<Vec<_>>(),
"recentEvents": recent,
"bufferCapacity": MAX_SECURITY_EVENTS,
"bufferUsed": events.len(),
})
}
}
impl Default for SystemDiagnosticsTelemetry {
fn default() -> Self {
Self::new()
}
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn trusted_contains(trusted: &[TrustedProxy], ip: &IpAddr) -> bool {
trusted.iter().any(|entry| entry.contains(ip))
}
fn effective_client_ip(config: &AppConfig, headers: &HeaderMap, peer_ip: IpAddr) -> IpAddr {
let trusted = config.parsed_trusted_proxies();
if !trusted_contains(&trusted, &peer_ip) {
return peer_ip;
}
let mut current = peer_ip;
for value in headers.get_all("x-forwarded-for").iter().rev() {
let Ok(value) = value.to_str() else {
return current;
};
for entry in value.split(',').rev() {
if !trusted_contains(&trusted, &current) {
return current;
}
let Ok(parsed) = entry.trim().parse::<IpAddr>() else {
return current;
};
current = parsed;
}
}
current
}
fn diagnostics_client_ip(
config: &AppConfig,
headers: &HeaderMap,
peer_ip: IpAddr,
) -> Result<IpAddr, &'static str> {
let has_xff = headers.contains_key("x-forwarded-for");
let has_other_forwarding =
headers.contains_key("forwarded") || headers.contains_key("x-real-ip");
if !has_xff && !has_other_forwarding {
return Ok(peer_ip);
}
let trusted = config.parsed_trusted_proxies();
if !trusted_contains(&trusted, &peer_ip) {
return Err("proxy headers received from an untrusted peer");
}
if !has_xff {
return Err("diagnostics requires X-Forwarded-For when accessed through a trusted proxy");
}
let mut current = peer_ip;
let mut saw_forwarded_address = false;
for value in headers.get_all("x-forwarded-for").iter().rev() {
let value = value
.to_str()
.map_err(|_| "invalid X-Forwarded-For header encoding")?;
for entry in value.split(',').rev() {
if !trusted_contains(&trusted, &current) {
return Ok(current);
}
current = entry
.trim()
.parse::<IpAddr>()
.map_err(|_| "invalid X-Forwarded-For address")?;
saw_forwarded_address = true;
}
}
if !saw_forwarded_address {
return Err("empty X-Forwarded-For header");
}
Ok(current)
}
pub async fn observe_requests(request: Request<Body>, next: middleware::Next) -> Response {
let telemetry = request
.extensions()
.get::<Arc<SystemDiagnosticsTelemetry>>()
.cloned();
let config = request.extensions().get::<Arc<AppConfig>>().cloned();
let path = request.uri().path().to_string();
let peer_ip = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|value| value.0.ip())
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
let source_ip = config
.as_deref()
.map(|cfg| effective_client_ip(cfg, request.headers(), peer_ip))
.unwrap_or(peer_ip);
let is_login = path.ends_with("/api/auth/login");
if let Some(telemetry) = telemetry.as_ref() {
telemetry.total_requests.fetch_add(1, Ordering::Relaxed);
if is_login {
telemetry.login_attempts.fetch_add(1, Ordering::Relaxed);
}
}
let response = next.run(request).await;
if let Some(telemetry) = telemetry.as_ref() {
let status = response.status();
if status.is_client_error() {
telemetry.client_errors.fetch_add(1, Ordering::Relaxed);
}
if status.is_server_error() {
telemetry.server_errors.fetch_add(1, Ordering::Relaxed);
telemetry.record_event(SecurityEventKind::ServerError, source_ip, &path);
}
if status == StatusCode::TOO_MANY_REQUESTS {
telemetry.record_event(SecurityEventKind::RateLimited, source_ip, &path);
tracing::warn!(source_ip = %source_ip, path = %path, "request rate limited");
}
if status == StatusCode::FORBIDDEN {
telemetry.record_event(SecurityEventKind::Forbidden, source_ip, &path);
}
if is_login && matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
telemetry.record_event(SecurityEventKind::LoginRejected, source_ip, &path);
tracing::warn!(source_ip = %source_ip, status = %status, "login rejected");
}
}
response
}
fn constant_time_secret_eq(expected: &str, supplied: &str) -> bool {
let expected = Sha256::digest(expected.as_bytes());
let supplied = Sha256::digest(supplied.as_bytes());
expected
.iter()
.zip(supplied.iter())
.fold(0u8, |diff, (left, right)| diff | (left ^ right))
== 0
}
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
let authorization = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| {
let mut parts = value.split_whitespace();
let scheme = parts.next()?;
let credentials = parts.next()?;
if parts.next().is_some() {
return None;
}
scheme.eq_ignore_ascii_case("bearer").then_some(credentials)
});
authorization.or_else(|| {
headers
.get("x-rustbox-diagnostics-token")
.and_then(|value| value.to_str().ok())
.map(str::trim)
})
}
fn basic_credentials(headers: &HeaderMap) -> Option<(String, String)> {
let raw = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
let mut parts = raw.split_whitespace();
let scheme = parts.next()?;
let encoded = parts.next()?;
if parts.next().is_some() || !scheme.eq_ignore_ascii_case("basic") {
return None;
}
let decoded = STANDARD.decode(encoded).ok()?;
let decoded = String::from_utf8(decoded).ok()?;
let (username, password) = decoded.split_once(':')?;
Some((username.to_string(), password.to_string()))
}
fn harden_diagnostics_response(mut response: Response) -> Response {
let headers = response.headers_mut();
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert("pragma", HeaderValue::from_static("no-cache"));
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert(
"cross-origin-resource-policy",
HeaderValue::from_static("same-origin"),
);
response
}
fn unauthorized(auth_mode: &str) -> Response {
let challenge = if auth_mode.eq_ignore_ascii_case("basic") {
"Basic realm=\"RustBox diagnostics\", charset=\"UTF-8\""
} else {
"Bearer realm=\"RustBox diagnostics\""
};
let mut response = (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "diagnostics authentication required"})),
)
.into_response();
response.headers_mut().insert(
header::WWW_AUTHENTICATE,
HeaderValue::from_str(challenge).unwrap_or_else(|_| HeaderValue::from_static("Bearer")),
);
harden_diagnostics_response(response)
}
fn forbidden() -> Response {
harden_diagnostics_response(
(
StatusCode::FORBIDDEN,
Json(json!({"error": "diagnostics endpoint is not available from this network"})),
)
.into_response(),
)
}
fn sanitize_url(raw: Option<&str>) -> Option<String> {
let raw = raw?.trim();
if raw.is_empty() {
return None;
}
if let Ok(url) = url::Url::parse(raw) {
if url.scheme() == "sqlite" {
return Some(format!("sqlite:{}", url.path()));
}
let host = url.host_str().unwrap_or("<unknown-host>");
let port = url
.port()
.map(|value| format!(":{value}"))
.unwrap_or_default();
let path = if url.path() == "/" { "" } else { url.path() };
return Some(format!("{}://{}{}{}", url.scheme(), host, port, path));
}
Some("<configured; redacted>".to_string())
}
fn process_snapshot() -> Value {
// Keep this intentionally limited to portable, process-local information.
// Rust std does not expose cross-platform RSS/thread/FD metrics, and the
// diagnostics endpoint must not depend on host-specific /proc probes.
json!({
"pid": std::process::id(),
})
}
fn sanitized_config(config: &AppConfig) -> Value {
json!({
"environment": config.environment,
"pathsAndLogging": {
"dataDir": config.data_dir,
"logDir": config.log_dir,
"logLevel": config.log_level,
},
"ui": {
"defaultTheme": config.default_theme,
"defaultInterfaceSkin": config.default_interface_skin,
},
"listen": {"host": config.host, "port": config.port, "basePath": config.base_path},
"mail": {
"imapHost": config.imap_host,
"imapPort": config.imap_port,
"smtpHost": config.smtp_host,
"smtpPort": config.smtp_port,
"tlsEnabled": config.tls_enabled,
"tlsVerifyOverride": config.tls_verify,
"sieveEnabled": config.sieve_enabled,
"sieveHost": config.sieve_host,
"sievePort": config.sieve_port,
"sieveTlsMode": config.sieve_tls_mode,
"sieveAllowInsecureAuth": config.sieve_allow_insecure_auth,
"sieveTimeoutSecs": config.sieve_timeout_secs,
},
"limits": {
"maxAttachmentSizeMiB": config.max_attachment_size_mb,
"maxMessageSizeMiB": config.max_message_size_mb,
"sessionTimeoutHours": config.session_timeout_hours,
"rememberMeEnabled": config.remember_me_enabled,
"rememberMeDays": config.remember_me_days,
},
"database": {
"backend": config.database_backend,
"url": sanitize_url(config.database_url.as_deref()),
},
"sessions": {
"backend": config.session_backend,
"redisUrl": sanitize_url(config.redis_url.as_deref()),
"redisSentinelNodes": config.redis_sentinel_nodes.iter().map(|value| sanitize_url(Some(value)).unwrap_or_else(|| value.clone())).collect::<Vec<_>>(),
"redisSentinelMaster": config.redis_sentinel_master,
"redisPasswordConfigured": config.redis_password.as_deref().is_some_and(|value| !value.is_empty()),
"redisSentinelPasswordConfigured": config.redis_sentinel_password.as_deref().is_some_and(|value| !value.is_empty()),
"sessionEncryptionKeyConfigured": config.session_encryption_key.as_deref().is_some_and(|value| !value.is_empty()),
},
"clusterMode": config.cluster_mode,
"storage": {
"backend": config.storage.backend,
"path": config.storage.path,
"shared": config.storage.shared,
"bucket": config.storage.bucket,
"region": config.storage.region,
"endpoint": sanitize_url(config.storage.endpoint.as_deref()),
"prefix": config.storage.prefix,
"pathStyle": config.storage.path_style,
"allowHttp": config.storage.allow_http,
"accessKeyConfigured": config.storage.access_key_id.as_deref().is_some_and(|value| !value.is_empty()),
"secretKeyConfigured": config.storage.secret_access_key.as_deref().is_some_and(|value| !value.is_empty()),
"sessionTokenConfigured": config.storage.session_token.as_deref().is_some_and(|value| !value.is_empty()),
"requestTimeoutSecs": config.storage.request_timeout_secs,
"connectTimeoutSecs": config.storage.connect_timeout_secs,
"maxRetries": config.storage.max_retries,
"retryTimeoutSecs": config.storage.retry_timeout_secs,
"serverSideEncryption": config.storage.server_side_encryption,
"kmsKeyConfigured": config.storage.kms_key_id.as_deref().is_some_and(|value| !value.is_empty()),
},
"search": {
"backend": config.search.backend,
"path": config.search.path,
"urls": config.search.urls.iter().map(|value| sanitize_url(Some(value)).unwrap_or_else(|| "<invalid url>".to_string())).collect::<Vec<_>>(),
"indexPrefix": config.search.index_prefix,
"usernameConfigured": config.search.username.as_deref().is_some_and(|value| !value.is_empty()),
"passwordConfigured": config.search.password.as_deref().is_some_and(|value| !value.is_empty()),
"required": config.search.required,
"allowInsecureHttp": config.search.allow_insecure_http,
"requestTimeoutSecs": config.search.request_timeout_secs,
"deepIndex": config.search.deep_index,
"workerIntervalSecs": config.search.worker_interval_secs,
"workerBatchSize": config.search.worker_batch_size,
"workerMaxAttempts": config.search.worker_max_attempts,
},
"sync": {
"intervalSecs": config.sync.interval_secs,
"deepIndexBatchSize": config.sync.deep_index_batch_size,
},
"features": {
"userDiagnostics": config.diagnostics_enabled,
"about": config.about_enabled,
"pgp": config.pgp_enabled,
"securityHeaders": config.security_headers_enabled,
},
"pgp": {
"enabled": config.pgp_enabled,
"wkdEnabled": config.pgp_wkd_enabled,
"hkpEnabled": config.pgp_hkp_enabled,
"hkpUrl": sanitize_url(Some(&config.pgp_hkp_url)),
"networkTimeoutSecs": config.pgp_network_timeout_secs,
"maxKeyBytes": config.pgp_max_key_bytes,
"allowPrivateExport": config.pgp_allow_private_export,
},
"specialFolders": {
"sent": config.default_sent_folder,
"drafts": config.default_drafts_folder,
"trash": config.default_trash_folder,
"junk": config.default_junk_folder,
"archive": config.default_archive_folder,
},
"outgoingHeaders": {
"count": config.outgoing_headers.len(),
"names": config.outgoing_headers.iter().filter_map(|entry| entry.split_once(':').map(|(name, _)| name.trim().to_string())).collect::<Vec<_>>(),
},
"trustedProxies": config.trusted_proxies,
"diagnosticsEndpoint": {
"enabled": config.diagnostics_endpoint.enabled,
"path": config.diagnostics_endpoint.path,
"allowedNetworks": config.diagnostics_endpoint.allowed_networks,
"auth": config.diagnostics_endpoint.auth,
"credentialConfigured": if config.diagnostics_endpoint.auth.eq_ignore_ascii_case("basic") {
config.diagnostics_endpoint.basic_username.as_deref().is_some_and(|value| !value.is_empty())
&& config.diagnostics_endpoint.basic_password.as_deref().is_some_and(|value| !value.is_empty())
} else {
config.diagnostics_endpoint.token.as_deref().is_some_and(|value| !value.is_empty())
},
"includeUserIdentifiers": config.diagnostics_endpoint.include_user_identifiers,
"probeTimeoutSecs": config.diagnostics_endpoint.probe_timeout_secs,
"securityWindowSecs": config.diagnostics_endpoint.security_window_secs,
}
})
}
fn configuration_checks(config: &AppConfig) -> Vec<Value> {
let mut checks = Vec::new();
if config.environment.eq_ignore_ascii_case("production") && !config.security_headers_enabled {
checks.push(json!({"severity":"warning","code":"security_headers_disabled","message":"Browser security headers are disabled in production."}));
}
if config.search.allow_insecure_http {
checks.push(json!({"severity":"warning","code":"opensearch_http_allowed","message":"OpenSearch permits plain HTTP."}));
}
if config.storage.allow_http {
checks.push(json!({"severity":"warning","code":"storage_http_allowed","message":"S3-compatible storage permits plain HTTP."}));
}
if config
.diagnostics_endpoint
.allowed_networks
.iter()
.any(|network| matches!(network.trim(), "0.0.0.0/0" | "::/0"))
{
checks.push(json!({"severity":"warning","code":"diagnostics_allowlist_global","message":"Diagnostics allowlist contains a global network; restrict it at the application and firewall/reverse-proxy layers."}));
}
if config
.diagnostics_endpoint
.auth
.eq_ignore_ascii_case("basic")
{
checks.push(json!({"severity":"info","code":"diagnostics_basic_auth","message":"Diagnostics uses Basic Auth; transport must be protected by HTTPS/TLS at RustBox or its trusted reverse proxy."}));
}
if config.diagnostics_endpoint.include_user_identifiers {
checks.push(json!({"severity":"info","code":"diagnostics_user_identifiers","message":"Diagnostics may expose authenticated user e-mail addresses and stable user hashes to authorized operators."}));
}
if config.search.deep_index {
checks.push(json!({"severity":"info","code":"deep_index_enabled","message":"Server-wide deep index is enabled; full message bodies may be fetched and stored/indexed automatically."}));
}
if config.tls_verify == Some(false) {
checks.push(json!({"severity":"warning","code":"tls_verification_disabled","message":"TLS certificate verification is globally disabled for configured mail connections."}));
}
checks
}
async fn timed_probe<F, T>(timeout: Duration, future: F) -> (Result<T, String>, u128)
where
F: std::future::Future<Output = Result<T, String>>,
{
let started = Instant::now();
let result = match tokio::time::timeout(timeout, future).await {
Ok(result) => result,
Err(_) => Err(format!("probe timed out after {} ms", timeout.as_millis())),
};
(result, started.elapsed().as_millis())
}
pub async fn system_diagnostics(
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Extension(dependencies): Extension<Arc<SystemDiagnosticsDependencies>>,
) -> Response {
let config = dependencies.config.as_ref();
let store = dependencies.store.as_ref();
let search = dependencies.search.as_ref();
let storage = dependencies.storage.as_ref();
let idle_manager = dependencies.idle_manager.as_ref();
let telemetry = dependencies.telemetry.as_ref();
let source_ip = match diagnostics_client_ip(config, &headers, peer.ip()) {
Ok(source_ip) => source_ip,
Err(_) => {
telemetry.record_event(
SecurityEventKind::DiagnosticsDenied,
peer.ip(),
&config.diagnostics_endpoint.path,
);
tracing::warn!(peer_ip = %peer.ip(), "administrator diagnostics proxy/source validation denied");
return forbidden();
}
};
let allowed = config
.parsed_diagnostics_allowed_networks()
.map(|networks| networks.iter().any(|network| network.contains(&source_ip)))
.unwrap_or(false);
if !allowed {
telemetry.record_event(
SecurityEventKind::DiagnosticsDenied,
source_ip,
&config.diagnostics_endpoint.path,
);
tracing::warn!(source_ip = %source_ip, "administrator diagnostics denied by network allowlist");
return forbidden();
}
let authenticated = if config
.diagnostics_endpoint
.auth
.eq_ignore_ascii_case("basic")
{
match (
config.diagnostics_endpoint.basic_username.as_deref(),
config.diagnostics_endpoint.basic_password.as_deref(),
basic_credentials(&headers),
) {
(Some(expected_user), Some(expected_password), Some((user, password))) => {
constant_time_secret_eq(expected_user, &user)
&& constant_time_secret_eq(expected_password, &password)
}
_ => false,
}
} else {
match (
config.diagnostics_endpoint.token.as_deref(),
bearer_token(&headers),
) {
(Some(expected), Some(supplied)) => constant_time_secret_eq(expected, supplied),
_ => false,
}
};
if !authenticated {
telemetry.record_event(
SecurityEventKind::DiagnosticsDenied,
source_ip,
&config.diagnostics_endpoint.path,
);
tracing::warn!(source_ip = %source_ip, auth_mode = %config.diagnostics_endpoint.auth, "administrator diagnostics authentication rejected");
return unauthorized(&config.diagnostics_endpoint.auth);
}
let timeout = Duration::from_secs(config.diagnostics_endpoint.probe_timeout_secs);
let db_future = async { Ok::<_, String>(db::pool::diagnostics().await) };
let storage_future = async { storage.health().await };
let search_future = async { search.diagnostics().await };
let sessions_future = store.persistence_diagnostics();
let (db_probe, storage_probe, search_probe, session_probe) = tokio::join!(
timed_probe(timeout, db_future),
timed_probe(timeout, storage_future),
timed_probe(timeout, search_future),
timed_probe(timeout, sessions_future),
);
let db_diag = db_probe
.0
.unwrap_or_else(|error| db::pool::DatabaseDiagnostics {
backend: config.database_backend.clone(),
status: "error".to_string(),
latency_ms: db_probe.1,
pool_size: None,
idle_connections: None,
database_bytes: None,
engine_version: None,
account_count: None,
schema_version_min: None,
schema_version_max: None,
schema_aligned: None,
expected_schema_version: db::pool::latest_schema_version(),
error: Some(error),
});
let storage_status = match storage_probe.0 {
Ok(()) => json!({"status":"ok","latencyMs":storage_probe.1}),
Err(error) => json!({"status":"error","latencyMs":storage_probe.1,"error":error}),
};
let search_status = match search_probe.0 {
Ok(details) => {
let detail_status = details
.get("status")
.and_then(Value::as_str)
.unwrap_or("ok");
let status = if detail_status == "degraded" {
"degraded"
} else {
"ok"
};
json!({"status":status,"latencyMs":search_probe.1,"backend":search.backend_name(),"required":config.search.required,"details":details})
}
Err(error) => {
json!({"status": if config.search.required {"error"} else {"degraded"},"latencyMs":search_probe.1,"backend":search.backend_name(),"required":config.search.required,"error":error})
}
};
let session_status = match session_probe.0 {
Ok(value) => json!({"status":"ok","latencyMs":session_probe.1,"persistence":value}),
Err(error) => {
json!({"status":"error","latencyMs":session_probe.1,"backend":config.session_backend,"error":error})
}
};
let local_sessions =
store.diagnostics_snapshot(config.diagnostics_endpoint.include_user_identifiers);
let security = telemetry.security_snapshot(config.diagnostics_endpoint.security_window_secs);
let security_alert = security
.get("securityAlert")
.and_then(Value::as_bool)
.unwrap_or(false);
let config_checks = configuration_checks(config);
let critical = db_diag.status != "ok"
|| storage_status.get("status").and_then(Value::as_str) != Some("ok")
|| session_status.get("status").and_then(Value::as_str) != Some("ok")
|| (config.search.required
&& search_status.get("status").and_then(Value::as_str) != Some("ok"));
let degraded = !critical
&& (security_alert
|| db_diag.schema_aligned == Some(false)
|| search_status.get("status").and_then(Value::as_str) == Some("degraded")
|| config_checks
.iter()
.any(|check| check.get("severity").and_then(Value::as_str) == Some("warning")));
let response = json!({
"status": if critical { "error" } else if degraded { "degraded" } else { "ok" },
"timestampUnix": now_unix(),
"request": {"sourceIp": source_ip.to_string()},
"application": {
"name": "RustBox",
"version": env!("CARGO_PKG_VERSION"),
"uptimeSeconds": telemetry.started.elapsed().as_secs(),
"startedAtUnix": telemetry.started_at_unix,
"process": process_snapshot(),
"clusterMode": config.cluster_mode,
},
"runtimeCounters": {
"totalRequests": telemetry.total_requests.load(Ordering::Relaxed),
"clientErrors": telemetry.client_errors.load(Ordering::Relaxed),
"serverErrors": telemetry.server_errors.load(Ordering::Relaxed),
"loginAttempts": telemetry.login_attempts.load(Ordering::Relaxed),
"idleTasks": idle_manager.task_count(),
"idleSubscribers": idle_manager.subscriber_count(),
"idleConnections": idle_manager.diagnostics_snapshot(config.diagnostics_endpoint.include_user_identifiers),
},
"configuration": sanitized_config(config),
"checks": config_checks,
"database": db_diag,
"storage": {
"backend": config.effective_storage_backend(),
"health": storage_status,
},
"search": search_status,
"sessions": {
"backend": config.session_backend,
"health": session_status,
"localNode": local_sessions,
},
"security": security,
});
harden_diagnostics_response(
(
if critical {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::OK
},
Json(response),
)
.into_response(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diagnostics_secret_compare_matches_only_exact_value() {
assert!(constant_time_secret_eq(
"abcdefghijklmnopqrstuvwx",
"abcdefghijklmnopqrstuvwx"
));
assert!(!constant_time_secret_eq(
"abcdefghijklmnopqrstuvwx",
"abcdefghijklmnopqrstuvwy"
));
}
#[test]
fn diagnostics_url_redaction_removes_credentials_and_query() {
assert_eq!(
sanitize_url(Some(
"postgres://user:secret@db.example.test:5432/rustbox?sslmode=require"
)),
Some("postgres://db.example.test:5432/rustbox".to_string())
);
assert_eq!(
sanitize_url(Some("redis://:secret@redis.example.test:6379/2")),
Some("redis://redis.example.test:6379/2".to_string())
);
}
#[test]
fn diagnostics_client_ip_ignores_untrusted_forwarded_header() {
let config = AppConfig {
trusted_proxies: Some("127.0.0.1/32".to_string()),
..AppConfig::default()
};
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.42"));
let peer: IpAddr = "198.51.100.10".parse().unwrap();
assert_eq!(effective_client_ip(&config, &headers, peer), peer);
}
#[test]
fn diagnostics_client_ip_uses_forwarded_header_only_from_trusted_proxy() {
let config = AppConfig {
trusted_proxies: Some("127.0.0.1/32".to_string()),
..AppConfig::default()
};
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.42"));
assert_eq!(
effective_client_ip(&config, &headers, "127.0.0.1".parse().unwrap()),
"203.0.113.42".parse::<IpAddr>().unwrap()
);
}
#[test]
fn diagnostics_endpoint_rejects_proxy_headers_from_untrusted_peer() {
let config = AppConfig::default();
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.42"));
assert!(diagnostics_client_ip(&config, &headers, "127.0.0.1".parse().unwrap(),).is_err());
}
#[test]
fn diagnostics_endpoint_rejects_malformed_xff_from_trusted_proxy() {
let config = AppConfig {
trusted_proxies: Some("127.0.0.1/32".to_string()),
..AppConfig::default()
};
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("not-an-ip"));
assert!(diagnostics_client_ip(&config, &headers, "127.0.0.1".parse().unwrap(),).is_err());
}
#[test]
fn diagnostics_endpoint_accepts_xff_from_configured_trusted_proxy() {
let config = AppConfig {
trusted_proxies: Some("127.0.0.1/32".to_string()),
..AppConfig::default()
};
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.42"));
assert_eq!(
diagnostics_client_ip(&config, &headers, "127.0.0.1".parse().unwrap()).unwrap(),
"203.0.113.42".parse::<IpAddr>().unwrap()
);
}
}
+236 -30
View File
@@ -12,7 +12,8 @@ use tower_governor::key_extractor::KeyExtractor;
use crate::imap::client::mock::MockImapClient; use crate::imap::client::mock::MockImapClient;
use crate::imap::client::{ use crate::imap::client::{
EmailAddress, ImapAttachment, ImapError, ImapFolder, ImapMessageBody, ImapMessageHeader, EmailAddress, FolderStatusExtended, ImapAttachment, ImapError, ImapFolder, ImapMessageBody,
ImapMessageHeader,
}; };
use crate::smtp::client::SmtpError; use crate::smtp::client::SmtpError;
use crate::smtp::client::mock::MockSmtpClient; use crate::smtp::client::mock::MockSmtpClient;
@@ -304,15 +305,18 @@ async fn disabled_optional_features_are_not_registered() {
} }
#[tokio::test] #[tokio::test]
async fn auth_policy_exposes_optional_feature_switches() { async fn auth_policy_exposes_only_login_requirements_before_authentication() {
let dir = setup_static_dir(); let dir = setup_static_dir();
let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); let mut config = (*test_config(dir.path().to_str().unwrap())).clone();
config.remember_me_enabled = true;
config.sieve_enabled = false; config.sieve_enabled = false;
config.diagnostics_enabled = false; config.diagnostics_enabled = false;
config.about_enabled = false; config.about_enabled = false;
let store = test_store();
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
let app = create_router( let app = create_router(
Arc::new(config), Arc::new(config),
test_store(), store,
test_imap_client(), test_imap_client(),
test_smtp_client(), test_smtp_client(),
test_search_engine("/tmp/oxi-test"), test_search_engine("/tmp/oxi-test"),
@@ -321,6 +325,7 @@ async fn auth_policy_exposes_optional_feature_switches() {
); );
let response = app let response = app
.clone()
.oneshot( .oneshot(
Request::builder() Request::builder()
.uri("/api/auth/policy") .uri("/api/auth/policy")
@@ -332,13 +337,52 @@ async fn auth_policy_exposes_optional_feature_switches() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["rememberMeEnabled"], true);
for key in [
"rememberMeDays",
"sessionTimeoutHours",
"pgpEnabled",
"sieveEnabled",
"diagnosticsEnabled",
"aboutEnabled",
] {
assert!(json.get(key).is_none(), "public policy leaked {key}");
}
let unauth = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/session-policy")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(unauth.status(), StatusCode::UNAUTHORIZED);
let mut request = Request::builder()
.uri("/api/auth/session-policy")
.header("x-requested-with", "XMLHttpRequest");
for (name, value) in auth_headers(&browser_id, &account_id, &token) {
request = request.header(name, value);
}
let response = app
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["sieveEnabled"], false); assert_eq!(json["sieveEnabled"], false);
assert_eq!(json["diagnosticsEnabled"], false); assert_eq!(json["diagnosticsEnabled"], false);
assert_eq!(json["aboutEnabled"], false); assert_eq!(json["aboutEnabled"], false);
assert!(json.get("rememberMeDays").is_none());
assert!(json.get("sessionTimeoutHours").is_none());
} }
#[tokio::test] #[tokio::test]
async fn security_headers_are_opt_in() { async fn security_headers_are_opt_in_and_content_type_aware() {
let dir = setup_static_dir(); let dir = setup_static_dir();
let app = create_router( let app = create_router(
test_config(dir.path().to_str().unwrap()), test_config(dir.path().to_str().unwrap()),
@@ -350,43 +394,134 @@ async fn security_headers_are_opt_in() {
test_idle_manager(), test_idle_manager(),
); );
let response = app let response = app
.oneshot( .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
Request::builder()
.uri("/api/health")
.body(Body::empty())
.unwrap(),
)
.await .await
.unwrap(); .unwrap();
assert!(response.headers().get("content-security-policy").is_none()); assert!(response.headers().get("content-security-policy").is_none());
assert!(response.headers().get("x-content-type-options").is_none());
let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); let disabled_api = create_router(
config.security_headers_enabled = true; test_config(dir.path().to_str().unwrap()),
let app = create_router(
Arc::new(config),
test_store(), test_store(),
test_imap_client(), test_imap_client(),
test_smtp_client(), test_smtp_client(),
test_search_engine("/tmp/oxi-test"), test_search_engine("/tmp/oxi-test"),
test_event_bus(), test_event_bus(),
test_idle_manager(), test_idle_manager(),
); )
let response = app .oneshot(
.oneshot( Request::builder()
Request::builder() .uri("/api/health")
.uri("/api/health") .body(Body::empty())
.body(Body::empty()) .unwrap(),
.unwrap(), )
) .await
.await .unwrap();
.unwrap();
assert_eq!(response.headers()["x-frame-options"], "DENY");
assert!( assert!(
response.headers()["content-security-policy"] disabled_api
.to_str() .headers()
.unwrap() .get("x-content-type-options")
.contains("default-src 'self'") .is_none()
); );
assert!(
disabled_api
.headers()
.get("cross-origin-resource-policy")
.is_none()
);
assert!(
disabled_api
.headers()
.get("content-security-policy")
.is_none()
);
assert!(disabled_api.headers().get("x-frame-options").is_none());
let mut config = (*test_config(dir.path().to_str().unwrap())).clone();
config.security_headers_enabled = true;
let config = Arc::new(config);
let html = create_router(
config.clone(),
test_store(),
test_imap_client(),
test_smtp_client(),
test_search_engine("/tmp/oxi-test"),
test_event_bus(),
test_idle_manager(),
)
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(html.headers()["x-content-type-options"], "nosniff");
assert_eq!(html.headers()["x-frame-options"], "DENY");
assert_eq!(html.headers()["referrer-policy"], "no-referrer");
assert_eq!(
html.headers()["permissions-policy"],
"camera=(), microphone=(), geolocation=()"
);
assert_eq!(html.headers()["cross-origin-opener-policy"], "same-origin");
let html_csp = html.headers()["content-security-policy"].to_str().unwrap();
assert!(html_csp.contains("default-src 'self'"));
assert!(html_csp.contains("script-src 'self';"));
assert!(html_csp.contains("script-src-attr 'none'"));
assert!(html_csp.contains("style-src 'self'; style-src-attr 'unsafe-inline'"));
assert!(!html_csp.contains("style-src 'self' 'unsafe-inline'"));
assert!(!html_csp.contains("unsafe-inline'; script-src"));
assert!(!html_csp.contains("script-src 'self' 'unsafe-inline'"));
let css = create_router(
config.clone(),
test_store(),
test_imap_client(),
test_smtp_client(),
test_search_engine("/tmp/oxi-test"),
test_event_bus(),
test_idle_manager(),
)
.oneshot(
Request::builder()
.uri("/assets/app.css")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(css.headers()["x-content-type-options"], "nosniff");
assert_eq!(css.headers()["referrer-policy"], "no-referrer");
assert_eq!(css.headers()["cross-origin-resource-policy"], "same-origin");
assert!(css.headers().get("x-frame-options").is_none());
assert!(css.headers().get("permissions-policy").is_none());
assert!(css.headers().get("cross-origin-opener-policy").is_none());
assert!(css.headers().get("content-security-policy").is_none());
let api = create_router(
config,
test_store(),
test_imap_client(),
test_smtp_client(),
test_search_engine("/tmp/oxi-test"),
test_event_bus(),
test_idle_manager(),
)
.oneshot(
Request::builder()
.uri("/api/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(api.headers()["x-content-type-options"], "nosniff");
assert_eq!(api.headers()["cache-control"], "no-store");
assert_eq!(api.headers()["cross-origin-resource-policy"], "same-origin");
assert_eq!(
api.headers()["content-security-policy"],
"frame-ancestors 'none'"
);
assert_eq!(api.headers()["x-frame-options"], "DENY");
assert!(api.headers().get("referrer-policy").is_none());
assert!(api.headers().get("permissions-policy").is_none());
assert!(api.headers().get("cross-origin-opener-policy").is_none());
} }
#[tokio::test] #[tokio::test]
@@ -945,6 +1080,77 @@ async fn get_folders_returns_200_with_folder_list() {
assert_eq!(folders[1]["name"], "Sent"); assert_eq!(folders[1]["name"], "Sent");
} }
#[tokio::test]
async fn folder_refresh_uses_server_counts_without_relisting_fresh_folder_tree() {
let static_dir = setup_static_dir();
let data_dir = TempDir::new().unwrap();
let config = test_config_with_imap(
static_dir.path().to_str().unwrap(),
data_dir.path().to_str().unwrap(),
);
let store = test_store();
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
let conn = crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash)
.expect("open user DB");
crate::db::folders::upsert_folder(
&conn,
"INBOX",
Some("/"),
None,
"\\HasNoChildren",
true,
3444,
1,
1,
10,
)
.unwrap();
crate::db::folders::touch_all_folders(&conn).unwrap();
drop(conn);
let mock = MockImapClient::new().with_folder_status_extended(FolderStatusExtended {
uid_validity: 1,
exists: 3444,
uid_next: 3445,
unseen: 12,
highest_modseq: 11,
});
let app = create_router(
config,
store,
Arc::new(mock),
test_smtp_client(),
test_search_engine(data_dir.path().to_str().unwrap()),
test_event_bus(),
test_idle_manager(),
);
let mut request = Request::builder()
.uri("/api/folders?refresh=true")
.header("x-requested-with", "XMLHttpRequest");
for (name, value) in auth_headers(&browser_id, &account_id, &token) {
request = request.header(name, value);
}
let response = app
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let inbox = json["folders"]
.as_array()
.unwrap()
.iter()
.find(|folder| folder["name"] == "INBOX")
.expect("INBOX remains in the cached folder tree");
assert_eq!(inbox["total_count"], 3444);
assert_eq!(inbox["unread_count"], 12);
}
#[tokio::test] #[tokio::test]
async fn get_folders_returns_503_when_imap_fails() { async fn get_folders_returns_503_when_imap_fails() {
let static_dir = setup_static_dir(); let static_dir = setup_static_dir();
+74 -3
View File
@@ -219,6 +219,25 @@ impl SearchEngine {
} }
} }
pub async fn diagnostics(&self) -> Result<JsonValue, String> {
match &self.backend {
SearchBackend::Disabled => Ok(json!({
"backend": "disabled",
"status": "disabled"
})),
SearchBackend::LocalTantivy { base_dir } => {
std::fs::create_dir_all(base_dir)
.map_err(|e| format!("failed to prepare local search directory: {e}"))?;
Ok(json!({
"backend": "local_tantivy",
"status": "ok",
"path": base_dir,
}))
}
SearchBackend::OpenSearch { backend, .. } => backend.diagnostics().await,
}
}
pub fn open_user_index(&self, user_hash: &str) -> Result<UserIndex, String> { pub fn open_user_index(&self, user_hash: &str) -> Result<UserIndex, String> {
match &self.backend { match &self.backend {
SearchBackend::Disabled => Err("search index is disabled by configuration".to_string()), SearchBackend::Disabled => Err("search index is disabled by configuration".to_string()),
@@ -695,9 +714,6 @@ impl LocalUserIndex {
impl OpenSearchBackend { impl OpenSearchBackend {
fn event_lease_secs(&self) -> u64 { fn event_lease_secs(&self) -> u64 {
// A mutation can probe/create the index and then write the document.
// Account for failover across all configured URLs and keep a safety margin so
// another worker cannot reclaim the same document while this request is alive.
self.request_timeout_secs self.request_timeout_secs
.saturating_mul(self.urls.len().max(1) as u64) .saturating_mul(self.urls.len().max(1) as u64)
.saturating_mul(4) .saturating_mul(4)
@@ -796,6 +812,61 @@ impl OpenSearchBackend {
} }
} }
async fn diagnostics(&self) -> Result<JsonValue, String> {
let (status, body) = self
.request(Method::GET, "_cluster/health?local=true", None)
.await?;
if !status.is_success() {
return Err(format!("OpenSearch health check failed ({status}): {body}"));
}
self.ensure_index().await?;
let health: JsonValue = serde_json::from_str(&body)
.map_err(|e| format!("invalid OpenSearch health response: {e}"))?;
let (count_status, count_body) = self
.request(Method::GET, &format!("{}/_count", self.index_name), None)
.await?;
if !count_status.is_success() {
return Err(format!(
"OpenSearch index count failed ({count_status}): {count_body}"
));
}
let document_count = serde_json::from_str::<JsonValue>(&count_body)
.map_err(|e| format!("invalid OpenSearch count response: {e}"))?
.get("count")
.and_then(JsonValue::as_u64)
.ok_or_else(|| {
"OpenSearch count response does not contain a numeric count".to_string()
})?;
let cluster_status = health
.get("status")
.and_then(JsonValue::as_str)
.unwrap_or("unknown");
if cluster_status.eq_ignore_ascii_case("red") {
return Err("OpenSearch cluster health is red".to_string());
}
let diagnostics_status = if cluster_status.eq_ignore_ascii_case("green") {
"ok"
} else {
"degraded"
};
Ok(json!({
"backend": "opensearch",
"status": diagnostics_status,
"clusterStatus": cluster_status,
"clusterName": health.get("cluster_name").and_then(JsonValue::as_str),
"numberOfNodes": health.get("number_of_nodes").and_then(JsonValue::as_u64),
"numberOfDataNodes": health.get("number_of_data_nodes").and_then(JsonValue::as_u64),
"activePrimaryShards": health.get("active_primary_shards").and_then(JsonValue::as_u64),
"activeShards": health.get("active_shards").and_then(JsonValue::as_u64),
"relocatingShards": health.get("relocating_shards").and_then(JsonValue::as_u64),
"initializingShards": health.get("initializing_shards").and_then(JsonValue::as_u64),
"unassignedShards": health.get("unassigned_shards").and_then(JsonValue::as_u64),
"index": self.index_name,
"documentCount": document_count,
"configuredUrls": self.urls.len(),
}))
}
fn document_id(user_hash: &str, uid: u32, folder: &str) -> String { fn document_id(user_hash: &str, uid: u32, folder: &str) -> String {
format!( format!(
"{:x}", "{:x}",
+56 -6
View File
@@ -4,7 +4,7 @@ use async_trait::async_trait;
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
use sqlx::{FromRow, MySqlPool, PgPool}; use sqlx::{FromRow, MySqlPool, PgPool};
use super::{PersistedSession, SessionPersistence}; use super::{PersistedSession, SessionPersistence, SessionPersistenceDiagnostics};
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::queries; use crate::queries;
@@ -148,11 +148,6 @@ pub struct DatabaseSessionStore {
impl DatabaseSessionStore { impl DatabaseSessionStore {
pub async fn connect(config: &AppConfig) -> Result<Self, String> { pub async fn connect(config: &AppConfig) -> Result<Self, String> {
// Schema ownership is centralized in db::pool::configure(). Calling it here
// keeps direct/test construction safe, while the normal application startup
// hits the OnceLock fast path. Do not run schema.sql again from this store:
// that used to duplicate DDL for SQLite/MySQL/PostgreSQL and produced repeated
// PostgreSQL "relation already exists, skipping" NOTICE messages.
crate::db::pool::configure(config).await?; crate::db::pool::configure(config).await?;
let pool = match config.database_backend.as_str() { let pool = match config.database_backend.as_str() {
@@ -425,6 +420,61 @@ impl SessionPersistence for DatabaseSessionStore {
} }
} }
async fn diagnostics(&self, now_unix: u64) -> Result<SessionPersistenceDiagnostics, String> {
let (backend, active_sessions, unique_users, unique_browsers, latest_accessed_unix) =
match &self.pool {
Pool::Sqlite(path) => {
let path = path.clone();
let row = sqlite_task(path, move |conn| {
conn.query_row(
"SELECT COUNT(*), COUNT(DISTINCT user_hash), COUNT(DISTINCT browser_id), MAX(last_accessed_unix) FROM sessions WHERE (last_accessed_unix + timeout_secs) >= ?1",
params![now_unix as i64],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, Option<i64>>(3)?)),
)
.map_err(|e| format!("failed to query SQLite session diagnostics: {e}"))
})
.await?;
("database/sqlite", row.0, row.1, row.2, row.3)
}
Pool::Mysql(pool) => {
let row: (i64, i64, i64, Option<i64>) = sqlx::query_as(
"SELECT COUNT(*), COUNT(DISTINCT user_hash), COUNT(DISTINCT browser_id), MAX(last_accessed_unix) FROM sessions WHERE (last_accessed_unix + timeout_secs) >= ?"
)
.bind(now_unix as i64)
.fetch_one(pool)
.await
.map_err(|e| format!("failed to query MySQL session diagnostics: {e}"))?;
("database/mysql", row.0, row.1, row.2, row.3)
}
Pool::Pgsql(pool) => {
let row: (i64, i64, i64, Option<i64>) = sqlx::query_as(
"SELECT COUNT(*), COUNT(DISTINCT user_hash), COUNT(DISTINCT browser_id), MAX(last_accessed_unix) FROM sessions WHERE (last_accessed_unix + timeout_secs) >= $1"
)
.bind(now_unix as i64)
.fetch_one(pool)
.await
.map_err(|e| format!("failed to query PostgreSQL session diagnostics: {e}"))?;
("database/pgsql", row.0, row.1, row.2, row.3)
}
};
Ok(SessionPersistenceDiagnostics {
backend: backend.to_string(),
status: "ok".to_string(),
active_sessions: active_sessions.max(0) as usize,
unique_users: unique_users.max(0) as usize,
unique_browsers: unique_browsers.max(0) as usize,
latest_accessed_unix: latest_accessed_unix.and_then(|value| u64::try_from(value).ok()),
truncated: false,
engine_version: None,
connected_clients: None,
blocked_clients: None,
used_memory_bytes: None,
server_uptime_secs: None,
role: None,
master_link_status: None,
})
}
async fn purge_expired(&self, now_unix: u64) -> Result<u64, String> { async fn purge_expired(&self, now_unix: u64) -> Result<u64, String> {
match &self.pool { match &self.pool {
Pool::Sqlite(path) => { Pool::Sqlite(path) => {
+19
View File
@@ -7,6 +7,24 @@ use serde::{Deserialize, Serialize};
use crate::config::AppConfig; use crate::config::AppConfig;
#[derive(Debug, Clone, Serialize)]
pub struct SessionPersistenceDiagnostics {
pub backend: String,
pub status: String,
pub active_sessions: usize,
pub unique_users: usize,
pub unique_browsers: usize,
pub latest_accessed_unix: Option<u64>,
pub truncated: bool,
pub engine_version: Option<String>,
pub connected_clients: Option<u64>,
pub blocked_clients: Option<u64>,
pub used_memory_bytes: Option<u64>,
pub server_uptime_secs: Option<u64>,
pub role: Option<String>,
pub master_link_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedSession { pub struct PersistedSession {
pub token: String, pub token: String,
@@ -45,6 +63,7 @@ pub trait SessionPersistence: Send + Sync {
async fn delete_account(&self, account_id: &str) -> Result<bool, String>; async fn delete_account(&self, account_id: &str) -> Result<bool, String>;
async fn delete_browser(&self, browser_id: &str) -> Result<(), String>; async fn delete_browser(&self, browser_id: &str) -> Result<(), String>;
async fn purge_expired(&self, now_unix: u64) -> Result<u64, String>; async fn purge_expired(&self, now_unix: u64) -> Result<u64, String>;
async fn diagnostics(&self, now_unix: u64) -> Result<SessionPersistenceDiagnostics, String>;
} }
pub async fn from_config( pub async fn from_config(
+110 -1
View File
@@ -1,7 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use redis::AsyncCommands; use redis::AsyncCommands;
use super::{PersistedSession, SessionPersistence}; use super::{PersistedSession, SessionPersistence, SessionPersistenceDiagnostics};
use crate::config::AppConfig; use crate::config::AppConfig;
#[derive(Clone)] #[derive(Clone)]
@@ -23,6 +23,14 @@ pub struct RedisSessionStore {
mode: RedisMode, mode: RedisMode,
} }
fn redis_info_value<'a>(info: &'a str, key: &str) -> Option<&'a str> {
info.lines().find_map(|line| {
let line = line.trim_end_matches('\r');
let (name, value) = line.split_once(':')?;
(name == key).then_some(value.trim())
})
}
impl RedisSessionStore { impl RedisSessionStore {
pub async fn connect(config: &AppConfig) -> Result<Self, String> { pub async fn connect(config: &AppConfig) -> Result<Self, String> {
let mode = if let Some(url) = config let mode = if let Some(url) = config
@@ -274,6 +282,107 @@ impl SessionPersistence for RedisSessionStore {
Ok(()) Ok(())
} }
async fn diagnostics(&self, now_unix: u64) -> Result<SessionPersistenceDiagnostics, String> {
use std::collections::HashSet;
const MAX_SCAN_SESSIONS: usize = 5000;
let mut con = self.connection().await?;
let pong: String = redis::cmd("PING")
.query_async(&mut con)
.await
.map_err(|e| format!("Redis PING failed: {e}"))?;
if !pong.eq_ignore_ascii_case("PONG") {
return Err(format!("unexpected Redis PING response: {pong}"));
}
let info: String = redis::cmd("INFO")
.query_async(&mut con)
.await
.map_err(|e| format!("Redis INFO failed: {e}"))?;
let mut cursor = 0u64;
let mut seen = 0usize;
let mut active = 0usize;
let mut users = HashSet::new();
let mut browsers = HashSet::new();
let mut latest = None;
let mut truncated = false;
loop {
let (next_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg("rustbox:session:*")
.arg("COUNT")
.arg(250)
.query_async(&mut con)
.await
.map_err(|e| format!("Redis SCAN failed: {e}"))?;
if !keys.is_empty() {
let remaining = MAX_SCAN_SESSIONS.saturating_sub(seen);
if remaining == 0 {
truncated = true;
break;
}
let keys = if keys.len() > remaining {
truncated = true;
&keys[..remaining]
} else {
&keys[..]
};
let mut command = redis::cmd("MGET");
for key in keys {
command.arg(key);
}
let values: Vec<Option<String>> = command
.query_async(&mut con)
.await
.map_err(|e| format!("Redis MGET diagnostics failed: {e}"))?;
seen += keys.len();
for raw in values.into_iter().flatten() {
let Ok(session) = serde_json::from_str::<PersistedSession>(&raw) else {
continue;
};
if session.expired_at(now_unix) {
continue;
}
active += 1;
users.insert(session.user_hash);
browsers.insert(session.browser_id);
latest = Some(latest.map_or(session.last_accessed_unix, |value: u64| {
value.max(session.last_accessed_unix)
}));
}
}
if truncated || next_cursor == 0 {
break;
}
cursor = next_cursor;
}
Ok(SessionPersistenceDiagnostics {
backend: "redis".to_string(),
status: "ok".to_string(),
active_sessions: active,
unique_users: users.len(),
unique_browsers: browsers.len(),
latest_accessed_unix: latest,
truncated,
engine_version: redis_info_value(&info, "redis_version").map(str::to_string),
connected_clients: redis_info_value(&info, "connected_clients")
.and_then(|value| value.parse().ok()),
blocked_clients: redis_info_value(&info, "blocked_clients")
.and_then(|value| value.parse().ok()),
used_memory_bytes: redis_info_value(&info, "used_memory")
.and_then(|value| value.parse().ok()),
server_uptime_secs: redis_info_value(&info, "uptime_in_seconds")
.and_then(|value| value.parse().ok()),
role: redis_info_value(&info, "role").map(str::to_string),
master_link_status: redis_info_value(&info, "master_link_status").map(str::to_string),
})
}
async fn purge_expired(&self, _now_unix: u64) -> Result<u64, String> { async fn purge_expired(&self, _now_unix: u64) -> Result<u64, String> {
Ok(0) Ok(0)
} }
+14 -3
View File
@@ -9,6 +9,7 @@ use crate::config::AppConfig;
const INDEX_HTML: &str = include_str!("web/index.html"); const INDEX_HTML: &str = include_str!("web/index.html");
const APP_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.css")); const APP_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.css"));
const APP_JS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.js")); const APP_JS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.js"));
const THEME_BOOTSTRAP_JS: &[u8] = include_bytes!("web/theme-bootstrap.js");
const ICONS: &[u8] = include_bytes!("web/icons.svg"); const ICONS: &[u8] = include_bytes!("web/icons.svg");
const I18N_EN: &[u8] = include_bytes!("web/i18n/en.json"); const I18N_EN: &[u8] = include_bytes!("web/i18n/en.json");
const I18N_PL: &[u8] = include_bytes!("web/i18n/pl.json"); const I18N_PL: &[u8] = include_bytes!("web/i18n/pl.json");
@@ -27,7 +28,6 @@ fn bytes_response(
[ [
(header::CONTENT_TYPE, content_type), (header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, cache), (header::CACHE_CONTROL, cache),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
], ],
body, body,
) )
@@ -48,6 +48,7 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
let versioned_css = format!("assets/app.{ASSET_ID}.css"); let versioned_css = format!("assets/app.{ASSET_ID}.css");
let versioned_js = format!("assets/app.{ASSET_ID}.js"); let versioned_js = format!("assets/app.{ASSET_ID}.js");
let versioned_theme_bootstrap = format!("assets/theme-bootstrap.{ASSET_ID}.js");
if relative == versioned_css.as_str() { if relative == versioned_css.as_str() {
return bytes_response( return bytes_response(
"text/css; charset=utf-8", "text/css; charset=utf-8",
@@ -62,10 +63,22 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
APP_JS, APP_JS,
); );
} }
if relative == versioned_theme_bootstrap.as_str() {
return bytes_response(
"text/javascript; charset=utf-8",
"public, max-age=31536000, immutable",
THEME_BOOTSTRAP_JS,
);
}
match relative { match relative {
"assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS), "assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS),
"assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS), "assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS),
"assets/theme-bootstrap.js" => bytes_response(
"text/javascript; charset=utf-8",
"no-store",
THEME_BOOTSTRAP_JS,
),
"assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS), "assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS),
"assets/i18n/en.json" => { "assets/i18n/en.json" => {
bytes_response("application/json; charset=utf-8", "no-store", I18N_EN) bytes_response("application/json; charset=utf-8", "no-store", I18N_EN)
@@ -78,7 +91,6 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
[ [
(header::CONTENT_TYPE, "image/svg+xml"), (header::CONTENT_TYPE, "image/svg+xml"),
(header::CACHE_CONTROL, "no-store"), (header::CACHE_CONTROL, "no-store"),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
], ],
FAVICON, FAVICON,
) )
@@ -121,7 +133,6 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
[ [
(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"), (header::CACHE_CONTROL, "no-store"),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
], ],
html, html,
) )
+11 -2
View File
@@ -158,15 +158,22 @@
.indexing-indicator svg { .indexing-indicator svg {
width: 16px; width: 16px;
height: 16px; height: 16px;
}
.indexing-indicator.active svg {
animation: rustbox-indexing-spin .9s linear infinite; animation: rustbox-indexing-spin .9s linear infinite;
} }
@keyframes rustbox-indexing-spin { @keyframes rustbox-indexing-spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.indexing-indicator svg { animation: none; } .indexing-indicator svg {
animation: none;
}
} }
.account-summary { .account-summary {
@@ -216,6 +223,8 @@
} }
.folder-pane { .folder-pane {
container-name: folder-pane;
container-type: inline-size;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 10px; padding: 10px;
+37
View File
@@ -49,10 +49,47 @@
} }
.folder-count.unread { .folder-count.unread {
color: var(--subtle);
}
.folder-count .folder-unread,
.folder-count.unread .folder-count-compact {
color: var(--accent); color: var(--accent);
font-weight: 700; font-weight: 700;
} }
.folder-count-wide {
display: inline-flex;
align-items: baseline;
gap: 0;
white-space: nowrap;
}
.folder-count-compact {
display: none;
}
.folder-count .folder-total,
.folder-count .folder-unread,
.folder-count-compact {
font-variant-numeric: tabular-nums;
}
.folder-count .folder-separator {
color: var(--subtle);
font-weight: 400;
}
@container folder-pane (max-width: 205px) {
.folder-count-wide {
display: none;
}
.folder-count-compact {
display: inline;
}
}
.message-pane { .message-pane {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
+5 -14
View File
@@ -12,16 +12,7 @@
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<meta name="description" content="RustBox webmail"> <meta name="description" content="RustBox webmail">
<title>RustBox</title> <title>RustBox</title>
<script> <script src="__RUSTBOX_ASSET_PREFIX__/assets/theme-bootstrap.__RUSTBOX_ASSET_ID__.js"></script>
(() => {
const root = document.documentElement;
const configured = root.dataset.defaultTheme || "system";
root.dataset.theme = configured === "system"
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
: configured;
root.dataset.interfaceSkin = root.dataset.defaultInterfaceSkin || "rustbox";
})();
</script>
<link rel="icon" href="__RUSTBOX_ASSET_PREFIX__/favicon.svg" type="image/svg+xml"> <link rel="icon" href="__RUSTBOX_ASSET_PREFIX__/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="__RUSTBOX_ASSET_PREFIX__/assets/app.__RUSTBOX_ASSET_ID__.css"> <link rel="stylesheet" href="__RUSTBOX_ASSET_PREFIX__/assets/app.__RUSTBOX_ASSET_ID__.css">
</head> </head>
@@ -77,13 +68,13 @@
<kbd>/</kbd> <kbd>/</kbd>
</form> </form>
<div class="appbar-actions"> <div class="appbar-actions">
<span id="indexing-indicator" class="indexing-indicator hidden" role="status" aria-live="polite"
title="Mail synchronization" aria-label="Mail synchronization"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-refresh-cw"></use>
</svg></span>
<button id="contacts-btn" class="icon-button" type="button" title="Contacts" aria-label="Contacts"><svg> <button id="contacts-btn" class="icon-button" type="button" title="Contacts" aria-label="Contacts"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use>
</svg></button> </svg></button>
<span id="indexing-indicator" class="indexing-indicator hidden" role="status" aria-live="polite"
title="Indexing messages" aria-label="Indexing messages"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-refresh-cw"></use>
</svg></span>
<button id="calendar-btn" class="icon-button" type="button" title="Calendar" aria-label="Calendar"><svg> <button id="calendar-btn" class="icon-button" type="button" title="Calendar" aria-label="Calendar"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-calendar-days"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-calendar-days"></use>
</svg></button> </svg></button>
+6 -1
View File
@@ -33,7 +33,7 @@
pgpKeys: [], pgpKeys: [],
pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false }, pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false },
pgpRecipientStatus: [], pgpRecipientStatus: [],
authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true, sieveEnabled: true, diagnosticsEnabled: true, aboutEnabled: true }, authPolicy: { rememberMeEnabled: true, pgpEnabled: false, sieveEnabled: false, diagnosticsEnabled: false, aboutEnabled: false },
contacts: [], contacts: [],
trustedSenders: new Set(), trustedSenders: new Set(),
contactGroups: [], contactGroups: [],
@@ -63,6 +63,11 @@
realtimeNotifiedMessages: new Map(), realtimeNotifiedMessages: new Map(),
realtimeWatchedFolder: null, realtimeWatchedFolder: null,
indexingActive: false, indexingActive: false,
indexingPending: 0,
imapSyncActive: false,
imapSyncFolder: null,
imapSyncPending: 0,
imapSyncInventoryPending: 0,
calendarReminderTimer: null, calendarReminderTimer: null,
composeDraftId: null, composeDraftId: null,
composeAttachments: [], composeAttachments: [],
+36 -9
View File
@@ -481,17 +481,40 @@ function applyFeatureAvailability() {
async function loadAuthPolicy() { async function loadAuthPolicy() {
try { try {
state.authPolicy = await request("/auth/policy", { method: "GET" }); const policy = await request("/auth/policy", { method: "GET" });
state.authPolicy = { ...state.authPolicy, ...policy };
} catch (_) { } catch (_) {
state.authPolicy = { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true, sieveEnabled: true, diagnosticsEnabled: true, aboutEnabled: true }; state.authPolicy = { ...state.authPolicy, rememberMeEnabled: true };
}
const enabled = state.authPolicy.rememberMeEnabled !== false;
["#login-remember-row", "#add-account-remember-row"].forEach((selector) => $(selector)?.classList.toggle("hidden", !enabled));
["#login-remember", "#add-account-remember"].forEach((selector) => {
const input = $(selector);
if (!input) return;
input.disabled = !enabled;
if (!enabled) input.checked = false;
});
["#login-remember-label", "#add-account-remember-label"].forEach((selector) => {
const label = $(selector);
if (label) label.textContent = "Keep me signed in";
});
}
async function loadSessionPolicy() {
try {
const policy = await request("/auth/session-policy", { method: "GET" });
state.authPolicy = { ...state.authPolicy, ...policy };
} catch (_) {
state.authPolicy = {
...state.authPolicy,
pgpEnabled: false,
sieveEnabled: false,
diagnosticsEnabled: false,
aboutEnabled: false,
};
} }
setOpenPgpAvailability(state.authPolicy.pgpEnabled !== false); setOpenPgpAvailability(state.authPolicy.pgpEnabled !== false);
applyFeatureAvailability(); applyFeatureAvailability();
const enabled = state.authPolicy.rememberMeEnabled !== false;
["#login-remember-row", "#add-account-remember-row"].forEach((selector) => $(selector)?.classList.toggle("hidden", !enabled));
["#login-remember", "#add-account-remember"].forEach((selector) => { const input = $(selector); if (input && !enabled) { input.checked = false; input.disabled = true; } });
const days = Number(state.authPolicy.rememberMeDays || 30);
["#login-remember-label", "#add-account-remember-label"].forEach((selector) => { const label = $(selector); if (label) label.textContent = `Keep me signed in (${days} days)`; });
} }
async function loadAccounts() { async function loadAccounts() {
@@ -607,9 +630,11 @@ async function reloadActiveAccount() {
state.selectedMessages.clear(); state.selectedMessages.clear();
state.selectionMode = false; state.selectionMode = false;
closeReader(); closeReader();
await loadSessionPolicy();
await loadPreferences(); await loadPreferences();
await loadMailPreferences(); await loadMailPreferences();
await loadOpenPgp(); if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp();
else setOpenPgpAvailability(false);
await loadIdentities(); await loadIdentities();
await loadFolders(true); await loadFolders(true);
await loadSpecialFolders(); await loadSpecialFolders();
@@ -684,9 +709,11 @@ async function login(event) {
state.accountId = data.account.id; state.accountId = data.account.id;
localStorage.setItem("rustbox.activeAccount", state.accountId); localStorage.setItem("rustbox.activeAccount", state.accountId);
await loadAccounts(); await loadAccounts();
await loadSessionPolicy();
await loadPreferences(); await loadPreferences();
await loadMailPreferences(); await loadMailPreferences();
await loadOpenPgp(); if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp();
else setOpenPgpAvailability(false);
await loadIdentities(); await loadIdentities();
await loadFolders(true); await loadFolders(true);
await loadSpecialFolders(); await loadSpecialFolders();
+43 -1
View File
@@ -86,6 +86,43 @@ function toggleFolderDrawer() {
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open")); setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
} }
function folderCounterParts(folder) {
const total = Math.max(0, Number(folder?.total_count) || 0);
const unread = Math.max(0, Number(folder?.unread_count) || 0);
const title = unread > 0 ? `${total} messages · ${unread} unread` : `${total} messages · no unread`;
const wide = unread > 0
? `<span class="folder-count-wide"><span class="folder-total">${total}</span><span class="folder-separator"> / </span><span class="folder-unread">${unread}</span></span>`
: `<span class="folder-count-wide"><span class="folder-total">${total}</span></span>`;
const compactValue = unread > 0 ? unread : total;
const compact = `<span class="folder-count-compact">${compactValue}</span>`;
return { total, unread, title, html: `${wide}${compact}` };
}
function updateFolderCounter(folder) {
if (!folder) return;
const parts = folderCounterParts(folder);
const button = $$(".folder-button").find((item) => item.dataset.folder === folder.name);
const counter = button?.querySelector(".folder-count");
if (!counter) return;
counter.classList.toggle("unread", parts.unread > 0);
counter.title = parts.title;
counter.innerHTML = parts.html;
}
function setFolderCounts(folderName, totalCount, unreadCount) {
const folder = state.folders.find((item) => item.name === folderName);
if (!folder) return;
if (Number.isFinite(Number(totalCount))) folder.total_count = Math.max(0, Number(totalCount));
if (Number.isFinite(Number(unreadCount))) folder.unread_count = Math.max(0, Number(unreadCount));
updateFolderCounter(folder);
if (folderName === state.folder && !state.searchContext) {
const total = Math.max(0, Number(folder.total_count) || 0);
const count = $("#message-count");
if (count) count.textContent = `${total} ${total === 1 ? "message" : "messages"}`;
}
updateUnreadAppBadge();
}
function renderFolders() { function renderFolders() {
const list = $("#folder-list"); const list = $("#folder-list");
list.replaceChildren(); list.replaceChildren();
@@ -109,7 +146,9 @@ function renderFolders() {
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`); const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
button.type = "button"; button.type = "button";
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`; button.dataset.folder = folder.name;
const counter = folderCounterParts(folder);
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${counter.unread > 0 ? "unread" : ""}" title="${escapeHtml(counter.title)}">${counter.html}</span>`;
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); }); button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
button.addEventListener("dragover", (event) => { button.addEventListener("dragover", (event) => {
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return; if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
@@ -160,6 +199,8 @@ async function loadFolders(restoreView = false) {
try { try {
const data = await request("/folders", { method: "GET" }); const data = await request("/folders", { method: "GET" });
state.folders = data.folders || []; state.folders = data.folders || [];
setImapSyncInventoryPending(data.pending_sync_count);
setIndexingIndicator(state.indexingActive, data.pending_index_count);
updateUnreadAppBadge(); updateUnreadAppBadge();
loadCollapsedFolders(); loadCollapsedFolders();
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX"); const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
@@ -172,6 +213,7 @@ async function loadFolders(restoreView = false) {
} }
renderFolders(); renderFolders();
$("#connection-label").textContent = "Connected"; $("#connection-label").textContent = "Connected";
setTimeout(() => { void refreshFolderListOnly({ forceServer: true }); }, 0);
if (restoreView) { if (restoreView) {
const restoredSearch = readActiveSearchContext(); const restoredSearch = readActiveSearchContext();
if (restoredSearch) { if (restoredSearch) {
+143 -34
View File
@@ -6,10 +6,12 @@ async function loadMessages(reset = false, options = {}) {
const previousScrollTop = reset && !restoreState && list ? list.scrollTop : null; const previousScrollTop = reset && !restoreState && list ? list.scrollTop : null;
if (reset) { if (reset) {
state.page = 0; state.page = 0;
state.messages = []; if (!options.background) {
state.selectedMessages.clear(); state.messages = [];
clearActiveSearchContext(); state.selectedMessages.clear();
list.innerHTML = `<div class="empty-list">Loading messages…</div>`; clearActiveSearchContext();
list.innerHTML = `<div class="empty-list">Loading messages…</div>`;
}
} }
$("#folder-title").textContent = state.folder; $("#folder-title").textContent = state.folder;
try { try {
@@ -22,6 +24,7 @@ async function loadMessages(reset = false, options = {}) {
let flaggedOnTop = state.currentFolderFlaggedOnTop; let flaggedOnTop = state.currentFolderFlaggedOnTop;
let folderTotalCount = null; let folderTotalCount = null;
let folderUnreadCount = null; let folderUnreadCount = null;
let syncing = false;
let lastLoadedPage = state.page; let lastLoadedPage = state.page;
for (const page of pages) { for (const page of pages) {
const forceRefresh = options.force ? "&refresh=true" : ""; const forceRefresh = options.force ? "&refresh=true" : "";
@@ -30,6 +33,7 @@ async function loadMessages(reset = false, options = {}) {
flaggedOnTop = Boolean(data.flagged_on_top); flaggedOnTop = Boolean(data.flagged_on_top);
folderTotalCount = Number(data.folder_total_count); folderTotalCount = Number(data.folder_total_count);
folderUnreadCount = Number(data.unread_count); folderUnreadCount = Number(data.unread_count);
syncing = syncing || Boolean(data.syncing);
loaded.push(...(data.messages || [])); loaded.push(...(data.messages || []));
lastLoadedPage = page; lastLoadedPage = page;
if (loaded.length >= totalCount) break; if (loaded.length >= totalCount) break;
@@ -38,12 +42,14 @@ async function loadMessages(reset = false, options = {}) {
state.total = totalCount; state.total = totalCount;
state.currentFolderFlaggedOnTop = flaggedOnTop; state.currentFolderFlaggedOnTop = flaggedOnTop;
state.messages = reset ? loaded : [...state.messages, ...loaded]; state.messages = reset ? loaded : [...state.messages, ...loaded];
const folderState = state.folders.find((item) => item.name === state.folder); if (state.folders.some((item) => item.name === state.folder)) {
if (folderState) { setFolderCounts(state.folder, folderTotalCount, folderUnreadCount);
if (Number.isFinite(folderTotalCount)) folderState.total_count = folderTotalCount; }
if (Number.isFinite(folderUnreadCount)) folderState.unread_count = folderUnreadCount; if (syncing) {
updateUnreadAppBadge(); const pending = Number.isFinite(folderTotalCount) ? Math.max(0, folderTotalCount - state.messages.length) : 0;
renderFolders(); setImapSyncIndicator(true, state.folder, pending);
} else if (state.imapSyncFolder === state.folder) {
setImapSyncIndicator(false, null, 0);
} }
$("#connection-label").textContent = "Connected"; $("#connection-label").textContent = "Connected";
renderMessages(); renderMessages();
@@ -59,28 +65,122 @@ async function loadMessages(reset = false, options = {}) {
} }
} }
function normalizedMessageFlags(value) {
const source = Array.isArray(value) ? value : String(value || "").split(/[,\s]+/).filter(Boolean);
return [...new Set(source.map((flag) => String(flag).trim()).filter(Boolean))].sort();
}
function findMessageRow(folder, uid) {
return $$(".message-row").find((row) => row.dataset.folder === String(folder) && Number(row.dataset.uid) === Number(uid)) || null;
}
function updateMessageRowState(folder, uid, flags) {
const row = findMessageRow(folder, uid);
if (!row) return;
const normalized = normalizedMessageFlags(flags);
const unread = !normalized.includes("\\Seen");
const flagged = normalized.includes("\\Flagged");
row.classList.toggle("unread", unread);
row.classList.toggle("flagged", flagged);
const readButton = row.querySelector(".message-quick-action:not(.message-flag):not(.message-fullscreen-action)");
if (readButton) {
readButton.title = unread ? "Mark read" : "Mark unread";
readButton.setAttribute("aria-label", readButton.title);
}
const flagButton = row.querySelector(".message-flag");
if (flagButton) {
flagButton.classList.toggle("active", flagged);
flagButton.title = flagged ? "Unflag" : "Flag";
flagButton.setAttribute("aria-label", flagButton.title);
}
}
function applyLocalMessageFlags(folder, uid, flags) {
const normalized = normalizedMessageFlags(flags);
const item = state.messages.find((message) => Number(message.uid) === Number(uid) && (message.folder || state.folder) === folder);
if (item) item.flags = normalized.join(" ");
if (state.currentDetail?.folder === folder && Number(state.currentDetail?.uid) === Number(uid)) {
state.currentDetail.flags = [...normalized];
$("#flag-btn")?.classList.toggle("active", normalized.includes("\\Flagged"));
}
updateMessageRowState(folder, uid, normalized);
}
function applyOptimisticMessageFlag(folder, uid, flag, add) {
const message = state.messages.find((item) => Number(item.uid) === Number(uid) && (item.folder || state.folder) === folder);
const detailMatches = state.currentDetail?.folder === folder && Number(state.currentDetail?.uid) === Number(uid);
const originalFlags = normalizedMessageFlags(message?.flags ?? (detailMatches ? state.currentDetail.flags : []));
const next = new Set(originalFlags);
add ? next.add(flag) : next.delete(flag);
const optimisticFlags = normalizedMessageFlags([...next]);
if (originalFlags.join("\u0000") === optimisticFlags.join("\u0000")) return () => {};
const folderState = state.folders.find((item) => item.name === folder);
const originalTotal = Number(folderState?.total_count);
const originalUnread = Number(folderState?.unread_count);
let optimisticUnread = originalUnread;
if (flag === "\\Seen" && Number.isFinite(originalUnread)) {
const wasSeen = originalFlags.includes("\\Seen");
const isSeen = optimisticFlags.includes("\\Seen");
if (wasSeen !== isSeen) optimisticUnread = Math.max(0, originalUnread + (isSeen ? -1 : 1));
}
applyLocalMessageFlags(folder, uid, optimisticFlags);
if (Number.isFinite(originalTotal) && Number.isFinite(optimisticUnread) && optimisticUnread !== originalUnread) {
setFolderCounts(folder, originalTotal, optimisticUnread);
}
return () => {
const current = state.messages.find((item) => Number(item.uid) === Number(uid) && (item.folder || state.folder) === folder);
const currentFlags = normalizedMessageFlags(current?.flags ?? (detailMatches ? state.currentDetail?.flags : []));
if (currentFlags.join("\u0000") === optimisticFlags.join("\u0000")) applyLocalMessageFlags(folder, uid, originalFlags);
const latestFolder = state.folders.find((item) => item.name === folder);
if (Number.isFinite(originalTotal) && Number.isFinite(originalUnread) && Number(latestFolder?.unread_count) === optimisticUnread) {
setFolderCounts(folder, originalTotal, originalUnread);
}
};
}
function updateActiveMessageRow() {
for (const row of $$(".message-row")) {
row.classList.toggle(
"active",
Boolean(state.selected) && row.dataset.folder === state.selected.folder && Number(row.dataset.uid) === Number(state.selected.uid),
);
}
}
async function quickUpdateMessageFlag(message, flag, add, successMessage) { async function quickUpdateMessageFlag(message, flag, add, successMessage) {
const folder = message.folder || state.folder; const folder = message.folder || state.folder;
const rollback = applyOptimisticMessageFlag(folder, message.uid, flag, add);
try { try {
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) }); const result = await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) });
const flags = new Set(Array.isArray(message.flags) ? message.flags : String(message.flags || "").split(/[,\s]+/).filter(Boolean)); const flags = Array.isArray(result.flags) ? result.flags : normalizedMessageFlags(message.flags);
add ? flags.add(flag) : flags.delete(flag); if (!Array.isArray(result.flags)) {
message.flags = [...flags].join(" "); const next = new Set(flags);
if (state.currentDetail?.uid === message.uid && state.currentDetail?.folder === folder) { add ? next.add(flag) : next.delete(flag);
const detailFlags = new Set(state.currentDetail.flags || []); applyLocalMessageFlags(folder, message.uid, [...next]);
add ? detailFlags.add(flag) : detailFlags.delete(flag); } else {
state.currentDetail.flags = [...detailFlags]; applyLocalMessageFlags(folder, message.uid, flags);
renderMessageDetail(state.currentDetail); }
if (Number.isFinite(Number(result.total_count)) && Number.isFinite(Number(result.unread_count))) {
setFolderCounts(folder, Number(result.total_count), Number(result.unread_count));
}
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) {
await loadMessages(true, { background: true });
} }
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) await loadMessages(true);
else renderMessages();
toast(successMessage, "success"); toast(successMessage, "success");
} catch (error) { toast(error.message, "error"); } } catch (error) {
rollback();
toast(error.message, "error");
}
} }
function renderMessages() { function renderMessages() {
$("#folder-title").textContent = state.folder; $("#folder-title").textContent = state.folder;
$("#message-count").textContent = `${state.total} ${state.total === 1 ? "message" : "messages"}`; const folderTotal = Number(state.folders.find((folder) => folder.name === state.folder)?.total_count);
const displayTotal = !state.searchContext && Number.isFinite(folderTotal) ? folderTotal : state.total;
$("#message-count").textContent = `${displayTotal} ${displayTotal === 1 ? "message" : "messages"}`;
const list = $("#message-list"); const list = $("#message-list");
const highlightTerms = activeSearchHighlightTerms(); const highlightTerms = activeSearchHighlightTerms();
list.classList.toggle("selection-mode", state.selectionMode); list.classList.toggle("selection-mode", state.selectionMode);
@@ -94,6 +194,8 @@ function renderMessages() {
const checked = state.selectedMessages.has(messageKey(message)); const checked = state.selectedMessages.has(messageKey(message));
const active = state.selected?.uid === message.uid && state.selected?.folder === folder; const active = state.selected?.uid === message.uid && state.selected?.folder === folder;
const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""}`); const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""}`);
row.dataset.folder = folder;
row.dataset.uid = String(message.uid);
row.tabIndex = 0; row.tabIndex = 0;
row.setAttribute("role", "button"); row.setAttribute("role", "button");
row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}`); row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}`);
@@ -122,13 +224,21 @@ function renderMessages() {
readButton.title = unread ? "Mark read" : "Mark unread"; readButton.title = unread ? "Mark read" : "Mark unread";
readButton.setAttribute("aria-label", readButton.title); readButton.setAttribute("aria-label", readButton.title);
readButton.innerHTML = svgIcon("mail"); readButton.innerHTML = svgIcon("mail");
readButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Seen", unread, unread ? "Marked read" : "Marked unread"); }); readButton.addEventListener("click", (event) => {
event.stopPropagation();
const currentUnread = !normalizedMessageFlags(message.flags).includes("\\Seen");
quickUpdateMessageFlag(message, "\\Seen", currentUnread, currentUnread ? "Marked read" : "Marked unread");
});
const flagButton = el("button", `message-quick-action message-flag ${flagged ? "active" : ""}`); const flagButton = el("button", `message-quick-action message-flag ${flagged ? "active" : ""}`);
flagButton.type = "button"; flagButton.type = "button";
flagButton.title = flagged ? "Unflag" : "Flag"; flagButton.title = flagged ? "Unflag" : "Flag";
flagButton.setAttribute("aria-label", flagButton.title); flagButton.setAttribute("aria-label", flagButton.title);
flagButton.innerHTML = svgIcon("star"); flagButton.innerHTML = svgIcon("star");
flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); }); flagButton.addEventListener("click", (event) => {
event.stopPropagation();
const currentFlagged = normalizedMessageFlags(message.flags).includes("\\Flagged");
quickUpdateMessageFlag(message, "\\Flagged", !currentFlagged, currentFlagged ? "Flag removed" : "Message flagged");
});
const fullscreenButton = el("button", "message-quick-action message-fullscreen-action"); const fullscreenButton = el("button", "message-quick-action message-fullscreen-action");
fullscreenButton.type = "button"; fullscreenButton.type = "button";
fullscreenButton.title = "Open full screen"; fullscreenButton.title = "Open full screen";
@@ -281,7 +391,7 @@ async function openMessage(folder, uid) {
clearTimeout(state.markReadTimer); clearTimeout(state.markReadTimer);
state.markReadTimer = null; state.markReadTimer = null;
state.selected = { folder, uid }; state.selected = { folder, uid };
renderMessages(); updateActiveMessageRow();
updateMessageNavigation(); updateMessageNavigation();
$("#reader-empty").classList.add("hidden"); $("#reader-empty").classList.add("hidden");
$("#reader-content").classList.remove("hidden"); $("#reader-content").classList.remove("hidden");
@@ -311,16 +421,15 @@ function scheduleMarkRead(detail) {
const delay = Math.max(0, Number(state.mailPrefs.mark_read_delay_ms || 0)); const delay = Math.max(0, Number(state.mailPrefs.mark_read_delay_ms || 0));
const run = async () => { const run = async () => {
if (!state.currentDetail || state.currentDetail.uid !== detail.uid || state.currentDetail.folder !== detail.folder) return; if (!state.currentDetail || state.currentDetail.uid !== detail.uid || state.currentDetail.folder !== detail.folder) return;
const rollback = applyOptimisticMessageFlag(detail.folder, detail.uid, "\\Seen", true);
try { try {
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: ["\\Seen"], add: true }) }); const result = await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: ["\\Seen"], add: true }) });
detail.flags = [...new Set([...(detail.flags || []), "\\Seen"])]; const flags = Array.isArray(result.flags) ? result.flags : [...new Set([...(detail.flags || []), "\\Seen"])];
const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder); applyLocalMessageFlags(detail.folder, detail.uid, flags);
if (item) { if (Number.isFinite(Number(result.total_count)) && Number.isFinite(Number(result.unread_count))) {
const flags = Array.isArray(item.flags) ? item.flags : String(item.flags || "").split(/[,\s]+/).filter(Boolean); setFolderCounts(detail.folder, Number(result.total_count), Number(result.unread_count));
item.flags = [...new Set([...flags, "\\Seen"])].join(" ");
} }
renderMessages(); } catch (_) { rollback(); }
} catch (_) { }
}; };
if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay); if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay);
} }
+88 -21
View File
@@ -2,17 +2,30 @@
async function updateFlag(flag, add) { async function updateFlag(flag, add) {
if (!state.currentDetail) return; if (!state.currentDetail) return;
const detail = state.currentDetail; const detail = state.currentDetail;
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { const rollback = applyOptimisticMessageFlag(detail.folder, detail.uid, flag, add);
method: "PATCH", try {
body: JSON.stringify({ flags: [flag], add }), const result = await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, {
}); method: "PATCH",
const flags = new Set(detail.flags || []); body: JSON.stringify({ flags: [flag], add }),
add ? flags.add(flag) : flags.delete(flag); });
detail.flags = [...flags]; const flags = Array.isArray(result.flags) ? result.flags : normalizedMessageFlags(detail.flags);
const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder); if (!Array.isArray(result.flags)) {
if (item) item.flags = detail.flags.join(" "); const next = new Set(flags);
renderMessages(); add ? next.add(flag) : next.delete(flag);
renderMessageDetail(detail); applyLocalMessageFlags(detail.folder, detail.uid, [...next]);
} else {
applyLocalMessageFlags(detail.folder, detail.uid, flags);
}
if (Number.isFinite(Number(result.total_count)) && Number.isFinite(Number(result.unread_count))) {
setFolderCounts(detail.folder, Number(result.total_count), Number(result.unread_count));
}
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && detail.folder === state.folder) {
await loadMessages(true, { background: true });
}
} catch (error) {
rollback();
throw error;
}
} }
async function toggleUnread() { async function toggleUnread() {
@@ -231,7 +244,23 @@ async function performBulk(action, targetFolder = null) {
if (action === "read" || action === "unread" || action === "flag" || action === "unflag") { if (action === "read" || action === "unread" || action === "flag" || action === "unflag") {
const flag = action === "read" || action === "unread" ? "\\Seen" : "\\Flagged"; const flag = action === "read" || action === "unread" ? "\\Seen" : "\\Flagged";
const add = action === "read" || action === "flag"; const add = action === "read" || action === "flag";
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) }); const rollback = applyOptimisticMessageFlag(folder, message.uid, flag, add);
let result;
try {
result = await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) });
} catch (error) {
rollback();
throw error;
}
const nextFlags = Array.isArray(result?.flags) ? result.flags : (() => {
const flags = new Set(normalizedMessageFlags(message.flags));
add ? flags.add(flag) : flags.delete(flag);
return [...flags];
})();
applyLocalMessageFlags(folder, message.uid, nextFlags);
if (Number.isFinite(Number(result?.total_count)) && Number.isFinite(Number(result?.unread_count))) {
setFolderCounts(folder, Number(result.total_count), Number(result.unread_count));
}
} else if (action === "move" || action === "archive" || action === "junk" || action === "delete") { } else if (action === "move" || action === "archive" || action === "junk" || action === "delete") {
let destination = targetFolder; let destination = targetFolder;
if (action === "archive") destination = detectSpecialFolder("archive"); if (action === "archive") destination = detectSpecialFolder("archive");
@@ -252,8 +281,17 @@ async function performBulk(action, targetFolder = null) {
} }
state.selectedMessages.clear(); state.selectedMessages.clear();
state.searchAllResults = null; state.searchAllResults = null;
await refreshFolderListOnly(); const flagsOnly = ["read", "unread", "flag", "unflag"].includes(action);
await refreshCurrentMessageView(); const orderingChanged = ["flag", "unflag"].includes(action) && state.currentFolderFlaggedOnTop && !state.searchContext;
if (!flagsOnly) {
await refreshFolderListOnly({ forceServer: true });
await refreshCurrentMessageView();
} else if (state.searchContext || orderingChanged) {
await refreshCurrentMessageView();
} else {
renderBulkToolbar();
updateUnreadAppBadge();
}
const bulkMessages = { junk: bulkJunkToInbox ? tr("marked_not_spam", "Marked as not spam") : tr("marked_spam", "Marked as spam"), archive: "Archived", read: "Marked read", unread: "Marked unread", flag: "Flagged", unflag: "Unflagged", delete: "Deleted", move: "Moved", copy: "Copied" }; const bulkMessages = { junk: bulkJunkToInbox ? tr("marked_not_spam", "Marked as not spam") : tr("marked_spam", "Marked as spam"), archive: "Archived", read: "Marked read", unread: "Marked unread", flag: "Flagged", unflag: "Unflagged", delete: "Deleted", move: "Moved", copy: "Copied" };
const undoAction = movedOperations > 0 && undoMoves.length === movedOperations ? bulkMoveUndoAction(undoMoves) : null; const undoAction = movedOperations > 0 && undoMoves.length === movedOperations ? bulkMoveUndoAction(undoMoves) : null;
toast(`${bulkMessages[action] || "Updated"}: ${completed} message${completed === 1 ? "" : "s"}`, "success", undoAction ? 10000 : null, undoAction); toast(`${bulkMessages[action] || "Updated"}: ${completed} message${completed === 1 ? "" : "s"}`, "success", undoAction ? 10000 : null, undoAction);
@@ -264,12 +302,28 @@ async function performBulk(action, targetFolder = null) {
} }
} }
async function refreshFolderListOnly() { async function refreshFolderListOnly(options = {}) {
try { try {
const data = await request("/folders", { method: "GET" }); const suffix = options.forceServer ? "?refresh=true" : "";
state.folders = data.folders || []; const data = await request(`/folders${suffix}`, { method: "GET" });
const nextFolders = data.folders || [];
setImapSyncInventoryPending(data.pending_sync_count);
setIndexingIndicator(state.indexingActive, data.pending_index_count);
const previousNames = state.folders.map((folder) => folder.name).join("\u0000");
const nextNames = nextFolders.map((folder) => folder.name).join("\u0000");
if (previousNames !== nextNames || state.folders.length !== nextFolders.length) {
state.folders = nextFolders;
renderFolders();
} else {
const byName = new Map(nextFolders.map((folder) => [folder.name, folder]));
for (const folder of state.folders) {
const fresh = byName.get(folder.name);
if (!fresh) continue;
Object.assign(folder, fresh);
updateFolderCounter(folder);
}
}
updateUnreadAppBadge(); updateUnreadAppBadge();
renderFolders();
} catch (_) { } } catch (_) { }
} }
@@ -621,9 +675,22 @@ function showFolderProperties(folder) {
async function markFolderAllRead(name) { async function markFolderAllRead(name) {
try { try {
await request(`/folders/${encodeURIComponent(name)}/mark-all-read`, { method: "POST", body: "{}" }); const result = await request(`/folders/${encodeURIComponent(name)}/mark-all-read`, { method: "POST", body: "{}" });
if (state.folder === name) await loadMessages(true); if (state.folder === name) {
await refreshFolderListOnly(); const throughUid = Number(result?.through_uid);
for (const message of state.messages) {
if (Number.isFinite(throughUid) && Number(message.uid) > throughUid) continue;
const flags = normalizedMessageFlags(message.flags);
if (!flags.includes("\\Seen")) flags.push("\\Seen");
applyLocalMessageFlags(name, message.uid, flags);
}
}
const folder = state.folders.find((item) => item.name === name);
setFolderCounts(
name,
Number.isFinite(Number(result?.total_count)) ? Number(result.total_count) : Number(folder?.total_count || 0),
Number.isFinite(Number(result?.unread_count)) ? Number(result.unread_count) : 0,
);
toast("All messages marked read"); toast("All messages marked read");
} catch (error) { toast(error.message, "error"); } } catch (error) { toast(error.message, "error"); }
} }
+116 -19
View File
@@ -160,17 +160,48 @@ function playNotificationSound() {
} catch (_) { } } catch (_) { }
} }
function setIndexingIndicator(active, pending = 0) { function renderSyncActivityIndicator() {
state.indexingActive = Boolean(active);
const indicator = $("#indexing-indicator"); const indicator = $("#indexing-indicator");
if (!indicator) return; if (!indicator) return;
indicator.classList.toggle("hidden", !state.indexingActive); const busy = Boolean(state.indexingActive || state.imapSyncActive);
const count = Math.max(0, Number(pending) || 0); indicator.classList.toggle("hidden", !busy);
const label = count > 0 ? `Indexing messages (${count} pending)` : "Indexing messages"; indicator.classList.toggle("active", busy);
const parts = [];
if (state.imapSyncActive || state.imapSyncPending > 0) {
const folder = state.imapSyncFolder ? ` · ${state.imapSyncFolder}` : "";
const pending = state.imapSyncPending > 0 ? ` · ${state.imapSyncPending} headers pending in task` : "";
parts.push(`IMAP synchronization${folder}${pending}`);
}
if (state.imapSyncInventoryPending > 0) {
parts.push(`IMAP cache incomplete · ${state.imapSyncInventoryPending} message headers not cached yet`);
}
if (state.indexingActive || state.indexingPending > 0) {
const pending = state.indexingPending > 0 ? ` · ${state.indexingPending} messages pending` : "";
parts.push(`Search indexing${pending}`);
}
const label = parts.join(" | ") || "Mail synchronization";
indicator.title = label; indicator.title = label;
indicator.setAttribute("aria-label", label); indicator.setAttribute("aria-label", label);
} }
function setIndexingIndicator(active, pending = 0) {
state.indexingActive = Boolean(active);
state.indexingPending = Math.max(0, Number(pending) || 0);
renderSyncActivityIndicator();
}
function setImapSyncIndicator(active, folder = null, pending = 0) {
state.imapSyncActive = Boolean(active);
state.imapSyncFolder = folder || null;
state.imapSyncPending = Math.max(0, Number(pending) || 0);
renderSyncActivityIndicator();
}
function setImapSyncInventoryPending(pending = 0) {
state.imapSyncInventoryPending = Math.max(0, Number(pending) || 0);
renderSyncActivityIndicator();
}
function syncRealtimeWatchedFolder(force = false) { function syncRealtimeWatchedFolder(force = false) {
const socket = state.ws; const socket = state.ws;
if (!socket || socket.readyState !== WebSocket.OPEN || !state.accountId || !state.folder) return; if (!socket || socket.readyState !== WebSocket.OPEN || !state.accountId || !state.folder) return;
@@ -296,23 +327,62 @@ function realtimeEventKey(payload) {
function isDuplicateRealtimeEvent(payload) { function isDuplicateRealtimeEvent(payload) {
const now = Date.now(); const now = Date.now();
for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key); for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key);
// Server websocket envelopes carry a unique eventId. When present it is the
// only safe deduplication key: state can legitimately cycle A -> B -> A in
// under a second (for example unread 1 -> 0 -> 1). Fingerprint-based
// deduplication would incorrectly drop the second A and leave stale UI.
if (payload.eventId) {
const idKey = `id:${payload.eventId}`;
const duplicate = state.realtimeSeenEvents.has(idKey);
state.realtimeSeenEvents.set(idKey, now);
return duplicate;
}
// Fallback for legacy/nonstandard envelopes without eventId.
const event = payload.event || {}; const event = payload.event || {};
const fingerprint = `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`; const fingerprint = `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`;
const idKey = payload.eventId ? `id:${payload.eventId}` : null;
const fingerprintKey = `event:${fingerprint}`; const fingerprintKey = `event:${fingerprint}`;
const duplicate = Boolean((idKey && state.realtimeSeenEvents.has(idKey)) || (state.realtimeSeenEvents.get(fingerprintKey) && now - state.realtimeSeenEvents.get(fingerprintKey) < 1500)); const seenAt = state.realtimeSeenEvents.get(fingerprintKey);
if (idKey) state.realtimeSeenEvents.set(idKey, now); const duplicate = Boolean(seenAt && now - seenAt < 1500);
state.realtimeSeenEvents.set(fingerprintKey, now); state.realtimeSeenEvents.set(fingerprintKey, now);
return duplicate; return duplicate;
} }
function applyRealtimeFolderStatus(data) {
if (!data?.folder) return;
setFolderCounts(data.folder, Number(data.total_count), Number(data.unread_count));
}
function applyRealtimeFlagChanges(data) {
if (!data?.folder) return false;
const changes = Array.isArray(data.changes) ? data.changes : [];
for (const change of changes) {
if (!Number.isFinite(Number(change?.uid))) continue;
applyLocalMessageFlags(data.folder, Number(change.uid), change.flags || []);
}
return changes.length > 0;
}
function applyRealtimeFolderAllRead(data) {
const folder = data?.folder;
if (!folder || folder !== state.folder) return;
const throughUid = Number(data?.through_uid);
for (const message of state.messages) {
if (Number.isFinite(throughUid) && Number(message.uid) > throughUid) continue;
const flags = normalizedMessageFlags(message.flags);
if (!flags.includes("\\Seen")) flags.push("\\Seen");
applyLocalMessageFlags(folder, message.uid, flags);
}
}
async function resyncRealtimeState() { async function resyncRealtimeState() {
if (!state.accountId) return; if (!state.accountId) return;
if (state.realtimeSyncPromise) return state.realtimeSyncPromise; if (state.realtimeSyncPromise) return state.realtimeSyncPromise;
state.realtimeSyncPromise = (async () => { state.realtimeSyncPromise = (async () => {
await refreshFolderListOnly(); await refreshFolderListOnly({ forceServer: true });
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }); if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true); else await loadMessages(true, { background: true });
})().catch(() => { }).finally(() => { state.realtimeSyncPromise = null; }); })().catch(() => { }).finally(() => { state.realtimeSyncPromise = null; });
return state.realtimeSyncPromise; return state.realtimeSyncPromise;
} }
@@ -323,13 +393,16 @@ function scheduleRealtimeRefresh(event, accountId) {
clearTimeout(state.realtimeRefreshTimer); clearTimeout(state.realtimeRefreshTimer);
state.realtimeRefreshTimer = setTimeout(async () => { state.realtimeRefreshTimer = setTimeout(async () => {
const events = state.realtimeRefreshEvents.splice(0); const events = state.realtimeRefreshEvents.splice(0);
const refreshCurrent = events.some((item) => item.type === "FolderUpdated" || (["NewMessages", "FlagsChanged"].includes(item.type) && item.data?.folder === state.folder)); const folderTreeChanged = events.some((item) => item.type === "FolderUpdated");
const tasks = [refreshFolderListOnly()]; const newInCurrent = events.some((item) => item.type === "NewMessages" && item.data?.folder === state.folder);
if (refreshCurrent) { const structuralFlagsInCurrent = events.some((item) => item.type === "FlagsChanged" && item.data?.folder === state.folder && !(item.data?.changes || []).length);
const tasks = [];
if (folderTreeChanged) tasks.push(refreshFolderListOnly());
if (folderTreeChanged || newInCurrent || structuralFlagsInCurrent) {
if (state.searchContext) tasks.push(runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop })); if (state.searchContext) tasks.push(runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }));
else tasks.push(loadMessages(true)); else tasks.push(loadMessages(true, { background: true }));
} }
await Promise.all(tasks); if (tasks.length) await Promise.all(tasks);
}, 80); }, 80);
} }
@@ -402,10 +475,29 @@ function connectRealtime() {
notifyNewMail(data, payload.accountId); notifyNewMail(data, payload.accountId);
} }
} }
if (event.type === "IndexingStatus" && payload.accountId === state.accountId) { if (payload.accountId === state.accountId) {
setIndexingIndicator(Boolean(event.data?.active), event.data?.pending); if (event.type === "FolderStatus") applyRealtimeFolderStatus(event.data || {});
if (event.type === "FlagsChanged") {
const patched = applyRealtimeFlagChanges(event.data || {});
if (!patched) scheduleRealtimeRefresh(event, payload.accountId);
}
if (event.type === "FolderAllRead") applyRealtimeFolderAllRead(event.data || {});
if (event.type === "IndexingStatus") setIndexingIndicator(Boolean(event.data?.active), event.data?.pending);
if (event.type === "SyncStatus") {
const wasActive = state.imapSyncActive;
const syncFolder = event.data?.folder || null;
const active = Boolean(event.data?.active);
setImapSyncIndicator(active, syncFolder, event.data?.pending);
const folderState = state.folders.find((folder) => folder.name === syncFolder);
const serverTotal = Number(folderState?.total_count);
const syncCompletedForCurrentFolder = wasActive && !active && syncFolder === state.folder;
if (syncCompletedForCurrentFolder && !state.searchContext && Number.isFinite(serverTotal) && state.total < serverTotal) {
void loadMessages(true, { background: true });
}
}
if (event.type === "SyncInventory") setImapSyncInventoryPending(event.data?.pending);
} }
if (["NewMessages", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId); if (["NewMessages", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
} catch (error) { console.warn("Invalid realtime event", error); } } catch (error) { console.warn("Invalid realtime event", error); }
}; };
socket.onclose = () => { socket.onclose = () => {
@@ -413,6 +505,7 @@ function connectRealtime() {
state.ws = null; state.ws = null;
state.realtimeWatchedFolder = null; state.realtimeWatchedFolder = null;
setIndexingIndicator(false); setIndexingIndicator(false);
setImapSyncIndicator(false, null, 0);
clearInterval(state.wsWatchdogTimer); clearInterval(state.wsWatchdogTimer);
state.wsWatchdogTimer = null; state.wsWatchdogTimer = null;
scheduleRealtimeReconnect(); scheduleRealtimeReconnect();
@@ -435,6 +528,7 @@ function disconnectRealtime() {
state.wsLastHeartbeatAt = 0; state.wsLastHeartbeatAt = 0;
state.realtimeWatchedFolder = null; state.realtimeWatchedFolder = null;
setIndexingIndicator(false); setIndexingIndicator(false);
setImapSyncIndicator(false, null, 0);
const socket = state.ws; const socket = state.ws;
state.ws = null; state.ws = null;
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect"); if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect");
@@ -516,7 +610,8 @@ async function loadDiagnostics() {
try { try {
const info = await request("/diagnostics/search", { method: "GET" }); const info = await request("/diagnostics/search", { method: "GET" });
searchStatus.textContent = "Ready"; searchStatus.textContent = "Ready";
$("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`; const bodyCacheState = info.deepIndexEnabled ? "body cache enabled" : "body cache disabled";
$("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies · ${bodyCacheState}${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`;
} catch (error) { } catch (error) {
searchStatus.textContent = "Unavailable"; searchStatus.textContent = "Unavailable";
$("#diagnostic-search-summary").textContent = error.message; $("#diagnostic-search-summary").textContent = error.message;
@@ -542,6 +637,7 @@ async function rebuildSearchIndex() {
const button = $("#diagnostic-search-rebuild"); const button = $("#diagnostic-search-rebuild");
if (!await appConfirm("Rebuild the search index from the local message cache?", { title: "Rebuild search index", confirmLabel: "Rebuild" })) return; if (!await appConfirm("Rebuild the search index from the local message cache?", { title: "Rebuild search index", confirmLabel: "Rebuild" })) return;
button.disabled = true; button.disabled = true;
setIndexingIndicator(true, 0);
try { try {
const result = await request("/diagnostics/search/rebuild", { method: "POST", body: "{}" }); const result = await request("/diagnostics/search/rebuild", { method: "POST", body: "{}" });
toast(`Search index rebuilt: ${result.indexedDocuments} messages`); toast(`Search index rebuilt: ${result.indexedDocuments} messages`);
@@ -549,6 +645,7 @@ async function rebuildSearchIndex() {
} catch (error) { } catch (error) {
toast(error.message, "error"); toast(error.message, "error");
} finally { } finally {
setIndexingIndicator(false, 0);
button.disabled = false; button.disabled = false;
} }
} }
+3 -3
View File
@@ -237,7 +237,7 @@ function bindEvents() {
const button = $("#refresh-btn"); const button = $("#refresh-btn");
setBusy(button, true); setBusy(button, true);
try { try {
const folderRefresh = refreshFolderListOnly(); const folderRefresh = refreshFolderListOnly({ forceServer: true });
if (state.searchContext) { if (state.searchContext) {
await Promise.all([folderRefresh, runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop })]); await Promise.all([folderRefresh, runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop })]);
} else { } else {
@@ -266,7 +266,7 @@ function bindEvents() {
disconnectRealtime(); disconnectRealtime();
connectRealtime(); connectRealtime();
if (state.accounts.length) { if (state.accounts.length) {
await refreshFolderListOnly(); await refreshFolderListOnly({ forceServer: true });
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }); if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true); else await loadMessages(true);
} }
@@ -559,7 +559,7 @@ async function boot() {
await loadAuthPolicy(); await loadAuthPolicy();
const authenticated = await loadAccounts(); const authenticated = await loadAccounts();
if (authenticated) { if (authenticated) {
await loadPreferences(); await loadMailPreferences(); if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp(); else setOpenPgpAvailability(false); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); await loadSessionPolicy(); await loadPreferences(); await loadMailPreferences(); if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp(); else setOpenPgpAvailability(false); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime();
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const folder = params.get("mail_folder"); const folder = params.get("mail_folder");
const uid = Number(params.get("mail_uid")); const uid = Number(params.get("mail_uid"));
+8
View File
@@ -0,0 +1,8 @@
(() => {
const root = document.documentElement;
const configured = root.dataset.defaultTheme || "system";
root.dataset.theme = configured === "system"
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
: configured;
root.dataset.interfaceSkin = root.dataset.defaultInterfaceSkin || "rustbox";
})();