From 2c98b8f92556dd4632762c7961e0229c649b9d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 31 Oct 2026 19:40:19 +0100 Subject: [PATCH] v0.2.3 --- CHANGELOG.md | 10 ++ Cargo.lock | 6 +- Cargo.toml | 2 +- NOTICE.md | 2 +- README.md | 15 ++- RELEASE-MANIFEST.txt | 17 ++- RELEASE-VALIDATION.txt | 12 +- config.dev.toml | 8 +- config.example.toml | 9 +- docker/.env.example | 9 ++ docker/README.md | 45 +++++++- docker/compose-ha-garage.yml | 157 +++++++++++++++++++++++++++ docker/configs/ha-garage.toml | 32 ++++++ docker/envoy/envoy.yaml | 63 +++++++++++ docker/garage/garage.toml | 12 ++ docker/garage/init/Dockerfile | 6 + docker/garage/init/init.sh | 22 ++++ docs/IMPLEMENTATION_STATUS.md | 6 +- docs/RELEASING.md | 2 +- docs/STORAGE_SEARCH_OPERATIONS.md | 4 +- migrations/README.md | 4 +- packaging/rustbox.toml | 20 +++- scripts/validate-release-static.py | 58 +++++++--- src/config.rs | 74 +++++++++++-- src/db/mod.rs | 1 + src/db/settings.rs | 97 +++++++++++++++++ src/routes/auth.rs | 6 + src/routes/mod.rs | 68 ++++++++++-- src/routes/settings.rs | 27 +++++ src/routes/tests.rs | 112 +++++++++++++++++++ src/web/css/50-settings.css | 9 ++ src/web/index.html | 12 +- src/web/js/00-bootstrap.js | 2 +- src/web/js/20-preferences-auth.js | 24 +++- src/web/js/60-mail-actions-search.js | 13 ++- src/web/js/80-settings-realtime.js | 34 ++++-- src/web/js/90-sieve.js | 1 + src/web/js/95-bindings-boot.js | 1 + 38 files changed, 915 insertions(+), 87 deletions(-) create mode 100644 docker/compose-ha-garage.yml create mode 100644 docker/configs/ha-garage.toml create mode 100644 docker/envoy/envoy.yaml create mode 100644 docker/garage/garage.toml create mode 100644 docker/garage/init/Dockerfile create mode 100755 docker/garage/init/init.sh create mode 100644 src/db/settings.rs create mode 100644 src/routes/settings.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d8e814..01c69ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # RustBox changelog +### 0.2.3 +- Fixed API fallback routing so administratively disabled or otherwise unknown `/api/*` endpoints return HTTP 404 instead of being caught by the SPA fallback and returning the UI with HTTP 200. +- Added administrator feature switches for ManageSieve, diagnostics and About; disabled Sieve/diagnostics routes are not registered, and the matching settings UI is removed while keyboard shortcuts remain available. +- Added a protected “Reset all settings” action that restores display, mailbox, notification, folder, special-folder, calendar and OpenPGP preferences without deleting mail, identities, keys, templates or trusted-sender data. +- Added opt-in browser security response headers (CSP, frame, referrer, permissions and cross-origin opener policies); the feature is disabled by default for compatibility. +- Added a Docker HA example with two RustBox application nodes behind Envoy round-robin load balancing, Valkey-backed sessions, PostgreSQL, OpenSearch and Garage S3 object storage. Envoy adds `X-RustBox-Backend` to responses. +- Added current pinned Garage, Valkey and Envoy images to the HA example and an idempotent Garage bucket/key initialization helper. +- No schema migration is required; runtime schema remains V047 and V048 remains the next migration number. +- Bumped the application/source release to 0.2.3. + ### 0.2.2 - Fixed the SQLite isolation regression test to initialize `AppConfig` with a struct literal plus `..Default::default()`, satisfying `cargo clippy --all-targets -- -D warnings` without suppressing `field_reassign_with_default`. - Fixed Rust test isolation for SQLite: test-only `db::pool::configure()` still initializes the requested database schema but no longer pins the first temporary SQLite path in the process-wide `OnceLock`, preventing later tests from reusing a deleted `TempDir` and collapsing into HTTP 500/database-open failures. diff --git a/Cargo.lock b/Cargo.lock index 46d780e..efedc8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3508,7 +3508,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.2" +version = "0.2.3" dependencies = [ "aes-gcm", "async-imap", @@ -4912,9 +4912,9 @@ dependencies = [ [[package]] name = "unicode-properties" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833" [[package]] name = "unicode-segmentation" diff --git a/Cargo.toml b/Cargo.toml index e552e35..56d64a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.2.2" +version = "0.2.3" edition = "2024" rust-version = "1.88" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index 296d4ec..7c8d9e1 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # RustBox attribution / notice -RustBox 0.2.2 is derived from the Oxi webmail project: +RustBox 0.2.3 is derived from the Oxi webmail project: - Upstream: `c0h1b4/oxi` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index eb1c531..15a026e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.2.2 +# RustBox 0.2.3 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`. -## Storage and search in 0.2.2 +## Storage and search in 0.2.3 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. @@ -71,7 +71,14 @@ max_attachment_size_mb = 10 # Hard limit for the complete rendered RFC822/MIME message submitted to the MTA. max_message_size_mb = 50 -# Optional; defaults to imap_host. +# Optional features exposed by the server/UI. +sieve_enabled = true +diagnostics_enabled = true +about_enabled = true +# Adds CSP/frame/referrer/permissions/COOP response headers when enabled. +security_headers_enabled = false + +# Optional; defaults to imap_host. Ignored when sieve_enabled=false. # sieve_host = "mail.example.com" sieve_port = 4190 sieve_tls_mode = "starttls" @@ -217,7 +224,7 @@ Initialize Git after extracting the release archive: ```bash git init git add . -git commit -m "RustBox 0.2.2 release" +git commit -m "RustBox 0.2.3 release" ./scripts/setup-upstream.sh ``` diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 1532621..6ce80d8 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,4 +1,12 @@ -RustBox 0.2.2 release manifest +RustBox 0.2.3 release manifest + +0.2.3 feature/configuration changes: +- optional Sieve, diagnostics and About feature switches +- API namespace has an explicit 404 fallback so disabled/unknown API routes cannot be served by the SPA fallback +- opt-in security response headers (disabled by default) +- authenticated reset-all-settings endpoint/UI +- two-node Docker topology with Envoy, Valkey, PostgreSQL, OpenSearch and Garage S3 +- no schema migration; V047 remains current and V048 remains next Architecture: - database_backend = sqlite | mysql | pgsql selects authoritative SQL only @@ -53,18 +61,19 @@ SQLite test isolation (0.2.2): Docker examples: - docker/compose-single-sqlite.yml +- docker/compose-ha-garage.yml (2x RustBox + Envoy + Valkey + PostgreSQL + OpenSearch + Garage S3) - docker/compose-postgres-s3-opensearch.yml (PostgreSQL + SILO/S3 + OpenSearch) - docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch) Operations/docs: -- README.md documents 0.2.2 storage/search configuration +- README.md documents 0.2.3 storage/search configuration - docker/README.md documents Compose variants - 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 Build/release metadata: - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts -- Cargo.toml version = 0.2.2 +- Cargo.toml version = 0.2.3 - rust-toolchain.toml pins Rust 1.88.0 -- Cargo.lock is intentionally not shipped because this environment could not regenerate it after dependency changes +- Cargo.lock is intentionally not shipped; the release gate regenerates it before locked checks - CI and scripts/build-release.sh generate Cargo.lock, then run locked tests/Clippy/release build diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 81e9da9..15c9a6c 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,7 +1,15 @@ -RustBox 0.2.2 - final source validation +RustBox 0.2.3 - final source validation + +0.2.3 scope: +- feature gates for Sieve/diagnostics/About and UI policy exposure +- API fallback isolation: unknown/disabled `/api/*` routes return 404 and never fall through to the SPA UI +- reset-all-settings preserving account/mail data +- optional browser security headers +- Docker two-node application topology with Envoy/Valkey/OpenSearch/PostgreSQL/Garage +- version metadata updated to 0.2.3 Completed in this environment: -- offline release validator: scripts/validate-release-static.py (164/164 checks passed) +- offline release validator: scripts/validate-release-static.py (196/196 checks passed) - all maintained TOML examples parse successfully - Docker Compose topology/config references are validated by the offline release validator - JSON files parse successfully diff --git a/config.dev.toml b/config.dev.toml index 4fde794..335e6f5 100644 --- a/config.dev.toml +++ b/config.dev.toml @@ -39,7 +39,13 @@ tls_enabled = true max_attachment_size_mb = 10 max_message_size_mb = 50 -# ManageSieve. Leave sieve_host unset to use imap_host. +# Optional administrator-facing features. +sieve_enabled = true +diagnostics_enabled = true +about_enabled = true +security_headers_enabled = false + +# ManageSieve. Leave sieve_host unset to use imap_host. Ignored when disabled. sieve_port = 4190 sieve_tls_mode = "starttls" # starttls | implicit | none sieve_allow_insecure_auth = false diff --git a/config.example.toml b/config.example.toml index 4a6ee42..558961e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,4 +1,4 @@ -# RustBox 0.2.2 +# RustBox 0.2.3 host = "127.0.0.1" port = 3001 environment = "production" @@ -55,6 +55,13 @@ max_message_size_mb = 50 # Optional reverse-proxy subpath, e.g. "/mail". # base_path = "/mail" +# Optional product features. Disabled features are not registered in the API/UI. +sieve_enabled = true +diagnostics_enabled = true +about_enabled = true +# Adds browser-facing security headers (CSP, frame/referrer/permissions policies). +security_headers_enabled = false + # ManageSieve. If sieve_host is omitted, imap_host from the authenticated # session is used. Plaintext authentication stays blocked unless explicitly # enabled. diff --git a/docker/.env.example b/docker/.env.example index 44d84d9..15d9014 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -28,6 +28,15 @@ POSTGRES_PASSWORD=rustbox-dev-password S3_ACCESS_KEY=rustbox S3_SECRET_KEY=rustbox-s3-dev-password +# HA/Garage variant. Development credentials only; replace outside local testing. +GARAGE_ACCESS_KEY_ID=GKb69252a3b0643e8bd08d4cd4 +GARAGE_SECRET_ACCESS_KEY=9115be9c9e4994306a4176543b0db461f4eb04c5c8a388676d1b57392d0f4e93 +GARAGE_CAPACITY=10G +SIEVE_ENABLED=false +DIAGNOSTICS_ENABLED=true +ABOUT_ENABLED=true +SECURITY_HEADERS_ENABLED=false + # MySQL variant. MYSQL_PASSWORD=rustbox-dev-password MYSQL_ROOT_PASSWORD=rustbox-root-password diff --git a/docker/README.md b/docker/README.md index 04ca3e7..fa9d117 100644 --- a/docker/README.md +++ b/docker/README.md @@ -2,10 +2,26 @@ Run all commands from the `docker/` directory. -The Compose files use the project name `rustbox` and the examples below additionally pass `-p rustbox`. Using `-p` is intentional: a globally exported `COMPOSE_PROJECT_NAME` has higher priority than the `name: rustbox` field in a Compose file and could otherwise attach RustBox to an unrelated Compose project/network. +Most Compose files use the project name `rustbox`; the HA/Garage example uses `rustbox-ha`. The examples pass `-p` explicitly so an exported `COMPOSE_PROJECT_NAME` cannot attach RustBox to an unrelated Compose project/network. ## Quick start +Two RustBox nodes + Envoy + Valkey + PostgreSQL + OpenSearch + Garage S3: + +```bash +PORT=3009 \ +IMAP_HOST=mail.linuxiarz.pl \ +SMTP_HOST=mail.linuxiarz.pl \ +docker compose -p rustbox-ha -f compose-ha-garage.yml up --build +``` + +Verify load balancing and the selected backend header: + +```bash +curl -sSI http://localhost:3009/ | grep -i '^x-rustbox-backend:' +curl -sSI http://localhost:3009/ | grep -i '^x-rustbox-backend:' +``` + PostgreSQL + S3/SILO + OpenSearch: ```bash @@ -58,7 +74,7 @@ cp .env.example .env | Variable | Default | Used by | Description | |---|---|---|---| | `HOST` | `0.0.0.0` | all | Address RustBox listens on inside the container. | -| `PORT` | `3001` | all | RustBox HTTP port. The same value is used for the container listener and host port mapping. Example: `PORT=3009` publishes `3009:3009`. | +| `PORT` | `3001` | all | Published HTTP port. Single-node examples also use it as the RustBox listener; HA keeps RustBox on `3001` internally and maps `${PORT}` to Envoy `8080`. | | `IMAP_HOST` | `mail.example.com` | all | IMAP server hostname used for mailbox access/authentication. Set this for a real mail system. | | `IMAP_PORT` | `993` | all | IMAP server port. | | `SMTP_HOST` | `mail.example.com` | all | SMTP server hostname used for sending mail. | @@ -73,6 +89,13 @@ cp .env.example .env | `MYSQL_ROOT_PASSWORD` | `rustbox-root-password` | MySQL/NFS | MySQL root password used by the container and health check. | | `NFS_SERVER` | none | MySQL/NFS | NFS server address. Required for `compose-mysql-nfs-opensearch.yml`. | | `NFS_EXPORT` | `/rustbox` | MySQL/NFS | Export path on the NFS server. | +| `GARAGE_ACCESS_KEY_ID` | development key | HA/Garage | Garage S3 access key imported by the one-shot initializer. Replace outside local testing. | +| `GARAGE_SECRET_ACCESS_KEY` | development secret | HA/Garage | Garage S3 secret key. Replace outside local testing. | +| `GARAGE_CAPACITY` | `10G` | HA/Garage | Capacity assigned to the single Garage node for layout balancing. | +| `SIEVE_ENABLED` | `false` | HA/Garage | Enables ManageSieve routes/UI on both RustBox nodes. | +| `DIAGNOSTICS_ENABLED` | `true` | HA/Garage | Enables diagnostics routes/UI. | +| `ABOUT_ENABLED` | `true` | HA/Garage | Enables the About panel; keyboard shortcuts stay available independently. | +| `SECURITY_HEADERS_ENABLED` | `false` | HA/Garage | Enables RustBox browser security headers. | | `COMPOSE_PROJECT_NAME` | `rustbox` in `.env.example` | Compose | Compose project name. A value exported in the shell overrides the file's `name:`. The documented commands use `-p rustbox`, which has the highest priority. | `RUSTBOX_CONFIG` and `DATABASE_URL` are set by the Compose files themselves and normally should not be supplied from the shell for these bundled topologies. @@ -100,6 +123,14 @@ docker compose -p rustbox -f compose-postgres-s3-opensearch.yml \ ## Topologies +### `compose-ha-garage.yml` + +Runs two RustBox application containers behind Envoy round-robin load balancing. PostgreSQL stores account/application state, Valkey stores shared sessions, OpenSearch provides the shared search index, and Garage provides S3-compatible blob storage. Only Envoy publishes a host port; the application nodes stay internal. + +Envoy adds `X-RustBox-Backend` with the selected upstream address to every proxied response. WebSocket upgrades are enabled and Envoy health-checks `/api/health` on both nodes. The example uses a fixed Docker subnet because Garage advertises its RPC address; change `172.28.80.0/24` in both the Compose file and `configs/ha-garage.toml` if it conflicts with your network. + +Garage is a single storage node in this local example. The application tier is redundant, but the backing services are not an HA production cluster by themselves. + ### `compose-single-sqlite.yml` Single RustBox node with SQLite, filesystem blob storage and local Tantivy search. Persistent application data is stored in the `rustbox-data` Docker volume. @@ -120,7 +151,7 @@ Replace all example passwords, S3 credentials and `SESSION_ENCRYPTION_KEY` befor The OpenSearch examples disable its security plugin to keep the local Compose topology self-contained. Production should use TLS/authentication and HTTPS URLs in `search.urls`. -The two cluster-ready examples intentionally start one RustBox application container so they can run directly on a single Docker host. For multiple RustBox application nodes, put them behind an external load balancer and do not publish the same host port from every node; SQL, blob storage and OpenSearch must remain shared between the nodes. +The PostgreSQL/SILO and MySQL/NFS examples intentionally start one RustBox application container. `compose-ha-garage.yml` demonstrates two application nodes sharing SQL, sessions, blob storage and search behind Envoy. For production, make the backing PostgreSQL/Valkey/OpenSearch/Garage services appropriately redundant and terminate TLS before or at the load balancer. ## Sessions and HTTP/HTTPS @@ -132,6 +163,12 @@ For a production deployment behind HTTPS set: ENVIRONMENT=production docker compose -p rustbox -f compose-postgres-s3-opensearch.yml up -d --build ``` +For the HA example, expose Envoy only behind HTTPS/TLS termination and use: + +```bash +ENVIRONMENT=production docker compose -p rustbox-ha -f compose-ha-garage.yml up -d --build +``` + In `production`, RustBox intentionally marks authentication cookies as `Secure`. Opening the application directly over plain HTTP in that mode causes the browser not to send the session cookie and protected requests return `Invalid or expired session`. After changing from production/HTTP to development/HTTP, log in again. If the browser still has stale RustBox state, clear cookies/site data for the RustBox origin once and reload. @@ -164,4 +201,4 @@ docker compose -p rustbox -f compose-postgres-s3-opensearch.yml exec opensearch -d '{"index.blocks.read_only_allow_delete":null}' ``` -RustBox 0.2.2 backs off its OpenSearch outbox worker after these 429/flood-stage responses, so a full disk no longer produces one warning per queued event. +RustBox 0.2.3 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. diff --git a/docker/compose-ha-garage.yml b/docker/compose-ha-garage.yml new file mode 100644 index 0000000..15892bb --- /dev/null +++ b/docker/compose-ha-garage.yml @@ -0,0 +1,157 @@ +name: rustbox-ha + +x-rustbox-common: &rustbox-common + build: + context: .. + dockerfile: docker/Dockerfile + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + garage-init: + condition: service_completed_successfully + opensearch: + condition: service_healthy + environment: &rustbox-environment + RUSTBOX_CONFIG: /etc/rustbox/rustbox.toml + ENVIRONMENT: ${ENVIRONMENT:-development} + HOST: 0.0.0.0 + PORT: 3001 + IMAP_HOST: ${IMAP_HOST:-mail.example.com} + IMAP_PORT: ${IMAP_PORT:-993} + SMTP_HOST: ${SMTP_HOST:-mail.example.com} + SMTP_PORT: ${SMTP_PORT:-587} + TLS_ENABLED: ${TLS_ENABLED:-true} + RUST_LOG: ${RUST_LOG:-info} + DATABASE_URL: postgres://rustbox:${POSTGRES_PASSWORD:-rustbox-dev-password}@postgres:5432/rustbox + SESSION_ENCRYPTION_KEY: ${SESSION_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=} + AWS_ACCESS_KEY_ID: ${GARAGE_ACCESS_KEY_ID:-GKb69252a3b0643e8bd08d4cd4} + AWS_SECRET_ACCESS_KEY: ${GARAGE_SECRET_ACCESS_KEY:-9115be9c9e4994306a4176543b0db461f4eb04c5c8a388676d1b57392d0f4e93} + SIEVE_ENABLED: ${SIEVE_ENABLED:-false} + DIAGNOSTICS_ENABLED: ${DIAGNOSTICS_ENABLED:-true} + ABOUT_ENABLED: ${ABOUT_ENABLED:-true} + SECURITY_HEADERS_ENABLED: ${SECURITY_HEADERS_ENABLED:-false} + volumes: + - ./configs/ha-garage.toml:/etc/rustbox/rustbox.toml:ro + expose: + - "3001" + networks: + - rustbox-ha + restart: unless-stopped + +services: + postgres: + image: postgres:17-bookworm + environment: + POSTGRES_DB: rustbox + POSTGRES_USER: rustbox + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-rustbox-dev-password} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U rustbox -d rustbox"] + interval: 5s + timeout: 5s + retries: 20 + volumes: + - postgres-ha-data:/var/lib/postgresql/data + networks: + - rustbox-ha + + valkey: + image: valkey/valkey:9.1.3-alpine + command: ["valkey-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + volumes: + - valkey-data:/data + networks: + - rustbox-ha + + opensearch: + image: opensearchproject/opensearch:3.9.0 + environment: + discovery.type: single-node + DISABLE_INSTALL_DEMO_CONFIG: "true" + DISABLE_SECURITY_PLUGIN: "true" + OPENSEARCH_JAVA_OPTS: -Xms512m -Xmx512m + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:9200/_cluster/health >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 30 + volumes: + - opensearch-ha-data:/usr/share/opensearch/data + networks: + - rustbox-ha + + garage: + image: dxflrs/garage:v2.5.0 + environment: + RUST_LOG: ${GARAGE_RUST_LOG:-info} + command: ["/garage", "-c", "/etc/garage.toml", "server"] + healthcheck: + test: ["CMD", "/garage", "-c", "/etc/garage.toml", "status"] + interval: 5s + timeout: 5s + retries: 30 + volumes: + - ./garage/garage.toml:/etc/garage.toml:ro + - garage-meta:/var/lib/garage/meta + - garage-data:/var/lib/garage/data + networks: + rustbox-ha: + ipv4_address: 172.28.80.10 + + garage-init: + build: + context: .. + dockerfile: docker/garage/init/Dockerfile + depends_on: + garage: + condition: service_healthy + environment: + GARAGE_ACCESS_KEY_ID: ${GARAGE_ACCESS_KEY_ID:-GKb69252a3b0643e8bd08d4cd4} + GARAGE_SECRET_ACCESS_KEY: ${GARAGE_SECRET_ACCESS_KEY:-9115be9c9e4994306a4176543b0db461f4eb04c5c8a388676d1b57392d0f4e93} + GARAGE_BUCKET: rustbox + GARAGE_CAPACITY: ${GARAGE_CAPACITY:-10G} + volumes: + - ./garage/garage.toml:/etc/garage.toml:ro + - garage-meta:/var/lib/garage/meta + - garage-data:/var/lib/garage/data + networks: + - rustbox-ha + + rustbox-1: + <<: *rustbox-common + + rustbox-2: + <<: *rustbox-common + + envoy: + image: envoyproxy/envoy:v1.40.2 + depends_on: + - rustbox-1 + - rustbox-2 + ports: + - "${PORT:-3001}:8080" + volumes: + - ./envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro + networks: + - rustbox-ha + restart: unless-stopped + +volumes: + postgres-ha-data: + valkey-data: + opensearch-ha-data: + garage-meta: + garage-data: + +networks: + rustbox-ha: + ipam: + config: + - subnet: 172.28.80.0/24 diff --git a/docker/configs/ha-garage.toml b/docker/configs/ha-garage.toml new file mode 100644 index 0000000..1d104de --- /dev/null +++ b/docker/configs/ha-garage.toml @@ -0,0 +1,32 @@ +host = "0.0.0.0" +port = 3001 +environment = "development" +database_backend = "pgsql" +session_backend = "redis" +redis_url = "redis://valkey:6379/" +cluster_mode = true +pgp_enabled = false +sieve_enabled = false +diagnostics_enabled = true +about_enabled = true +security_headers_enabled = false +trusted_proxies = "172.28.80.0/24" + +[storage] +backend = "s3" +bucket = "rustbox" +region = "garage" +endpoint = "http://garage:3900" +path_style = true +allow_http = true +prefix = "mail" + +[search] +backend = "opensearch" +urls = ["http://opensearch:9200"] +index_prefix = "rustbox" +allow_insecure_http = true +required = true +worker_interval_secs = 2 +worker_batch_size = 100 +worker_max_attempts = 8 diff --git a/docker/envoy/envoy.yaml b/docker/envoy/envoy.yaml new file mode 100644 index 0000000..5b89596 --- /dev/null +++ b/docker/envoy/envoy.yaml @@ -0,0 +1,63 @@ +static_resources: + listeners: + - name: rustbox_http + address: + socket_address: + address: 0.0.0.0 + port_value: 8080 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: rustbox_ingress + use_remote_address: true + upgrade_configs: + - upgrade_type: websocket + route_config: + name: rustbox_routes + virtual_hosts: + - name: rustbox + domains: ["*"] + response_headers_to_add: + - header: + key: X-RustBox-Backend + value: "%UPSTREAM_REMOTE_ADDRESS%" + append_action: OVERWRITE_IF_EXISTS_OR_ADD + routes: + - match: + prefix: "/" + route: + cluster: rustbox + timeout: 0s + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + - name: rustbox + type: STRICT_DNS + connect_timeout: 2s + lb_policy: ROUND_ROBIN + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 2 + healthy_threshold: 1 + http_health_check: + path: /api/health + load_assignment: + cluster_name: rustbox + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: rustbox-1 + port_value: 3001 + - endpoint: + address: + socket_address: + address: rustbox-2 + port_value: 3001 diff --git a/docker/garage/garage.toml b/docker/garage/garage.toml new file mode 100644 index 0000000..81c389e --- /dev/null +++ b/docker/garage/garage.toml @@ -0,0 +1,12 @@ +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "sqlite" +replication_factor = 1 +rpc_bind_addr = "0.0.0.0:3901" +rpc_public_addr = "172.28.80.10:3901" +rpc_secret = "8f2ec1b94f2ff5fc39391d8e1666929f8d6adc4f2270d5c950866a0a65e5f4b3" + +[s3_api] +s3_region = "garage" +api_bind_addr = "0.0.0.0:3900" +root_domain = ".s3.garage.localhost" diff --git a/docker/garage/init/Dockerfile b/docker/garage/init/Dockerfile new file mode 100644 index 0000000..a3f9597 --- /dev/null +++ b/docker/garage/init/Dockerfile @@ -0,0 +1,6 @@ +FROM dxflrs/garage:v2.5.0 AS garage +FROM alpine:3.22 +COPY --from=garage /garage /usr/local/bin/garage +COPY docker/garage/init/init.sh /usr/local/bin/init-garage +RUN chmod +x /usr/local/bin/init-garage +ENTRYPOINT ["/usr/local/bin/init-garage"] diff --git a/docker/garage/init/init.sh b/docker/garage/init/init.sh new file mode 100755 index 0000000..6649c27 --- /dev/null +++ b/docker/garage/init/init.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +CONFIG=/etc/garage.toml +STATUS=/tmp/garage-status + +until garage -c "$CONFIG" status >"$STATUS" 2>/dev/null; do + sleep 1 +done + +node_id="$(awk '/NO ROLE ASSIGNED/ { print $1; exit }' "$STATUS")" +if [ -n "$node_id" ]; then + garage -c "$CONFIG" layout assign -z local -c "${GARAGE_CAPACITY:-10G}" "$node_id" + garage -c "$CONFIG" layout apply --version 1 +fi + +garage -c "$CONFIG" key import -n rustbox --yes \ + "$GARAGE_ACCESS_KEY_ID" "$GARAGE_SECRET_ACCESS_KEY" >/dev/null 2>&1 || true + +garage -c "$CONFIG" bucket create "$GARAGE_BUCKET" >/dev/null 2>&1 || true +garage -c "$CONFIG" bucket allow --read --write \ + "$GARAGE_BUCKET" --key "$GARAGE_ACCESS_KEY_ID" diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 3397f0a..3a7f2ec 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,8 +1,8 @@ -# RustBox 0.2.2 - implementation status +# RustBox 0.2.3 - implementation status -This file tracks the `0.2.2` 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.3` 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.2 storage/search architecture +## 0.2.3 storage/search architecture - 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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 159e541..4cc5fe9 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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. -## Updating an existing checkout to 0.2.2 +## Updating an existing checkout to 0.2.3 Do not overlay the release with `cp *` or another command that skips dotfiles. That can retain stale `.github`, `.env.example`, or `Cargo.lock` files. diff --git a/docs/STORAGE_SEARCH_OPERATIONS.md b/docs/STORAGE_SEARCH_OPERATIONS.md index ebbad44..0436f5b 100644 --- a/docs/STORAGE_SEARCH_OPERATIONS.md +++ b/docs/STORAGE_SEARCH_OPERATIONS.md @@ -1,6 +1,6 @@ -# RustBox 0.2.2 storage/search operations +# RustBox 0.2.3 storage/search operations -RustBox 0.2.2 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed. +RustBox 0.2.3 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed. ## Deployment matrix diff --git a/migrations/README.md b/migrations/README.md index 99d99e5..d087d4f 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -1,6 +1,6 @@ # Database schemas and migrations -RustBox 0.2.2 keeps SQL, blob storage and search as independent concerns: +RustBox 0.2.3 keeps SQL, blob storage and search as independent concerns: ```text database_backend = sqlite | mysql | pgsql @@ -34,7 +34,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. -The next migration number after RustBox 0.2.2 is **V048**. +The next migration number after RustBox 0.2.3 is **V048**. Redis and Redis Sentinel do not use SQL migrations. diff --git a/packaging/rustbox.toml b/packaging/rustbox.toml index 30c0087..b1f7608 100644 --- a/packaging/rustbox.toml +++ b/packaging/rustbox.toml @@ -1,4 +1,4 @@ -# RustBox 0.2.2 - packaged production configuration (DEB/RPM) +# RustBox 0.2.3 - packaged production configuration (DEB/RPM) # # This file contains all supported RustBox configuration options. # Commented options are optional and do not change the packaged defaults. @@ -35,6 +35,13 @@ default_theme = "system" # and Roundcube variants. default_interface_skin = "apple-mail" +# Optional product features. Disabled features are removed from the API/UI. +sieve_enabled = true +diagnostics_enabled = true +about_enabled = true +# Browser-facing CSP/frame/referrer/permissions headers. Off by default for compatibility. +security_headers_enabled = false + # ----------------------------------------------------------------------------- # Database @@ -87,10 +94,10 @@ remember_me_days = 30 # # session_encryption_key = "BASE64_32_BYTE_KEY" -# Direct Redis: +# Direct Redis/Valkey: # redis_url = "redis://redis.example.com:6379/" -# Redis Sentinel: +# Redis Sentinel (or a compatible Redis deployment): # redis_sentinel_nodes = [ # "sentinel-a:26379", # "sentinel-b:26379", @@ -248,7 +255,7 @@ retry_timeout_secs = 60 # bucket = "rustbox" # region = "eu-central-1" # -# Custom endpoint for MinIO, SILO, Ceph RGW, OpenStack Swift s3api, etc. +# Custom endpoint for Garage, MinIO, SILO, Ceph RGW, OpenStack Swift s3api, etc. # AWS S3 normally does not require endpoint. # # endpoint = "https://s3.example.net" @@ -265,6 +272,7 @@ retry_timeout_secs = 60 # # access_key_id = "..." # secret_access_key = "..." +# session_token = "..." # # Server-side encryption: # @@ -405,13 +413,13 @@ worker_max_attempts = 12 # ----------------------------------------------------------------------------- -# Cluster sessions using Redis +# Cluster sessions using Redis/Valkey # ----------------------------------------------------------------------------- # # session_backend = "redis" # session_encryption_key = "BASE64_32_BYTE_KEY" # -# redis_url = "redis://redis.internal:6379/" +# redis_url = "redis://valkey.internal:6379/" # # Or Sentinel: # diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index 6de6e9b..f61f175 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Static/offline release checks for RustBox 0.2.2. +"""Static/offline release checks for RustBox 0.2.3. These checks intentionally avoid pretending to replace `cargo test`: they validate configuration, SQL migrations, Docker examples, query-catalog parity and release @@ -49,7 +49,7 @@ def extract_section(text: str, begin: str, end: str) -> str: # Release/toolchain metadata. cargo = tomllib.loads(read("Cargo.toml")) -require(cargo["package"]["version"] == "0.2.2", "Cargo package version is 0.2.2") +require(cargo["package"]["version"] == "0.2.3", "Cargo package version is 0.2.3") require(cargo["package"]["rust-version"] == "1.88", "Cargo rust-version is 1.88") toolchain = tomllib.loads(read("rust-toolchain.toml")) require(toolchain["toolchain"]["channel"] == "1.88.0", "rust-toolchain pins Rust 1.88.0") @@ -65,15 +65,26 @@ 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("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(read("README.md").startswith("# RustBox 0.2.2"), "README identifies RustBox 0.2.2") -require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.2"), "release manifest identifies RustBox 0.2.2") -require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.2"), "release validation identifies RustBox 0.2.2") -require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.2"), "implementation status identifies RustBox 0.2.2") +require(read("README.md").startswith("# RustBox 0.2.3"), "README identifies RustBox 0.2.3") +require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.3"), "release manifest identifies RustBox 0.2.3") +require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.3"), "release validation identifies RustBox 0.2.3") +require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.3"), "implementation status identifies RustBox 0.2.3") 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(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") require("setOpenPgpAvailability" in read("src/web/js/72-openpgp-identities.js"), "UI hides administratively disabled OpenPGP") +config_rs = read("src/config.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 security_headers_enabled: bool" in config_rs and "security_headers_enabled: false" in config_rs, "security headers are configurable and disabled by default") +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("if config.security_headers_enabled" in routes_mod and "add_security_headers" in routes_mod, "security-header middleware is opt-in") +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('$("#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("state.authPolicy.sieveEnabled === false ? []" in read("src/web/js/60-mail-actions-search.js"), "Sieve context-menu actions disappear when Sieve is disabled") require("is_opensearch_backpressure_error" in read("src/search/engine.rs"), "OpenSearch worker classifies backpressure failures") require("worker pass failed; backing off" in read("src/search/engine.rs"), "OpenSearch worker backs off after failures") require("ENVIRONMENT: ${ENVIRONMENT:-development}" in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL Compose defaults direct HTTP to development cookie mode") @@ -160,6 +171,7 @@ toml_files = [ "docker/configs/single-sqlite.toml", "docker/configs/postgres-s3-opensearch.toml", "docker/configs/mysql-nfs-opensearch.toml", + "docker/configs/ha-garage.toml", ] for rel in toml_files: try: @@ -170,7 +182,7 @@ for rel in toml_files: # Parse Docker Compose YAML and validate referenced config paths. compose_files = sorted((ROOT / "docker").glob("compose-*.yml")) -require(len(compose_files) >= 3, "three Docker Compose deployment variants exist") +require(len(compose_files) >= 4, "four Docker Compose deployment variants exist") if yaml is None: errors.append("PyYAML is required for Docker Compose static validation") else: @@ -178,11 +190,14 @@ else: try: data = yaml.safe_load(path.read_text(encoding="utf-8")) require(isinstance(data, dict) and "services" in data, f"Compose parses with services: {path.name}") - rustbox = data.get("services", {}).get("rustbox", {}) - for volume in rustbox.get("volumes", []) or []: - if isinstance(volume, str) and volume.startswith("./configs/"): - config_rel = volume.split(":", 1)[0].removeprefix("./") - require((ROOT / "docker" / config_rel).is_file(), f"Compose config mount exists: {path.name} -> {config_rel}") + services = data.get("services", {}) + for service_name, service in services.items(): + if service_name != "rustbox" and not service_name.startswith("rustbox-"): + continue + for volume in service.get("volumes", []) or []: + if isinstance(volume, str) and volume.startswith("./configs/"): + config_rel = volume.split(":", 1)[0].removeprefix("./") + require((ROOT / "docker" / config_rel).is_file(), f"Compose config mount exists: {path.name}/{service_name} -> {config_rel}") except Exception as exc: errors.append(f"Compose parse failed: {path.name}: {exc}") @@ -195,6 +210,23 @@ 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["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") +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["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["search"]["backend"] == "opensearch", "HA/Garage config uses OpenSearch") +ha_compose = read("docker/compose-ha-garage.yml") +require("rustbox-1:" in ha_compose and "rustbox-2:" in ha_compose, "HA Compose defines two RustBox application nodes") +for service in ["postgres:", "valkey:", "opensearch:", "garage:", "envoy:"]: + require(service in ha_compose, f"HA Compose includes {service.removesuffix(':')}") +require("valkey/valkey:9.1.3-alpine" in ha_compose, "HA Compose pins Valkey 9.1.3") +require("opensearchproject/opensearch:3.9.0" in ha_compose, "HA Compose pins OpenSearch 3.9.0") +require("dxflrs/garage:v2.5.0" in ha_compose, "HA Compose pins Garage 2.5.0") +require("envoyproxy/envoy:v1.40.2" in ha_compose, "HA Compose pins Envoy 1.40.2") +require("X-RustBox-Backend" in read("docker/envoy/envoy.yaml"), "Envoy exposes the selected RustBox backend response header") +require("address: rustbox-1" in read("docker/envoy/envoy.yaml") and "address: rustbox-2" in read("docker/envoy/envoy.yaml"), "Envoy balances both RustBox nodes") +require('sieve_enabled = false' in read("docker/configs/ha-garage.toml"), "HA example disables optional Sieve by default") +require('security_headers_enabled = false' in read("docker/configs/ha-garage.toml"), "HA example keeps optional security headers disabled by default") require("type: nfs" in read("docker/compose-mysql-nfs-opensearch.yml"), "NFS Compose uses a real NFS volume driver") require("docker.io/pgsty/silo:RELEASE.2026-10-26T00-00-00Z" in read("docker/compose-postgres-s3-opensearch.yml"), "S3 Compose pins the maintained SILO integration image") require("docker.io/pgsty/mc:RELEASE.2026-10-26T00-00-00Z" in read("docker/compose-postgres-s3-opensearch.yml"), "S3 Compose pins the matching SILO client image") @@ -455,7 +487,7 @@ require(not missing_includes, f"Rust include_str/include_bytes targets exist ({' # Shell syntax checks are useful offline and deterministic. try: - shell_files = [ROOT / "dev.sh", *sorted((ROOT / "scripts").glob("*.sh"))] + shell_files = [ROOT / "dev.sh", *sorted((ROOT / "scripts").glob("*.sh")), ROOT / "docker/garage/init/init.sh"] subprocess.run(["bash", "-n", *map(str, shell_files)], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) ok("shell scripts pass bash -n") except Exception as exc: diff --git a/src/config.rs b/src/config.rs index 27fef9e..cfa804f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -218,6 +218,18 @@ pub struct AppConfig { #[serde(default)] pub base_path: Option, + #[serde(default = "default_feature_enabled")] + pub sieve_enabled: bool, + + #[serde(default = "default_feature_enabled")] + pub diagnostics_enabled: bool, + + #[serde(default = "default_feature_enabled")] + pub about_enabled: bool, + + #[serde(default)] + pub security_headers_enabled: bool, + #[serde(default)] pub sieve_host: Option, @@ -406,6 +418,10 @@ fn default_interface_skin() -> String { "rustbox".to_string() } +fn default_feature_enabled() -> bool { + true +} + fn default_sieve_port() -> u16 { 4190 } @@ -470,6 +486,10 @@ impl Default for AppConfig { default_theme: default_theme(), default_interface_skin: default_interface_skin(), base_path: None, + sieve_enabled: true, + diagnostics_enabled: true, + about_enabled: true, + security_headers_enabled: false, sieve_host: None, sieve_port: default_sieve_port(), sieve_tls_mode: default_sieve_tls_mode(), @@ -654,8 +674,12 @@ impl AppConfig { { return Err("IMAP_HOST / imap_host must be configured".to_string()); } - if self.port == 0 || self.imap_port == 0 || self.smtp_port == 0 || self.sieve_port == 0 { - return Err("network ports must be greater than zero".to_string()); + if self.port == 0 + || self.imap_port == 0 + || self.smtp_port == 0 + || (self.sieve_enabled && self.sieve_port == 0) + { + return Err("enabled network ports must be greater than zero".to_string()); } if let Some(log_dir) = self.log_dir.as_deref() { let log_dir = log_dir.trim(); @@ -925,16 +949,18 @@ impl AppConfig { ) { return Err("DEFAULT_INTERFACE_SKIN must be one of: rustbox, roundcube-classic, roundcube-larry, roundcube-elastic, clean, gmail, proton, zoho, outlook, minimal, graphite, high-contrast, fastmail, apple-mail, yahoo, hey, nord, github, gitlab, discord, slack, reddit, notion".to_string()); } - if !matches!( - self.sieve_tls_mode.as_str(), - "starttls" | "implicit" | "none" - ) { - return Err("SIEVE_TLS_MODE must be one of: starttls, implicit, none".to_string()); - } - if self.sieve_tls_mode == "none" && !self.sieve_allow_insecure_auth { - tracing::warn!( - "ManageSieve plaintext transport selected; authentication will remain blocked until SIEVE_ALLOW_INSECURE_AUTH=true" - ); + if self.sieve_enabled { + if !matches!( + self.sieve_tls_mode.as_str(), + "starttls" | "implicit" | "none" + ) { + return Err("SIEVE_TLS_MODE must be one of: starttls, implicit, none".to_string()); + } + if self.sieve_tls_mode == "none" && !self.sieve_allow_insecure_auth { + tracing::warn!( + "ManageSieve plaintext transport selected; authentication will remain blocked until SIEVE_ALLOW_INSECURE_AUTH=true" + ); + } } self.parsed_outgoing_headers()?; if self.pgp_network_timeout_secs == 0 || self.pgp_network_timeout_secs > 60 { @@ -993,6 +1019,10 @@ mod tests { assert_eq!(config.environment, "development"); assert_eq!(config.default_theme, "system"); assert_eq!(config.default_interface_skin, "rustbox"); + assert!(config.sieve_enabled); + assert!(config.diagnostics_enabled); + assert!(config.about_enabled); + assert!(!config.security_headers_enabled); assert!(config.sieve_host.is_none()); assert_eq!(config.sieve_port, 4190); assert_eq!(config.sieve_tls_mode, "starttls"); @@ -1029,6 +1059,10 @@ mod tests { .merge(("environment", "production")) .merge(("default_theme", "light")) .merge(("default_interface_skin", "gmail")) + .merge(("sieve_enabled", false)) + .merge(("diagnostics_enabled", false)) + .merge(("about_enabled", false)) + .merge(("security_headers_enabled", true)) .merge(("sieve_host", "sieve.example.com")) .merge(("sieve_port", 4191u16)) .merge(("sieve_tls_mode", "implicit")) @@ -1056,6 +1090,10 @@ mod tests { assert_eq!(config.environment, "production"); assert_eq!(config.default_theme, "light"); assert_eq!(config.default_interface_skin, "gmail"); + assert!(!config.sieve_enabled); + assert!(!config.diagnostics_enabled); + assert!(!config.about_enabled); + assert!(config.security_headers_enabled); assert_eq!(config.sieve_host.as_deref(), Some("sieve.example.com")); assert_eq!(config.sieve_port, 4191); assert_eq!(config.sieve_tls_mode, "implicit"); @@ -1082,6 +1120,18 @@ mod tests { } } + #[test] + fn disabled_sieve_skips_sieve_specific_validation() { + let config = AppConfig { + imap_host: Some("imap.example.com".to_string()), + sieve_enabled: false, + sieve_port: 0, + sieve_tls_mode: "unsupported".to_string(), + ..AppConfig::default() + }; + assert!(config.validate().is_ok()); + } + #[test] fn invalid_default_theme_is_rejected() { let config = AppConfig { diff --git a/src/db/mod.rs b/src/db/mod.rs index db13878..be1fa85 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -14,6 +14,7 @@ pub mod pgp; pub mod pool; pub mod response_templates; pub mod search_outbox; +pub mod settings; pub mod special_folders; pub mod tags; pub mod trusted_senders; diff --git a/src/db/settings.rs b/src/db/settings.rs new file mode 100644 index 0000000..64525ef --- /dev/null +++ b/src/db/settings.rs @@ -0,0 +1,97 @@ +use crate::db::pool::Connection; + +pub fn reset_all(conn: &mut Connection) -> Result<(), String> { + let transaction = conn + .transaction() + .map_err(|error| format!("Failed to start settings reset: {error}"))?; + + for sql in [ + "DELETE FROM folder_preferences", + "DELETE FROM special_folders", + "DELETE FROM display_preferences", + "DELETE FROM mail_preferences", + "DELETE FROM notification_preferences", + "DELETE FROM calendar_settings", + "DELETE FROM pgp_preferences", + "INSERT INTO display_preferences (id) VALUES (1)", + "INSERT INTO mail_preferences (id) VALUES (1)", + "INSERT INTO notification_preferences (id) VALUES (1)", + "INSERT INTO calendar_settings (id) VALUES (1)", + "INSERT INTO pgp_preferences (id) VALUES (1)", + ] { + transaction + .execute(sql, []) + .map_err(|error| format!("Failed to reset settings: {error}"))?; + } + + transaction + .commit() + .map_err(|error| format!("Failed to commit settings reset: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::display_preferences::{self, UpdateDisplayPreferences}; + use crate::db::folder_preferences::{self, UpdateFolderPreferences}; + use crate::db::folders; + use crate::db::mail_preferences::{self, UpdateMailPreferences}; + use crate::db::pool::open_test_db; + + #[test] + fn reset_restores_defaults_and_folder_overrides() { + let mut conn = open_test_db(); + display_preferences::update_preferences( + &conn, + &UpdateDisplayPreferences { + theme: Some("light".to_string()), + ..Default::default() + }, + ) + .unwrap(); + mail_preferences::update_preferences( + &conn, + &UpdateMailPreferences { + messages_per_page: Some(100), + mark_read_delay_ms: None, + remote_images: None, + preferred_body: None, + thread_mode: None, + collapse_quotes: None, + show_snippets: None, + default_sort: None, + flagged_on_top: None, + sync_imap_keywords: None, + }, + ) + .unwrap(); + folders::insert_folder_if_new(&conn, "INBOX", Some("/"), "").unwrap(); + folder_preferences::update_preferences( + &conn, + "INBOX", + &UpdateFolderPreferences { + flagged_on_top: Some(true), + }, + ) + .unwrap(); + + reset_all(&mut conn).unwrap(); + + assert_eq!( + display_preferences::get_preferences(&conn).unwrap().theme, + "system" + ); + assert_eq!( + mail_preferences::get_preferences(&conn) + .unwrap() + .messages_per_page, + 50 + ); + assert_eq!( + folder_preferences::get_preferences(&conn, "INBOX") + .unwrap() + .flagged_on_top, + None + ); + } +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 9f86984..118ee7b 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -21,6 +21,9 @@ pub struct AuthPolicyResponse { remember_me_days: u64, session_timeout_hours: u64, pgp_enabled: bool, + sieve_enabled: bool, + diagnostics_enabled: bool, + about_enabled: bool, } pub async fn auth_policy(Extension(config): Extension>) -> Json { @@ -29,6 +32,9 @@ pub async fn auth_policy(Extension(config): Extension>) -> Json, next: middleware::Next) -> Response { + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + headers.insert("x-frame-options", HeaderValue::from_static("DENY")); + headers.insert("referrer-policy", HeaderValue::from_static("no-referrer")); + 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' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' ws: wss:", + ), + ); + response +} + #[cfg(test)] pub fn create_router( config: Arc, @@ -455,6 +484,9 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic delete(calendar::delete_meeting_template), ) .route("/quota", get(quota::get_quota)) + .route("/settings/reset", post(settings::reset_all_settings)); + + let diagnostics_routes = Router::new() .route("/diagnostics/imap", get(diagnostics::imap_diagnostics)) .route("/diagnostics/smtp", get(diagnostics::smtp_diagnostics)) .route("/diagnostics/sync", get(diagnostics::sync_diagnostics)) @@ -466,7 +498,9 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic .route( "/diagnostics/search/rebuild", post(diagnostics::rebuild_search_index), - ) + ); + + let sieve_routes = Router::new() .route("/sieve/status", get(sieve::status)) .route("/sieve/scripts", get(sieve::list_scripts)) .route( @@ -481,9 +515,20 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic ) .route("/sieve/scripts/{name}/rename", post(sieve::rename_script)) .route("/sieve/deactivate", post(sieve::deactivate_script)) - .route("/sieve/check", post(sieve::check_script)) - .layer(middleware::from_fn(auth_guard)) - .layer(middleware::from_fn(csrf_protection)); + .route("/sieve/check", post(sieve::check_script)); + + let protected_data = if config.diagnostics_enabled { + protected_data.merge(diagnostics_routes) + } else { + protected_data + }; + let protected_data = if config.sieve_enabled { + protected_data.merge(sieve_routes) + } else { + protected_data + } + .layer(middleware::from_fn(auth_guard)) + .layer(middleware::from_fn(csrf_protection)); let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler)); @@ -492,7 +537,8 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic .route("/ready", get(health::readiness_check)) .nest("/auth", auth_router) .merge(ws_route) - .merge(protected_data); + .merge(protected_data) + .fallback(|| async { axum::http::StatusCode::NOT_FOUND }); let inner = Router::new() .nest("/api", api_router) @@ -509,7 +555,7 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic } }; - router + let router = router .layer(Extension(idle_manager)) .layer(Extension(event_bus)) .layer(Extension(smtp_client)) @@ -518,7 +564,13 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic .layer(Extension(imap_client)) .layer(Extension(store)) .layer(Extension(config.clone())) - .layer(TraceLayer::new_for_http()) + .layer(TraceLayer::new_for_http()); + + if config.security_headers_enabled { + router.layer(middleware::from_fn(add_security_headers)) + } else { + router + } } #[cfg(test)] diff --git a/src/routes/settings.rs b/src/routes/settings.rs new file mode 100644 index 0000000..468582e --- /dev/null +++ b/src/routes/settings.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::Serialize; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::error::AppError; + +#[derive(Serialize)] +struct ResetSettingsResponse { + ok: bool, +} + +pub async fn reset_all_settings( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let mut conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|error| AppError::InternalError(format!("Database error: {error}")))?; + + db::settings::reset_all(&mut conn).map_err(AppError::InternalError)?; + + Ok(Json(ResetSettingsResponse { ok: true }).into_response()) +} diff --git a/src/routes/tests.rs b/src/routes/tests.rs index 10f1d04..d2a2196 100644 --- a/src/routes/tests.rs +++ b/src/routes/tests.rs @@ -277,6 +277,118 @@ async fn api_health_works_with_static_fallback() { assert_eq!(json["status"], "ok"); } +#[tokio::test] +async fn disabled_optional_features_are_not_registered() { + let dir = setup_static_dir(); + let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); + config.sieve_enabled = false; + config.diagnostics_enabled = false; + let app = create_router( + Arc::new(config), + test_store(), + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + for uri in ["/api/sieve/status", "/api/diagnostics/imap"] { + let response = app + .clone() + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); + } +} + +#[tokio::test] +async fn auth_policy_exposes_optional_feature_switches() { + let dir = setup_static_dir(); + let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); + config.sieve_enabled = false; + config.diagnostics_enabled = false; + config.about_enabled = false; + let app = create_router( + Arc::new(config), + test_store(), + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .uri("/api/auth/policy") + .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["diagnosticsEnabled"], false); + assert_eq!(json["aboutEnabled"], false); +} + +#[tokio::test] +async fn security_headers_are_opt_in() { + let dir = setup_static_dir(); + let app = create_router( + test_config(dir.path().to_str().unwrap()), + test_store(), + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + let response = app + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(response.headers().get("content-security-policy").is_none()); + + let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); + config.security_headers_enabled = true; + let app = create_router( + Arc::new(config), + test_store(), + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + let response = app + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.headers()["x-frame-options"], "DENY"); + assert!( + response.headers()["content-security-policy"] + .to_str() + .unwrap() + .contains("default-src 'self'") + ); +} + #[tokio::test] async fn root_serves_embedded_rustbox_ui() { let dir = setup_static_dir(); diff --git a/src/web/css/50-settings.css b/src/web/css/50-settings.css index 855e761..0ee8636 100644 --- a/src/web/css/50-settings.css +++ b/src/web/css/50-settings.css @@ -25,6 +25,15 @@ overscroll-behavior: contain; } + +.settings-shortcuts { + margin-top: auto; +} + +.settings-panel[data-feature-disabled] { + display: none !important; +} + .settings-tab { min-height: 30px; border: 1px solid transparent; diff --git a/src/web/index.html b/src/web/index.html index 4793299..d7b2331 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -567,7 +567,7 @@ data-i18n="filters">Filters + data-i18n="about">About
@@ -1120,7 +1120,7 @@
-
+
ManageSieveNot checked
@@ -1162,6 +1162,12 @@ sessions
+
diff --git a/src/web/js/00-bootstrap.js b/src/web/js/00-bootstrap.js index 95d00a3..e9579c3 100644 --- a/src/web/js/00-bootstrap.js +++ b/src/web/js/00-bootstrap.js @@ -33,7 +33,7 @@ pgpKeys: [], pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false }, pgpRecipientStatus: [], - authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true }, + authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true, sieveEnabled: true, diagnosticsEnabled: true, aboutEnabled: true }, contacts: [], trustedSenders: new Set(), contactGroups: [], diff --git a/src/web/js/20-preferences-auth.js b/src/web/js/20-preferences-auth.js index c7515df..2c50a53 100644 --- a/src/web/js/20-preferences-auth.js +++ b/src/web/js/20-preferences-auth.js @@ -458,13 +458,35 @@ function showApp() { $("#app-view").classList.remove("hidden"); } +function settingsTabAvailable(name) { + if (name === "openpgp") return state.authPolicy.pgpEnabled !== false; + if (name === "sieve") return state.authPolicy.sieveEnabled !== false; + if (name === "diagnostics") return state.authPolicy.diagnosticsEnabled !== false; + if (name === "about") return state.authPolicy.aboutEnabled !== false; + return true; +} + +function applyFeatureAvailability() { + for (const [name, enabled] of [ + ["sieve", state.authPolicy.sieveEnabled !== false], + ["diagnostics", state.authPolicy.diagnosticsEnabled !== false], + ["about", state.authPolicy.aboutEnabled !== false], + ]) { + $(`.settings-tab[data-tab="${name}"]`)?.classList.toggle("hidden", !enabled); + $(`.settings-panel[data-panel="${name}"]`)?.toggleAttribute("data-feature-disabled", !enabled); + } + $("#diagnostic-sieve-card")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false); + $("#quick-filter-btn")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false); +} + async function loadAuthPolicy() { try { state.authPolicy = await request("/auth/policy", { method: "GET" }); } catch (_) { - state.authPolicy = { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true }; + state.authPolicy = { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true, sieveEnabled: true, diagnosticsEnabled: true, aboutEnabled: true }; } setOpenPgpAvailability(state.authPolicy.pgpEnabled !== false); + 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; } }); diff --git a/src/web/js/60-mail-actions-search.js b/src/web/js/60-mail-actions-search.js index 8f0c052..5cdf82b 100644 --- a/src/web/js/60-mail-actions-search.js +++ b/src/web/js/60-mail-actions-search.js @@ -500,9 +500,11 @@ function openMessageContextMenu(x, y, message) { { label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } }, { label: "Remove label…", action: async () => { const attached = state.tags.filter((tag) => (message.tags || []).some((item) => item.id === tag.id)); const tag = await promptTag("Remove label", attached); if (tag) await removeTagFromMessage(message, tag); } }, { label: isJunkFolder(message.folder || state.folder) ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"), action: async () => { await ensureMessageOpen(message); await junkCurrent(); } }, - { label: "Create filter from sender", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("sender"); } }, - { label: "Create filter from domain", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("domain"); } }, - { label: "Create filter from subject", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("subject"); } }, + ...(state.authPolicy.sieveEnabled === false ? [] : [ + { label: "Create filter from sender", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("sender"); } }, + { label: "Create filter from domain", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("domain"); } }, + { label: "Create filter from subject", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("subject"); } }, + ]), { label: "Add sender to contacts", action: async () => { await ensureMessageOpen(message); await addSenderToContacts(); } }, { label: "Search messages from sender", action: async () => { await ensureMessageOpen(message); await searchFromCurrentSender(); } }, { label: "Unsubscribe from mailing list", action: async () => { await ensureMessageOpen(message); await openListUnsubscribe(); } }, @@ -835,7 +837,9 @@ function openCurrentSenderMenu(x, y) { { label: "Copy address", action: copySenderAddress }, { label: "Search messages", action: searchFromCurrentSender }, "separator", - { label: "Create filter", action: async () => createFilterFromCurrentMessage("sender") }, + ...(state.authPolicy.sieveEnabled === false ? [] : [ + { label: "Create filter", action: async () => createFilterFromCurrentMessage("sender") }, + ]), ]); } @@ -985,6 +989,7 @@ async function prepareQuickFilterAdvanced(config) { } async function createFilterFromCurrentMessage(kind = "sender") { + if (state.authPolicy.sieveEnabled === false) return; const detail = state.currentDetail; if (!detail) return; diff --git a/src/web/js/80-settings-realtime.js b/src/web/js/80-settings-realtime.js index bad8049..03b4e20 100644 --- a/src/web/js/80-settings-realtime.js +++ b/src/web/js/80-settings-realtime.js @@ -45,7 +45,7 @@ async function loadQuota() { } function openSettings(tab = "appearance") { - if (tab === "openpgp" && state.authPolicy.pgpEnabled === false) tab = "appearance"; + if (!settingsTabAvailable(tab)) tab = "appearance"; selectSettingsTab(tab); $("#settings-dialog").showModal(); if (tab === "sieve") loadSieve(); @@ -59,7 +59,7 @@ function openSettings(tab = "appearance") { } function selectSettingsTab(name) { - if (name === "openpgp" && state.authPolicy.pgpEnabled === false) name = "appearance"; + if (!settingsTabAvailable(name)) name = "appearance"; $$(".settings-tab").forEach((button) => button.classList.toggle("active", button.dataset.tab === name)); $$(".settings-panel").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.panel !== name)); } @@ -371,6 +371,20 @@ function disconnectRealtime() { if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect"); } +async function resetAllSettings() { + if (!window.confirm("Reset all RustBox settings to defaults? Messages, identities, keys and templates will be kept.")) return; + const button = $("#reset-all-settings"); + if (button) button.disabled = true; + try { + await request("/settings/reset", { method: "POST", body: "{}" }); + ["rustbox.calendarView", "rustbox.threadExpanded", "rustbox.composeFullscreen"].forEach((key) => localStorage.removeItem(key)); + window.location.reload(); + } catch (error) { + toast(error.message, "error"); + if (button) button.disabled = false; + } +} + function capabilityBadge(label, supported) { return `${escapeHtml(label)}${supported ? "yes" : "no"}`; } @@ -410,13 +424,15 @@ async function loadDiagnostics() { smtpStatus.textContent = "Unavailable"; $("#diagnostic-smtp-summary").textContent = error.message; } - try { - const info = await request("/sieve/status", { method: "GET" }); - sieveStatus.textContent = info.connected ? "Connected" : "Unavailable"; - $("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`; - } catch (error) { - sieveStatus.textContent = "Unavailable"; - $("#diagnostic-sieve-summary").textContent = error.message; + if (state.authPolicy.sieveEnabled !== false) { + try { + const info = await request("/sieve/status", { method: "GET" }); + sieveStatus.textContent = info.connected ? "Connected" : "Unavailable"; + $("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`; + } catch (error) { + sieveStatus.textContent = "Unavailable"; + $("#diagnostic-sieve-summary").textContent = error.message; + } } try { const info = await request("/diagnostics/sync", { method: "GET" }); diff --git a/src/web/js/90-sieve.js b/src/web/js/90-sieve.js index e87de42..ff9657d 100644 --- a/src/web/js/90-sieve.js +++ b/src/web/js/90-sieve.js @@ -1,5 +1,6 @@ async function loadSieve() { + if (state.authPolicy.sieveEnabled === false) return; const status = $("#sieve-status"); status.textContent = "Connecting…"; try { diff --git a/src/web/js/95-bindings-boot.js b/src/web/js/95-bindings-boot.js index 9f96dd4..de01bfe 100644 --- a/src/web/js/95-bindings-boot.js +++ b/src/web/js/95-bindings-boot.js @@ -246,6 +246,7 @@ function bindEvents() { $("#shortcuts-open").addEventListener("click", () => $("#shortcuts-dialog").showModal()); $("#account-add").addEventListener("click", () => { $("#add-account-error").classList.add("hidden"); $("#add-account-dialog").showModal(); setTimeout(() => $("#add-account-email").focus(), 20); }); $("#account-revoke-others").addEventListener("click", revokeOtherSessions); + $("#reset-all-settings").addEventListener("click", resetAllSettings); $("#add-account-form").addEventListener("submit", addBrowserAccount); $("#mobile-folders").addEventListener("click", toggleFolderDrawer); $("#folder-drawer-close").addEventListener("click", () => setFolderDrawerOpen(false));