1 parent
5b22add1f3
commit
2c98b8f925
38 files changed
+915
-87
No files matched your search
@@ -1,5 +1,15 @@
|
|||||||
# RustBox changelog
|
# 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
|
### 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 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.
|
- 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.
|
||||||
|
|||||||
Generated
+3
-3
@@ -3508,7 +3508,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-imap",
|
"async-imap",
|
||||||
@@ -4912,9 +4912,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-properties"
|
name = "unicode-properties"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
default-run = "rustbox"
|
default-run = "rustbox"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# RustBox attribution / notice
|
# 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`
|
- Upstream: `c0h1b4/oxi`
|
||||||
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
|
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
|
||||||
|
|||||||
@@ -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`.
|
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.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.
|
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.
|
# Hard limit for the complete rendered RFC822/MIME message submitted to the MTA.
|
||||||
max_message_size_mb = 50
|
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_host = "mail.example.com"
|
||||||
sieve_port = 4190
|
sieve_port = 4190
|
||||||
sieve_tls_mode = "starttls"
|
sieve_tls_mode = "starttls"
|
||||||
@@ -217,7 +224,7 @@ Initialize Git after extracting the release archive:
|
|||||||
```bash
|
```bash
|
||||||
git init
|
git init
|
||||||
git add .
|
git add .
|
||||||
git commit -m "RustBox 0.2.2 release"
|
git commit -m "RustBox 0.2.3 release"
|
||||||
./scripts/setup-upstream.sh
|
./scripts/setup-upstream.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -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:
|
Architecture:
|
||||||
- database_backend = sqlite | mysql | pgsql selects authoritative SQL only
|
- database_backend = sqlite | mysql | pgsql selects authoritative SQL only
|
||||||
@@ -53,18 +61,19 @@ SQLite test isolation (0.2.2):
|
|||||||
|
|
||||||
Docker examples:
|
Docker examples:
|
||||||
- docker/compose-single-sqlite.yml
|
- 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-postgres-s3-opensearch.yml (PostgreSQL + SILO/S3 + OpenSearch)
|
||||||
- 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.2 storage/search configuration
|
- README.md documents 0.2.3 storage/search 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.2
|
- Cargo.toml version = 0.2.3
|
||||||
- rust-toolchain.toml pins Rust 1.88.0
|
- 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
|
- CI and scripts/build-release.sh generate Cargo.lock, then run locked tests/Clippy/release build
|
||||||
+10
-2
@@ -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:
|
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
|
- all maintained TOML examples parse successfully
|
||||||
- Docker Compose topology/config references are validated by the offline release validator
|
- Docker Compose topology/config references are validated by the offline release validator
|
||||||
- JSON files parse successfully
|
- JSON files parse successfully
|
||||||
|
|||||||
+7
-1
@@ -39,7 +39,13 @@ tls_enabled = true
|
|||||||
max_attachment_size_mb = 10
|
max_attachment_size_mb = 10
|
||||||
max_message_size_mb = 50
|
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_port = 4190
|
||||||
sieve_tls_mode = "starttls" # starttls | implicit | none
|
sieve_tls_mode = "starttls" # starttls | implicit | none
|
||||||
sieve_allow_insecure_auth = false
|
sieve_allow_insecure_auth = false
|
||||||
|
|||||||
+8
-1
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.2.2
|
# RustBox 0.2.3
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
port = 3001
|
port = 3001
|
||||||
environment = "production"
|
environment = "production"
|
||||||
@@ -55,6 +55,13 @@ max_message_size_mb = 50
|
|||||||
# Optional reverse-proxy subpath, e.g. "/mail".
|
# Optional reverse-proxy subpath, e.g. "/mail".
|
||||||
# base_path = "/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
|
# ManageSieve. If sieve_host is omitted, imap_host from the authenticated
|
||||||
# session is used. Plaintext authentication stays blocked unless explicitly
|
# session is used. Plaintext authentication stays blocked unless explicitly
|
||||||
# enabled.
|
# enabled.
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ POSTGRES_PASSWORD=rustbox-dev-password
|
|||||||
S3_ACCESS_KEY=rustbox
|
S3_ACCESS_KEY=rustbox
|
||||||
S3_SECRET_KEY=rustbox-s3-dev-password
|
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 variant.
|
||||||
MYSQL_PASSWORD=rustbox-dev-password
|
MYSQL_PASSWORD=rustbox-dev-password
|
||||||
MYSQL_ROOT_PASSWORD=rustbox-root-password
|
MYSQL_ROOT_PASSWORD=rustbox-root-password
|
||||||
|
|||||||
+41
-4
@@ -2,10 +2,26 @@
|
|||||||
|
|
||||||
Run all commands from the `docker/` directory.
|
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
|
## 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:
|
PostgreSQL + S3/SILO + OpenSearch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -58,7 +74,7 @@ cp .env.example .env
|
|||||||
| Variable | Default | Used by | Description |
|
| Variable | Default | Used by | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `HOST` | `0.0.0.0` | all | Address RustBox listens on inside the container. |
|
| `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_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. |
|
| `IMAP_PORT` | `993` | all | IMAP server port. |
|
||||||
| `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. |
|
||||||
@@ -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. |
|
| `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_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. |
|
| `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. |
|
| `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.
|
`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
|
## 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`
|
### `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.
|
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 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
|
## 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
|
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`.
|
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.
|
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}'
|
-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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
Executable
+22
@@ -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"
|
||||||
@@ -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.
|
- 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.
|
||||||
|
|||||||
+1
-1
@@ -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.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.
|
Do not overlay the release with `cp *` or another command that skips dotfiles. That can retain stale `.github`, `.env.example`, or `Cargo.lock` files.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## Deployment matrix
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Database schemas and migrations
|
# 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
|
```text
|
||||||
database_backend = sqlite | mysql | pgsql
|
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.
|
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.
|
Redis and Redis Sentinel do not use SQL migrations.
|
||||||
|
|
||||||
|
|||||||
+14
-6
@@ -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.
|
# 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.
|
||||||
@@ -35,6 +35,13 @@ default_theme = "system"
|
|||||||
# and Roundcube variants.
|
# and Roundcube variants.
|
||||||
default_interface_skin = "apple-mail"
|
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
|
# Database
|
||||||
@@ -87,10 +94,10 @@ remember_me_days = 30
|
|||||||
#
|
#
|
||||||
# session_encryption_key = "BASE64_32_BYTE_KEY"
|
# session_encryption_key = "BASE64_32_BYTE_KEY"
|
||||||
|
|
||||||
# Direct Redis:
|
# Direct Redis/Valkey:
|
||||||
# redis_url = "redis://redis.example.com:6379/"
|
# redis_url = "redis://redis.example.com:6379/"
|
||||||
|
|
||||||
# Redis Sentinel:
|
# Redis Sentinel (or a compatible Redis deployment):
|
||||||
# redis_sentinel_nodes = [
|
# redis_sentinel_nodes = [
|
||||||
# "sentinel-a:26379",
|
# "sentinel-a:26379",
|
||||||
# "sentinel-b:26379",
|
# "sentinel-b:26379",
|
||||||
@@ -248,7 +255,7 @@ retry_timeout_secs = 60
|
|||||||
# bucket = "rustbox"
|
# bucket = "rustbox"
|
||||||
# region = "eu-central-1"
|
# 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.
|
# AWS S3 normally does not require endpoint.
|
||||||
#
|
#
|
||||||
# endpoint = "https://s3.example.net"
|
# endpoint = "https://s3.example.net"
|
||||||
@@ -265,6 +272,7 @@ retry_timeout_secs = 60
|
|||||||
#
|
#
|
||||||
# access_key_id = "..."
|
# access_key_id = "..."
|
||||||
# secret_access_key = "..."
|
# secret_access_key = "..."
|
||||||
|
# session_token = "..."
|
||||||
#
|
#
|
||||||
# Server-side encryption:
|
# Server-side encryption:
|
||||||
#
|
#
|
||||||
@@ -405,13 +413,13 @@ worker_max_attempts = 12
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Cluster sessions using Redis
|
# Cluster sessions using Redis/Valkey
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
#
|
#
|
||||||
# session_backend = "redis"
|
# session_backend = "redis"
|
||||||
# session_encryption_key = "BASE64_32_BYTE_KEY"
|
# session_encryption_key = "BASE64_32_BYTE_KEY"
|
||||||
#
|
#
|
||||||
# redis_url = "redis://redis.internal:6379/"
|
# redis_url = "redis://valkey.internal:6379/"
|
||||||
#
|
#
|
||||||
# Or Sentinel:
|
# Or Sentinel:
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
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,7 +49,7 @@ 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.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")
|
require(cargo["package"]["rust-version"] == "1.88", "Cargo rust-version is 1.88")
|
||||||
toolchain = tomllib.loads(read("rust-toolchain.toml"))
|
toolchain = tomllib.loads(read("rust-toolchain.toml"))
|
||||||
require(toolchain["toolchain"]["channel"] == "1.88.0", "rust-toolchain pins Rust 1.88.0")
|
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("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.2"), "README 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.2"), "release manifest identifies RustBox 0.2.2")
|
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.2"), "release validation identifies RustBox 0.2.2")
|
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.2"), "implementation status identifies RustBox 0.2.2")
|
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((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")
|
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")
|
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("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("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")
|
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/single-sqlite.toml",
|
||||||
"docker/configs/postgres-s3-opensearch.toml",
|
"docker/configs/postgres-s3-opensearch.toml",
|
||||||
"docker/configs/mysql-nfs-opensearch.toml",
|
"docker/configs/mysql-nfs-opensearch.toml",
|
||||||
|
"docker/configs/ha-garage.toml",
|
||||||
]
|
]
|
||||||
for rel in toml_files:
|
for rel in toml_files:
|
||||||
try:
|
try:
|
||||||
@@ -170,7 +182,7 @@ for rel in toml_files:
|
|||||||
|
|
||||||
# Parse Docker Compose YAML and validate referenced config paths.
|
# Parse Docker Compose YAML and validate referenced config paths.
|
||||||
compose_files = sorted((ROOT / "docker").glob("compose-*.yml"))
|
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:
|
if yaml is None:
|
||||||
errors.append("PyYAML is required for Docker Compose static validation")
|
errors.append("PyYAML is required for Docker Compose static validation")
|
||||||
else:
|
else:
|
||||||
@@ -178,11 +190,14 @@ else:
|
|||||||
try:
|
try:
|
||||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
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}")
|
require(isinstance(data, dict) and "services" in data, f"Compose parses with services: {path.name}")
|
||||||
rustbox = data.get("services", {}).get("rustbox", {})
|
services = data.get("services", {})
|
||||||
for volume in rustbox.get("volumes", []) or []:
|
for service_name, service in services.items():
|
||||||
if isinstance(volume, str) and volume.startswith("./configs/"):
|
if service_name != "rustbox" and not service_name.startswith("rustbox-"):
|
||||||
config_rel = volume.split(":", 1)[0].removeprefix("./")
|
continue
|
||||||
require((ROOT / "docker" / config_rel).is_file(), f"Compose config mount exists: {path.name} -> {config_rel}")
|
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:
|
except Exception as exc:
|
||||||
errors.append(f"Compose parse failed: {path.name}: {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["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")
|
||||||
|
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("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/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")
|
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.
|
# Shell syntax checks are useful offline and deterministic.
|
||||||
try:
|
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)
|
subprocess.run(["bash", "-n", *map(str, shell_files)], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
ok("shell scripts pass bash -n")
|
ok("shell scripts pass bash -n")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
+62
-12
@@ -218,6 +218,18 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub base_path: Option<String>,
|
pub base_path: Option<String>,
|
||||||
|
|
||||||
|
#[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)]
|
#[serde(default)]
|
||||||
pub sieve_host: Option<String>,
|
pub sieve_host: Option<String>,
|
||||||
|
|
||||||
@@ -406,6 +418,10 @@ fn default_interface_skin() -> String {
|
|||||||
"rustbox".to_string()
|
"rustbox".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_feature_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn default_sieve_port() -> u16 {
|
fn default_sieve_port() -> u16 {
|
||||||
4190
|
4190
|
||||||
}
|
}
|
||||||
@@ -470,6 +486,10 @@ impl Default for AppConfig {
|
|||||||
default_theme: default_theme(),
|
default_theme: default_theme(),
|
||||||
default_interface_skin: default_interface_skin(),
|
default_interface_skin: default_interface_skin(),
|
||||||
base_path: None,
|
base_path: None,
|
||||||
|
sieve_enabled: true,
|
||||||
|
diagnostics_enabled: true,
|
||||||
|
about_enabled: true,
|
||||||
|
security_headers_enabled: false,
|
||||||
sieve_host: None,
|
sieve_host: None,
|
||||||
sieve_port: default_sieve_port(),
|
sieve_port: default_sieve_port(),
|
||||||
sieve_tls_mode: default_sieve_tls_mode(),
|
sieve_tls_mode: default_sieve_tls_mode(),
|
||||||
@@ -654,8 +674,12 @@ impl AppConfig {
|
|||||||
{
|
{
|
||||||
return Err("IMAP_HOST / imap_host must be configured".to_string());
|
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 {
|
if self.port == 0
|
||||||
return Err("network ports must be greater than zero".to_string());
|
|| 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() {
|
if let Some(log_dir) = self.log_dir.as_deref() {
|
||||||
let log_dir = log_dir.trim();
|
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());
|
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!(
|
if self.sieve_enabled {
|
||||||
self.sieve_tls_mode.as_str(),
|
if !matches!(
|
||||||
"starttls" | "implicit" | "none"
|
self.sieve_tls_mode.as_str(),
|
||||||
) {
|
"starttls" | "implicit" | "none"
|
||||||
return Err("SIEVE_TLS_MODE must be one of: starttls, implicit, none".to_string());
|
) {
|
||||||
}
|
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!(
|
if self.sieve_tls_mode == "none" && !self.sieve_allow_insecure_auth {
|
||||||
"ManageSieve plaintext transport selected; authentication will remain blocked until SIEVE_ALLOW_INSECURE_AUTH=true"
|
tracing::warn!(
|
||||||
);
|
"ManageSieve plaintext transport selected; authentication will remain blocked until SIEVE_ALLOW_INSECURE_AUTH=true"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.parsed_outgoing_headers()?;
|
self.parsed_outgoing_headers()?;
|
||||||
if self.pgp_network_timeout_secs == 0 || self.pgp_network_timeout_secs > 60 {
|
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.environment, "development");
|
||||||
assert_eq!(config.default_theme, "system");
|
assert_eq!(config.default_theme, "system");
|
||||||
assert_eq!(config.default_interface_skin, "rustbox");
|
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!(config.sieve_host.is_none());
|
||||||
assert_eq!(config.sieve_port, 4190);
|
assert_eq!(config.sieve_port, 4190);
|
||||||
assert_eq!(config.sieve_tls_mode, "starttls");
|
assert_eq!(config.sieve_tls_mode, "starttls");
|
||||||
@@ -1029,6 +1059,10 @@ mod tests {
|
|||||||
.merge(("environment", "production"))
|
.merge(("environment", "production"))
|
||||||
.merge(("default_theme", "light"))
|
.merge(("default_theme", "light"))
|
||||||
.merge(("default_interface_skin", "gmail"))
|
.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_host", "sieve.example.com"))
|
||||||
.merge(("sieve_port", 4191u16))
|
.merge(("sieve_port", 4191u16))
|
||||||
.merge(("sieve_tls_mode", "implicit"))
|
.merge(("sieve_tls_mode", "implicit"))
|
||||||
@@ -1056,6 +1090,10 @@ mod tests {
|
|||||||
assert_eq!(config.environment, "production");
|
assert_eq!(config.environment, "production");
|
||||||
assert_eq!(config.default_theme, "light");
|
assert_eq!(config.default_theme, "light");
|
||||||
assert_eq!(config.default_interface_skin, "gmail");
|
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_host.as_deref(), Some("sieve.example.com"));
|
||||||
assert_eq!(config.sieve_port, 4191);
|
assert_eq!(config.sieve_port, 4191);
|
||||||
assert_eq!(config.sieve_tls_mode, "implicit");
|
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]
|
#[test]
|
||||||
fn invalid_default_theme_is_rejected() {
|
fn invalid_default_theme_is_rejected() {
|
||||||
let config = AppConfig {
|
let config = AppConfig {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod pgp;
|
|||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod response_templates;
|
pub mod response_templates;
|
||||||
pub mod search_outbox;
|
pub mod search_outbox;
|
||||||
|
pub mod settings;
|
||||||
pub mod special_folders;
|
pub mod special_folders;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
pub mod trusted_senders;
|
pub mod trusted_senders;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ pub struct AuthPolicyResponse {
|
|||||||
remember_me_days: u64,
|
remember_me_days: u64,
|
||||||
session_timeout_hours: u64,
|
session_timeout_hours: u64,
|
||||||
pgp_enabled: bool,
|
pgp_enabled: bool,
|
||||||
|
sieve_enabled: bool,
|
||||||
|
diagnostics_enabled: bool,
|
||||||
|
about_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<AuthPolicyResponse> {
|
pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<AuthPolicyResponse> {
|
||||||
@@ -29,6 +32,9 @@ pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<A
|
|||||||
remember_me_days: config.remember_me_days,
|
remember_me_days: config.remember_me_days,
|
||||||
session_timeout_hours: config.session_timeout_hours,
|
session_timeout_hours: config.session_timeout_hours,
|
||||||
pgp_enabled: config.pgp_enabled,
|
pgp_enabled: config.pgp_enabled,
|
||||||
|
sieve_enabled: config.sieve_enabled,
|
||||||
|
diagnostics_enabled: config.diagnostics_enabled,
|
||||||
|
about_enabled: config.about_enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-8
@@ -19,6 +19,7 @@ pub mod quota;
|
|||||||
pub mod response_templates;
|
pub mod response_templates;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod send;
|
pub mod send;
|
||||||
|
pub mod settings;
|
||||||
pub mod sieve;
|
pub mod sieve;
|
||||||
pub mod special_folders;
|
pub mod special_folders;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
@@ -29,8 +30,10 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
use axum::extract::{ConnectInfo, DefaultBodyLimit};
|
use axum::extract::{ConnectInfo, DefaultBodyLimit};
|
||||||
use axum::http::Request;
|
use axum::http::{HeaderValue, Request};
|
||||||
|
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};
|
||||||
use tower_governor::GovernorError;
|
use tower_governor::GovernorError;
|
||||||
@@ -101,6 +104,32 @@ impl KeyExtractor for ProxyAwareIpExtractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn add_security_headers(request: Request<Body>, 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)]
|
#[cfg(test)]
|
||||||
pub fn create_router(
|
pub fn create_router(
|
||||||
config: Arc<AppConfig>,
|
config: Arc<AppConfig>,
|
||||||
@@ -455,6 +484,9 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
|||||||
delete(calendar::delete_meeting_template),
|
delete(calendar::delete_meeting_template),
|
||||||
)
|
)
|
||||||
.route("/quota", get(quota::get_quota))
|
.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/imap", get(diagnostics::imap_diagnostics))
|
||||||
.route("/diagnostics/smtp", get(diagnostics::smtp_diagnostics))
|
.route("/diagnostics/smtp", get(diagnostics::smtp_diagnostics))
|
||||||
.route("/diagnostics/sync", get(diagnostics::sync_diagnostics))
|
.route("/diagnostics/sync", get(diagnostics::sync_diagnostics))
|
||||||
@@ -466,7 +498,9 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
|||||||
.route(
|
.route(
|
||||||
"/diagnostics/search/rebuild",
|
"/diagnostics/search/rebuild",
|
||||||
post(diagnostics::rebuild_search_index),
|
post(diagnostics::rebuild_search_index),
|
||||||
)
|
);
|
||||||
|
|
||||||
|
let sieve_routes = Router::new()
|
||||||
.route("/sieve/status", get(sieve::status))
|
.route("/sieve/status", get(sieve::status))
|
||||||
.route("/sieve/scripts", get(sieve::list_scripts))
|
.route("/sieve/scripts", get(sieve::list_scripts))
|
||||||
.route(
|
.route(
|
||||||
@@ -481,9 +515,20 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
|||||||
)
|
)
|
||||||
.route("/sieve/scripts/{name}/rename", post(sieve::rename_script))
|
.route("/sieve/scripts/{name}/rename", post(sieve::rename_script))
|
||||||
.route("/sieve/deactivate", post(sieve::deactivate_script))
|
.route("/sieve/deactivate", post(sieve::deactivate_script))
|
||||||
.route("/sieve/check", post(sieve::check_script))
|
.route("/sieve/check", post(sieve::check_script));
|
||||||
.layer(middleware::from_fn(auth_guard))
|
|
||||||
.layer(middleware::from_fn(csrf_protection));
|
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));
|
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<AppConfig>, services: RouterServic
|
|||||||
.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 });
|
||||||
|
|
||||||
let inner = Router::new()
|
let inner = Router::new()
|
||||||
.nest("/api", api_router)
|
.nest("/api", api_router)
|
||||||
@@ -509,7 +555,7 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router
|
let router = router
|
||||||
.layer(Extension(idle_manager))
|
.layer(Extension(idle_manager))
|
||||||
.layer(Extension(event_bus))
|
.layer(Extension(event_bus))
|
||||||
.layer(Extension(smtp_client))
|
.layer(Extension(smtp_client))
|
||||||
@@ -518,7 +564,13 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
|||||||
.layer(Extension(imap_client))
|
.layer(Extension(imap_client))
|
||||||
.layer(Extension(store))
|
.layer(Extension(store))
|
||||||
.layer(Extension(config.clone()))
|
.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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -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<SessionState>,
|
||||||
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -277,6 +277,118 @@ async fn api_health_works_with_static_fallback() {
|
|||||||
assert_eq!(json["status"], "ok");
|
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]
|
#[tokio::test]
|
||||||
async fn root_serves_embedded_rustbox_ui() {
|
async fn root_serves_embedded_rustbox_ui() {
|
||||||
let dir = setup_static_dir();
|
let dir = setup_static_dir();
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.settings-shortcuts {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel[data-feature-disabled] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-tab {
|
.settings-tab {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
|||||||
+8
-4
@@ -567,7 +567,7 @@
|
|||||||
data-i18n="filters">Filters</button><button class="settings-tab" data-tab="diagnostics"
|
data-i18n="filters">Filters</button><button class="settings-tab" data-tab="diagnostics"
|
||||||
type="button">Diagnostics</button><button class="settings-tab" data-tab="account"
|
type="button">Diagnostics</button><button class="settings-tab" data-tab="account"
|
||||||
type="button">Account</button><button class="settings-tab" data-tab="about" type="button"
|
type="button">Account</button><button class="settings-tab" data-tab="about" type="button"
|
||||||
data-i18n="about">About</button></nav>
|
data-i18n="about">About</button><button id="shortcuts-open" class="button quiet settings-shortcuts" type="button">Keyboard shortcuts</button></nav>
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<section class="settings-panel" data-panel="appearance">
|
<section class="settings-panel" data-panel="appearance">
|
||||||
<div class="settings-panel-heading">
|
<div class="settings-panel-heading">
|
||||||
@@ -1120,7 +1120,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="diagnostic-smtp-summary" class="capability-list"></div>
|
<div id="diagnostic-smtp-summary" class="capability-list"></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="diagnostic-card">
|
<section id="diagnostic-sieve-card" class="diagnostic-card">
|
||||||
<div class="section-heading"><span>ManageSieve</span><strong id="diagnostic-sieve-status">Not
|
<div class="section-heading"><span>ManageSieve</span><strong id="diagnostic-sieve-status">Not
|
||||||
checked</strong></div>
|
checked</strong></div>
|
||||||
<div id="diagnostic-sieve-summary" class="capability-list"></div>
|
<div id="diagnostic-sieve-summary" class="capability-list"></div>
|
||||||
@@ -1162,6 +1162,12 @@
|
|||||||
sessions</button>
|
sessions</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="account-session-list" class="account-settings-list"></div>
|
<div id="account-session-list" class="account-settings-list"></div>
|
||||||
|
<div class="settings-panel-heading account-session-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Reset settings</h3>
|
||||||
|
<p class="muted">Restore appearance, mailbox, notifications, folder, calendar and OpenPGP preferences. Messages, identities, keys and templates are kept.</p>
|
||||||
|
</div><button id="reset-all-settings" class="button quiet danger" type="button">Reset all settings</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="settings-panel hidden" data-panel="about">
|
<section class="settings-panel hidden" data-panel="about">
|
||||||
<h2>RustBox __RUSTBOX_VERSION__</h2>
|
<h2>RustBox __RUSTBOX_VERSION__</h2>
|
||||||
@@ -1179,8 +1185,6 @@
|
|||||||
<dd><a href="https://git.linuxiarz.pl/gru/RustBox" target="_blank"
|
<dd><a href="https://git.linuxiarz.pl/gru/RustBox" target="_blank"
|
||||||
rel="noopener noreferrer">git.linuxiarz.pl/gru/RustBox</a></dd>
|
rel="noopener noreferrer">git.linuxiarz.pl/gru/RustBox</a></dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="inline-actions"><button id="shortcuts-open" class="button quiet" type="button">Keyboard
|
|
||||||
shortcuts</button></div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 },
|
authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true, sieveEnabled: true, diagnosticsEnabled: true, aboutEnabled: true },
|
||||||
contacts: [],
|
contacts: [],
|
||||||
trustedSenders: new Set(),
|
trustedSenders: new Set(),
|
||||||
contactGroups: [],
|
contactGroups: [],
|
||||||
|
|||||||
@@ -458,13 +458,35 @@ function showApp() {
|
|||||||
$("#app-view").classList.remove("hidden");
|
$("#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() {
|
async function loadAuthPolicy() {
|
||||||
try {
|
try {
|
||||||
state.authPolicy = await request("/auth/policy", { method: "GET" });
|
state.authPolicy = await request("/auth/policy", { method: "GET" });
|
||||||
} catch (_) {
|
} 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);
|
setOpenPgpAvailability(state.authPolicy.pgpEnabled !== false);
|
||||||
|
applyFeatureAvailability();
|
||||||
const enabled = state.authPolicy.rememberMeEnabled !== false;
|
const enabled = state.authPolicy.rememberMeEnabled !== false;
|
||||||
["#login-remember-row", "#add-account-remember-row"].forEach((selector) => $(selector)?.classList.toggle("hidden", !enabled));
|
["#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; } });
|
["#login-remember", "#add-account-remember"].forEach((selector) => { const input = $(selector); if (input && !enabled) { input.checked = false; input.disabled = true; } });
|
||||||
|
|||||||
@@ -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: "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: "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: 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"); } },
|
...(state.authPolicy.sieveEnabled === false ? [] : [
|
||||||
{ label: "Create filter from domain", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("domain"); } },
|
{ label: "Create filter from sender", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("sender"); } },
|
||||||
{ label: "Create filter from subject", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("subject"); } },
|
{ 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: "Add sender to contacts", action: async () => { await ensureMessageOpen(message); await addSenderToContacts(); } },
|
||||||
{ label: "Search messages from sender", action: async () => { await ensureMessageOpen(message); await searchFromCurrentSender(); } },
|
{ label: "Search messages from sender", action: async () => { await ensureMessageOpen(message); await searchFromCurrentSender(); } },
|
||||||
{ label: "Unsubscribe from mailing list", action: async () => { await ensureMessageOpen(message); await openListUnsubscribe(); } },
|
{ 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: "Copy address", action: copySenderAddress },
|
||||||
{ label: "Search messages", action: searchFromCurrentSender },
|
{ label: "Search messages", action: searchFromCurrentSender },
|
||||||
"separator",
|
"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") {
|
async function createFilterFromCurrentMessage(kind = "sender") {
|
||||||
|
if (state.authPolicy.sieveEnabled === false) return;
|
||||||
const detail = state.currentDetail;
|
const detail = state.currentDetail;
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async function loadQuota() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(tab = "appearance") {
|
function openSettings(tab = "appearance") {
|
||||||
if (tab === "openpgp" && state.authPolicy.pgpEnabled === false) tab = "appearance";
|
if (!settingsTabAvailable(tab)) tab = "appearance";
|
||||||
selectSettingsTab(tab);
|
selectSettingsTab(tab);
|
||||||
$("#settings-dialog").showModal();
|
$("#settings-dialog").showModal();
|
||||||
if (tab === "sieve") loadSieve();
|
if (tab === "sieve") loadSieve();
|
||||||
@@ -59,7 +59,7 @@ function openSettings(tab = "appearance") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectSettingsTab(name) {
|
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-tab").forEach((button) => button.classList.toggle("active", button.dataset.tab === name));
|
||||||
$$(".settings-panel").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.panel !== 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");
|
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) {
|
function capabilityBadge(label, supported) {
|
||||||
return `<span class="capability-badge ${supported ? "supported" : "unsupported"}">${escapeHtml(label)}<strong>${supported ? "yes" : "no"}</strong></span>`;
|
return `<span class="capability-badge ${supported ? "supported" : "unsupported"}">${escapeHtml(label)}<strong>${supported ? "yes" : "no"}</strong></span>`;
|
||||||
}
|
}
|
||||||
@@ -410,13 +424,15 @@ async function loadDiagnostics() {
|
|||||||
smtpStatus.textContent = "Unavailable";
|
smtpStatus.textContent = "Unavailable";
|
||||||
$("#diagnostic-smtp-summary").textContent = error.message;
|
$("#diagnostic-smtp-summary").textContent = error.message;
|
||||||
}
|
}
|
||||||
try {
|
if (state.authPolicy.sieveEnabled !== false) {
|
||||||
const info = await request("/sieve/status", { method: "GET" });
|
try {
|
||||||
sieveStatus.textContent = info.connected ? "Connected" : "Unavailable";
|
const info = await request("/sieve/status", { method: "GET" });
|
||||||
$("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`;
|
sieveStatus.textContent = info.connected ? "Connected" : "Unavailable";
|
||||||
} catch (error) {
|
$("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`;
|
||||||
sieveStatus.textContent = "Unavailable";
|
} catch (error) {
|
||||||
$("#diagnostic-sieve-summary").textContent = error.message;
|
sieveStatus.textContent = "Unavailable";
|
||||||
|
$("#diagnostic-sieve-summary").textContent = error.message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const info = await request("/diagnostics/sync", { method: "GET" });
|
const info = await request("/diagnostics/sync", { method: "GET" });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
async function loadSieve() {
|
async function loadSieve() {
|
||||||
|
if (state.authPolicy.sieveEnabled === false) return;
|
||||||
const status = $("#sieve-status");
|
const status = $("#sieve-status");
|
||||||
status.textContent = "Connecting…";
|
status.textContent = "Connecting…";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ function bindEvents() {
|
|||||||
$("#shortcuts-open").addEventListener("click", () => $("#shortcuts-dialog").showModal());
|
$("#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-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);
|
$("#account-revoke-others").addEventListener("click", revokeOtherSessions);
|
||||||
|
$("#reset-all-settings").addEventListener("click", resetAllSettings);
|
||||||
$("#add-account-form").addEventListener("submit", addBrowserAccount);
|
$("#add-account-form").addEventListener("submit", addBrowserAccount);
|
||||||
$("#mobile-folders").addEventListener("click", toggleFolderDrawer);
|
$("#mobile-folders").addEventListener("click", toggleFolderDrawer);
|
||||||
$("#folder-drawer-close").addEventListener("click", () => setFolderDrawerOpen(false));
|
$("#folder-drawer-close").addEventListener("click", () => setFolderDrawerOpen(false));
|
||||||
|
|||||||
Reference in new issue
Block a user