0.2.4
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-31 22:36:28 +01:00
1 parent 2c98b8f925
commit e4aa787e06
27 files changed
+179 -110

No files matched your search

+9
View File
@@ -1,5 +1,14 @@
# RustBox changelog # RustBox changelog
### 0.2.4
- Fixed S3-compatible storage over explicitly allowed plain HTTP endpoints by applying `storage.allow_http` to the final `ClientOptions` object instead of setting it before `with_client_options()` replaces those options. This fixes Garage/MinIO/Ceph-style internal HTTP endpoints without weakening the HTTPS default.
- Added an HTTP S3 regression test that verifies a request reaches an explicitly allowed local S3-compatible endpoint.
- Updated the pinned Rust toolchain/MSRV to Rust 1.99.1.
- Updated RustBox Docker build/runtime images from Debian 12 Bookworm to Debian 13 Trixie (`rust:1.99.1-trixie`, `debian:trixie-slim`) and switched the runtime OpenSSL package to Trixie's `libssl3t64`.
- Updated PostgreSQL 17 Compose images from Bookworm to the supported Trixie variant.
- No schema migration is required; runtime schema remains V047 and V048 remains the next migration number.
- Bumped the application/source release to 0.2.4.
### 0.2.3 ### 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. - 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 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.
Generated
+5 -7
View File
@@ -699,13 +699,11 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]] [[package]]
name = "crc-fast" name = "crc-fast"
version = "1.8.2" version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85d9be5297a59f1b7651fd2711a1f4461929f53b182b394df0df15b3a387ef51" checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5"
dependencies = [ dependencies = [
"crc",
"digest 0.10.7", "digest 0.10.7",
"rustversion",
"spin 0.10.1", "spin 0.10.1",
] ]
@@ -3508,7 +3506,7 @@ dependencies = [
[[package]] [[package]]
name = "rustbox" name = "rustbox"
version = "0.2.3" version = "0.2.4"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-imap", "async-imap",
@@ -4979,9 +4977,9 @@ checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.26.1" version = "1.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
dependencies = [ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"js-sys", "js-sys",
+2 -2
View File
@@ -1,8 +1,8 @@
[package] [package]
name = "rustbox" name = "rustbox"
version = "0.2.3" version = "0.2.4"
edition = "2024" edition = "2024"
rust-version = "1.88" rust-version = "1.99.1"
default-run = "rustbox" default-run = "rustbox"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
# RustBox attribution / notice # RustBox attribution / notice
RustBox 0.2.3 is derived from the Oxi webmail project: RustBox 0.2.4 is derived from the Oxi webmail project:
- Upstream: `c0h1b4/oxi` - Upstream: `c0h1b4/oxi`
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
+3 -3
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.3 # RustBox 0.2.4
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.3 ## Storage and search in 0.2.4
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.
@@ -224,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.3 release" git commit -m "RustBox 0.2.4 release"
./scripts/setup-upstream.sh ./scripts/setup-upstream.sh
``` ```
+10 -5
View File
@@ -1,6 +1,11 @@
RustBox 0.2.3 release manifest RustBox 0.2.4 release manifest
0.2.3 feature/configuration changes: 0.2.4 feature/configuration changes:
- fixes S3-compatible HTTP endpoints by carrying allow_http in the final object_store ClientOptions
- keeps HTTPS-only behavior as the default; plain HTTP still requires storage.allow_http=true
- adds a local HTTP S3 regression test for the request path
- pins Rust 1.99.1 and uses rust:1.99.1-trixie / debian:trixie-slim with libssl3t64
- moves PostgreSQL 17 Compose services to postgres:17-trixie
- optional Sieve, diagnostics and About feature switches - 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 - 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) - opt-in security response headers (disabled by default)
@@ -66,14 +71,14 @@ Docker examples:
- docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch) - docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch)
Operations/docs: Operations/docs:
- README.md documents 0.2.3 storage/search configuration - README.md documents 0.2.4 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.3 - Cargo.toml version = 0.2.4
- rust-toolchain.toml pins Rust 1.88.0 - rust-toolchain.toml pins Rust 1.99.1
- Cargo.lock is intentionally not shipped; the release gate regenerates it before locked checks - 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
+9 -5
View File
@@ -1,15 +1,19 @@
RustBox 0.2.3 - final source validation RustBox 0.2.4 - final source validation
0.2.3 scope: 0.2.4 scope:
- S3-compatible plain-HTTP client-options fix for explicitly allowed internal endpoints
- regression coverage for an HTTP S3-compatible endpoint
- Rust 1.99.1 toolchain/MSRV
- Debian Trixie RustBox images and PostgreSQL 17 Trixie Compose variants
- feature gates for Sieve/diagnostics/About and UI policy exposure - 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 - API fallback isolation: unknown/disabled `/api/*` routes return 404 and never fall through to the SPA UI
- reset-all-settings preserving account/mail data - reset-all-settings preserving account/mail data
- optional browser security headers - optional browser security headers
- Docker two-node application topology with Envoy/Valkey/OpenSearch/PostgreSQL/Garage - Docker two-node application topology with Envoy/Valkey/OpenSearch/PostgreSQL/Garage
- version metadata updated to 0.2.3 - version metadata updated to 0.2.4
Completed in this environment: Completed in this environment:
- offline release validator: scripts/validate-release-static.py (196/196 checks passed) - offline release validator: scripts/validate-release-static.py (207/207 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
@@ -35,7 +39,7 @@ Completed in this environment:
- 0.2.1 OpenPGP visibility, OpenSearch backpressure and HTTP/dev cookie fixes remain in place - 0.2.1 OpenPGP visibility, OpenSearch backpressure and HTTP/dev cookie fixes remain in place
Not executed in this packaging environment: Not executed in this packaging environment:
- cargo fmt/test/clippy/build with Rust 1.88, because Rust/Cargo is not installed here and outbound DNS is unavailable - cargo fmt/test/clippy/build with Rust 1.99.1, because Rust/Cargo is not installed in this validation environment
- live PostgreSQL/MySQL/S3/NFS/OpenSearch integration tests - live PostgreSQL/MySQL/S3/NFS/OpenSearch integration tests
The final release gate on a Rust host is: The final release gate on a Rust host is:
+1 -1
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.3 # RustBox 0.2.4
host = "127.0.0.1" host = "127.0.0.1"
port = 3001 port = 3001
environment = "production" environment = "production"
+3 -3
View File
@@ -1,11 +1,11 @@
FROM rust:1.88-bookworm AS builder FROM rust:1.99.1-trixie AS builder
WORKDIR /build WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/*
COPY . . COPY . .
RUN cargo generate-lockfile && cargo build --release --locked RUN cargo generate-lockfile && cargo build --release --locked
FROM debian:bookworm-slim FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3t64 && rm -rf /var/lib/apt/lists/*
RUN useradd --system --uid 10001 --create-home --home-dir /var/lib/rustbox rustbox RUN useradd --system --uid 10001 --create-home --home-dir /var/lib/rustbox rustbox
COPY --from=builder /build/target/release/rustbox /usr/local/bin/rustbox COPY --from=builder /build/target/release/rustbox /usr/local/bin/rustbox
USER rustbox USER rustbox
+9 -9
View File
@@ -10,8 +10,8 @@ Two RustBox nodes + Envoy + Valkey + PostgreSQL + OpenSearch + Garage S3:
```bash ```bash
PORT=3009 \ PORT=3009 \
IMAP_HOST=mail.linuxiarz.pl \ IMAP_HOST=mail.example.com \
SMTP_HOST=mail.linuxiarz.pl \ SMTP_HOST=mail.example.com \
docker compose -p rustbox-ha -f compose-ha-garage.yml up --build docker compose -p rustbox-ha -f compose-ha-garage.yml up --build
``` ```
@@ -26,16 +26,16 @@ PostgreSQL + S3/SILO + OpenSearch:
```bash ```bash
PORT=3009 \ PORT=3009 \
IMAP_HOST=mail.linuxiarz.pl \ IMAP_HOST=mail.example.com \
SMTP_HOST=mail.linuxiarz.pl \ SMTP_HOST=mail.example.com \
docker compose -p rustbox -f compose-postgres-s3-opensearch.yml up --build docker compose -p rustbox -f compose-postgres-s3-opensearch.yml up --build
``` ```
SQLite, local blobs and local Tantivy: SQLite, local blobs and local Tantivy:
```bash ```bash
IMAP_HOST=mail.linuxiarz.pl \ IMAP_HOST=mail.example.com \
SMTP_HOST=mail.linuxiarz.pl \ SMTP_HOST=mail.example.com \
docker compose -p rustbox -f compose-single-sqlite.yml up --build docker compose -p rustbox -f compose-single-sqlite.yml up --build
``` ```
@@ -44,8 +44,8 @@ MySQL + NFS + OpenSearch:
```bash ```bash
NFS_SERVER=10.0.0.10 \ NFS_SERVER=10.0.0.10 \
NFS_EXPORT=/exports/rustbox \ NFS_EXPORT=/exports/rustbox \
IMAP_HOST=mail.linuxiarz.pl \ IMAP_HOST=mail.example.com \
SMTP_HOST=mail.linuxiarz.pl \ SMTP_HOST=mail.example.com \
docker compose -p rustbox -f compose-mysql-nfs-opensearch.yml up --build docker compose -p rustbox -f compose-mysql-nfs-opensearch.yml up --build
``` ```
@@ -201,4 +201,4 @@ docker compose -p rustbox -f compose-postgres-s3-opensearch.yml exec opensearch
-d '{"index.blocks.read_only_allow_delete":null}' -d '{"index.blocks.read_only_allow_delete":null}'
``` ```
RustBox 0.2.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. RustBox 0.2.4 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.
+3 -3
View File
@@ -42,7 +42,7 @@ x-rustbox-common: &rustbox-common
services: services:
postgres: postgres:
image: postgres:17-bookworm image: postgres:17-trixie
environment: environment:
POSTGRES_DB: rustbox POSTGRES_DB: rustbox
POSTGRES_USER: rustbox POSTGRES_USER: rustbox
@@ -103,7 +103,7 @@ services:
- garage-data:/var/lib/garage/data - garage-data:/var/lib/garage/data
networks: networks:
rustbox-ha: rustbox-ha:
ipv4_address: 172.28.80.10 ipv4_address: 10.240.6.10
garage-init: garage-init:
build: build:
@@ -154,4 +154,4 @@ networks:
rustbox-ha: rustbox-ha:
ipam: ipam:
config: config:
- subnet: 172.28.80.0/24 - subnet: 10.240.6.0/24
+1 -1
View File
@@ -2,7 +2,7 @@ name: rustbox
services: services:
postgres: postgres:
image: postgres:17-bookworm image: postgres:17-trixie
environment: environment:
POSTGRES_DB: rustbox POSTGRES_DB: rustbox
POSTGRES_USER: rustbox POSTGRES_USER: rustbox
+1 -1
View File
@@ -10,7 +10,7 @@ sieve_enabled = false
diagnostics_enabled = true diagnostics_enabled = true
about_enabled = true about_enabled = true
security_headers_enabled = false security_headers_enabled = false
trusted_proxies = "172.28.80.0/24" trusted_proxies = "10.240.6.0/24"
[storage] [storage]
backend = "s3" backend = "s3"
+1 -1
View File
@@ -3,7 +3,7 @@ data_dir = "/var/lib/garage/data"
db_engine = "sqlite" db_engine = "sqlite"
replication_factor = 1 replication_factor = 1
rpc_bind_addr = "0.0.0.0:3901" rpc_bind_addr = "0.0.0.0:3901"
rpc_public_addr = "172.28.80.10:3901" rpc_public_addr = "10.240.6.10:3901"
rpc_secret = "8f2ec1b94f2ff5fc39391d8e1666929f8d6adc4f2270d5c950866a0a65e5f4b3" rpc_secret = "8f2ec1b94f2ff5fc39391d8e1666929f8d6adc4f2270d5c950866a0a65e5f4b3"
[s3_api] [s3_api]
+3 -3
View File
@@ -1,8 +1,8 @@
# RustBox 0.2.3 - implementation status # RustBox 0.2.4 - implementation status
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. This file tracks the `0.2.4` 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.3 storage/search architecture ## 0.2.4 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
View File
@@ -26,7 +26,7 @@ RUSTBOX_CONFIG=./config.dev.toml ./target/release/rustbox --check-config
Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source. Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source.
## Updating an existing checkout to 0.2.3 ## Updating an existing checkout to 0.2.4
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.
+3 -3
View File
@@ -1,6 +1,6 @@
# RustBox 0.2.3 storage/search operations # RustBox 0.2.4 storage/search operations
RustBox 0.2.3 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed. RustBox 0.2.4 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed.
## Deployment matrix ## Deployment matrix
@@ -83,4 +83,4 @@ Cross-node realtime event fan-out remains separate from blob/search storage and
## Validation status ## Validation status
The source package includes `scripts/validate-release-static.py` for offline structural checks. Full release validation still requires a Rust 1.88 build host and live service tests against the exact PostgreSQL/MySQL, S3/NFS and OpenSearch infrastructure used in production. Live SILO/MinIO/AWS/Ceph RGW and multi-node acceptance items remain intentionally unchecked in `TODO.md` until executed. The source package includes `scripts/validate-release-static.py` for offline structural checks. Full release validation still requires a Rust 1.99.1 build host and live service tests against the exact PostgreSQL/MySQL, S3/NFS and OpenSearch infrastructure used in production. Live SILO/MinIO/AWS/Ceph RGW and multi-node acceptance items remain intentionally unchecked in `TODO.md` until executed.
+2 -2
View File
@@ -1,6 +1,6 @@
# Database schemas and migrations # Database schemas and migrations
RustBox 0.2.3 keeps SQL, blob storage and search as independent concerns: RustBox 0.2.4 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.3 is **V048**. The next migration number after RustBox 0.2.4 is **V048**.
Redis and Redis Sentinel do not use SQL migrations. Redis and Redis Sentinel do not use SQL migrations.
+1 -1
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.3 - packaged production configuration (DEB/RPM) # RustBox 0.2.4 - 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.
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain] [toolchain]
channel = "1.88.0" channel = "1.99.1"
components = ["rustfmt", "clippy"] components = ["rustfmt", "clippy"]
+30 -8
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Static/offline release checks for RustBox 0.2.3. """Static/offline release checks for RustBox 0.2.4.
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,10 +49,10 @@ 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.3", "Cargo package version is 0.2.3") require(cargo["package"]["version"] == "0.2.4", "Cargo package version is 0.2.4")
require(cargo["package"]["rust-version"] == "1.88", "Cargo rust-version is 1.88") require(cargo["package"]["rust-version"] == "1.99.1", "Cargo rust-version is 1.99.1")
toolchain = tomllib.loads(read("rust-toolchain.toml")) toolchain = tomllib.loads(read("rust-toolchain.toml"))
require(toolchain["toolchain"]["channel"] == "1.88.0", "rust-toolchain pins Rust 1.88.0") require(toolchain["toolchain"]["channel"] == "1.99.1", "rust-toolchain pins Rust 1.99.1")
require(not (ROOT / "Cargo.lock").exists(), "stale Cargo.lock is not shipped") require(not (ROOT / "Cargo.lock").exists(), "stale Cargo.lock is not shipped")
require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script regenerates Cargo.lock") require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script regenerates Cargo.lock")
release_script = read("scripts/build-release.sh") release_script = read("scripts/build-release.sh")
@@ -65,15 +65,28 @@ 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.3"), "README identifies RustBox 0.2.3") require(read("README.md").startswith("# RustBox 0.2.4"), "README identifies RustBox 0.2.4")
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.3"), "release manifest identifies RustBox 0.2.3") require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.4"), "release manifest identifies RustBox 0.2.4")
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.3"), "release validation identifies RustBox 0.2.3") require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.4"), "release validation identifies RustBox 0.2.4")
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.3"), "implementation status identifies RustBox 0.2.3") require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.4"), "implementation status identifies RustBox 0.2.4")
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")
storage_rs = read("src/storage/mod.rs")
require(
".with_allow_http(storage.allow_http)" in storage_rs.split("let client_options = ClientOptions::new()", 1)[1].split("let retry =", 1)[0],
"S3 allow_http is applied to the final ClientOptions",
)
require(
".with_allow_http(storage.allow_http)" not in storage_rs.split("let mut builder = AmazonS3Builder::from_env()", 1)[1].split("if let Some(endpoint)", 1)[0],
"S3 builder does not overwrite allow_http with replacement client options",
)
require(
"s3_http_endpoint_works_when_explicitly_allowed" in storage_rs,
"S3 HTTP regression test is present",
)
config_rs = read("src/config.rs") config_rs = read("src/config.rs")
routes_mod = read("src/routes/mod.rs") routes_mod = read("src/routes/mod.rs")
require("pub sieve_enabled: bool" in config_rs and "pub diagnostics_enabled: bool" in config_rs and "pub about_enabled: bool" in config_rs, "configuration exposes Sieve, diagnostics and About feature gates") require("pub sieve_enabled: bool" in config_rs and "pub diagnostics_enabled: bool" in config_rs and "pub about_enabled: bool" in config_rs, "configuration exposes Sieve, diagnostics and About feature gates")
@@ -215,7 +228,16 @@ require(ha["cluster_mode"] is True and ha["session_backend"] == "redis", "HA/Gar
require(ha["redis_url"] == "redis://valkey:6379/", "HA/Garage config targets Valkey for shared sessions") require(ha["redis_url"] == "redis://valkey:6379/", "HA/Garage config targets Valkey for shared sessions")
require(ha["storage"]["backend"] == "s3" and ha["storage"]["endpoint"] == "http://garage:3900", "HA/Garage config stores blobs in Garage S3") require(ha["storage"]["backend"] == "s3" and ha["storage"]["endpoint"] == "http://garage:3900", "HA/Garage config stores blobs in Garage S3")
require(ha["search"]["backend"] == "opensearch", "HA/Garage config uses OpenSearch") require(ha["search"]["backend"] == "opensearch", "HA/Garage config uses OpenSearch")
dockerfile = read("docker/Dockerfile")
require("FROM rust:1.99.1-trixie AS builder" in dockerfile, "Docker builder pins Rust 1.99.1 on Debian Trixie")
require("FROM debian:trixie-slim" in dockerfile, "Docker runtime uses Debian Trixie slim")
require("libssl3t64" in dockerfile and "libssl3 " not in dockerfile, "Docker runtime uses the Debian Trixie OpenSSL runtime package")
require("bookworm" not in dockerfile.lower(), "RustBox Dockerfile no longer uses Debian Bookworm")
require("postgres:17-trixie" in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL/S3 Compose uses PostgreSQL 17 Trixie")
require("bookworm" not in read("docker/compose-postgres-s3-opensearch.yml").lower(), "PostgreSQL/S3 Compose no longer uses Bookworm")
ha_compose = read("docker/compose-ha-garage.yml") ha_compose = read("docker/compose-ha-garage.yml")
require("postgres:17-trixie" in ha_compose, "HA Compose uses PostgreSQL 17 Trixie")
require("bookworm" not in ha_compose.lower(), "HA Compose no longer uses Bookworm")
require("rustbox-1:" in ha_compose and "rustbox-2:" in ha_compose, "HA Compose defines two RustBox application nodes") 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:"]: for service in ["postgres:", "valkey:", "opensearch:", "garage:", "envoy:"]:
require(service in ha_compose, f"HA Compose includes {service.removesuffix(':')}") require(service in ha_compose, f"HA Compose includes {service.removesuffix(':')}")
-2
View File
@@ -49,8 +49,6 @@ pub fn provision_user_data_with_search(
.map_err(|e| format!("failed to create search directory: {e}"))?; .map_err(|e| format!("failed to create search directory: {e}"))?;
} }
// Ensure this account has its isolated tables in the configured RustBox database.
// The same database backend stores sessions unless session_backend=redis.
let conn = crate::db::pool::open_user_db(data_dir, user_hash) let conn = crate::db::pool::open_user_db(data_dir, user_hash)
.map_err(|e| format!("failed to initialize user database: {e}"))?; .map_err(|e| format!("failed to initialize user database: {e}"))?;
drop(conn); drop(conn);
+2 -35
View File
@@ -20,8 +20,6 @@ const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql");
const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql"); const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql");
const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql"); const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql");
// The squashed legacy baseline is V046. Runtime migrations start at V047 and
// are stored as real files for every supported SQL engine.
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[( const SQLITE_MIGRATIONS: &[(u32, &str)] = &[(
47, 47,
include_str!("../../migrations/sqlite/V047__blob_keys_and_search_outbox.sql"), include_str!("../../migrations/sqlite/V047__blob_keys_and_search_outbox.sql"),
@@ -65,7 +63,6 @@ const TENANT_TABLES: &[&str] = &[
"user_meta", "user_meta",
]; ];
// Parent tables precede children so legacy SQLite data can be copied with foreign keys enabled.
const IMPORT_TABLES: &[&str] = &[ const IMPORT_TABLES: &[&str] = &[
"calendar_events", "calendar_events",
"calendar_settings", "calendar_settings",
@@ -229,12 +226,6 @@ pub async fn configure(config: &AppConfig) -> Result<(), String> {
conn.execute_batch(&global_schema(SQLITE_SCHEMA)?) conn.execute_batch(&global_schema(SQLITE_SCHEMA)?)
.map_err(|e| format!("SQLite global schema initialization failed: {e}"))?; .map_err(|e| format!("SQLite global schema initialization failed: {e}"))?;
// Unit tests create many independent temporary data directories in one
// process. Pinning the first SQLite path in the process-wide OnceLock
// makes later tests reuse a TempDir that may already have been deleted,
// causing a cascade of "unable to open database file" failures.
// Initialize the requested test database, but let open_user_db() resolve
// each test's own data_dir instead of publishing this path globally.
#[cfg(test)] #[cfg(test)]
{ {
return Ok(()); return Ok(());
@@ -287,9 +278,6 @@ pub async fn configure(config: &AppConfig) -> Result<(), String> {
let schema = global_schema(PGSQL_SCHEMA)?; let schema = global_schema(PGSQL_SCHEMA)?;
let init_pool = pool.clone(); let init_pool = pool.clone();
runtime_wait(&runtime, async move { runtime_wait(&runtime, async move {
// PostgreSQL emits NOTICE for every CREATE ... IF NOT EXISTS that
// already exists. Keep expected idempotent startup DDL quiet without
// changing application-wide logging or server settings.
let mut tx = init_pool let mut tx = init_pool
.begin() .begin()
.await .await
@@ -298,11 +286,6 @@ pub async fn configure(config: &AppConfig) -> Result<(), String> {
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| format!("failed to quiet PostgreSQL schema notices: {e}"))?; .map_err(|e| format!("failed to quiet PostgreSQL schema notices: {e}"))?;
// `raw_sql(...).execute(&mut PgConnection)` can trigger SQLx's
// "Executor is not general enough" lifetime error when this
// future is spawned onto the dedicated database runtime. Execute
// the unprepared multi-statement DDL directly through `Executor`
// instead; this is SQLx's supported path for raw DDL batches.
(&mut *tx) (&mut *tx)
.execute(schema.as_str()) .execute(schema.as_str())
.await .await
@@ -460,7 +443,6 @@ pub trait ToDbValue {
} }
} }
// Kept explicit rather than clever: the macro above intentionally stays simple for rustfmt/parser compatibility.
impl ToDbValue for i64 { impl ToDbValue for i64 {
fn to_db_value(&self) -> DbValue { fn to_db_value(&self) -> DbValue {
DbValue::I64(Some(*self)) DbValue::I64(Some(*self))
@@ -601,10 +583,6 @@ impl IntoDbParams for [(); 0] {
} }
} }
// Do not use a const-generic array implementation here. For an empty array (`[]`)
// Rust cannot infer the element type and sees both `[(); 0]` and `[T; 0]` as valid
// `IntoDbParams` candidates. Implement only non-empty array lengths explicitly so
// legacy/rusqlite-style `[]` call sites remain unambiguous.
macro_rules! impl_into_db_params_array { macro_rules! impl_into_db_params_array {
($($n:expr),+ $(,)?) => { ($($n:expr),+ $(,)?) => {
$( $(
@@ -1457,8 +1435,6 @@ fn adapt_sql_params(
return (sql, params); return (sql, params);
} }
// Dynamic query builders in the application use SQLite-style ?N placeholders.
// Normalize them here so the same higher-level logic can target all engines.
let chars: Vec<char> = sql.chars().collect(); let chars: Vec<char> = sql.chars().collect();
let mut output = String::with_capacity(sql.len() + 16); let mut output = String::with_capacity(sql.len() + 16);
let mut reordered = Vec::new(); let mut reordered = Vec::new();
@@ -1522,8 +1498,6 @@ fn adapt_sql_params(
sql = output; sql = output;
if backend == DatabaseBackend::Mysql && !reordered.is_empty() { if backend == DatabaseBackend::Mysql && !reordered.is_empty() {
// If numbered placeholders were expanded, bind in occurrence order.
// Plain MySQL catalog queries already use '?' and are handled the same way.
return (adapt_dialect_sql(backend, sql), reordered); return (adapt_dialect_sql(backend, sql), reordered);
} }
(adapt_dialect_sql(backend, sql), params) (adapt_dialect_sql(backend, sql), params)
@@ -1855,9 +1829,6 @@ fn tenant_prefix(user_hash: &str) -> Result<String, String> {
return Err("empty user hash for database isolation".to_string()); return Err("empty user hash for database isolation".to_string());
} }
// Production user hashes are SHA-256 hex strings. Tests and legacy callers may
// use symbolic identifiers; hash those first so table prefixes remain safe,
// deterministic and restricted to ASCII hex without weakening isolation.
let normalized = if user_hash.len() >= 24 && user_hash.chars().all(|c| c.is_ascii_hexdigit()) { let normalized = if user_hash.len() >= 24 && user_hash.chars().all(|c| c.is_ascii_hexdigit()) {
user_hash.to_ascii_lowercase() user_hash.to_ascii_lowercase()
} else { } else {
@@ -2060,8 +2031,6 @@ fn ensure_server_tenant(
let tenant = tenant_schema(MYSQL_SCHEMA, &prefix)?; let tenant = tenant_schema(MYSQL_SCHEMA, &prefix)?;
let legacy_path = legacy_path.clone(); let legacy_path = legacy_path.clone();
runtime_wait(runtime, async move { runtime_wait(runtime, async move {
// Serialize first-time provisioning per account across cluster nodes.
// MySQL named locks are session-scoped and are released automatically if the connection dies.
let lock_name = format!("rustbox:{prefix}"); let lock_name = format!("rustbox:{prefix}");
let mut lock_conn = pool let mut lock_conn = pool
.acquire() .acquire()
@@ -2210,10 +2179,8 @@ fn ensure_server_tenant(
ConfiguredDatabase::Sqlite { .. } => false, ConfiguredDatabase::Sqlite { .. } => false,
}; };
if imported_legacy { if imported_legacy && let Some(path) = legacy_path.as_ref() {
if let Some(path) = legacy_path.as_ref() { backup_legacy_sqlite(path);
backup_legacy_sqlite(path);
}
} }
Ok(()) Ok(())
} }
-3
View File
@@ -1,6 +1,3 @@
// Query catalogs intentionally expose parallel SQL dialects. Not every catalog
// is used in a SQLite-only process, so dead-code suppression is scoped to the
// dialect modules instead of the rest of the crate.
#[allow(dead_code)] #[allow(dead_code)]
pub mod mysql; pub mod mysql;
#[allow(dead_code)] #[allow(dead_code)]
-2
View File
@@ -194,8 +194,6 @@ pub async fn delete_attachment(
return Err(AppError::NotFound("Attachment not found".to_string())); return Err(AppError::NotFound("Attachment not found".to_string()));
} }
if let Err(error) = storage.delete(&storage_key).await { if let Err(error) = storage.delete(&storage_key).await {
// SQL is authoritative. A failed physical delete leaves an orphaned blob,
// which is safer than leaving a DB row pointing at a missing attachment.
tracing::warn!(error = %error, storage_key = %storage_key, "Failed to remove deleted attachment blob"); tracing::warn!(error = %error, storage_key = %storage_key, "Failed to remove deleted attachment blob");
} }
+68 -2
View File
@@ -223,6 +223,7 @@ fn s3_storage(storage: &StorageConfig) -> Result<Arc<dyn BlobStorage>, String> {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.ok_or_else(|| "storage.bucket is required for S3".to_string())?; .ok_or_else(|| "storage.bucket is required for S3".to_string())?;
let client_options = ClientOptions::new() let client_options = ClientOptions::new()
.with_allow_http(storage.allow_http)
.with_connect_timeout(Duration::from_secs(storage.connect_timeout_secs)) .with_connect_timeout(Duration::from_secs(storage.connect_timeout_secs))
.with_timeout(Duration::from_secs(storage.request_timeout_secs)); .with_timeout(Duration::from_secs(storage.request_timeout_secs));
let retry = RetryConfig { let retry = RetryConfig {
@@ -234,7 +235,6 @@ fn s3_storage(storage: &StorageConfig) -> Result<Arc<dyn BlobStorage>, String> {
.with_bucket_name(bucket) .with_bucket_name(bucket)
.with_region(storage.region.trim()) .with_region(storage.region.trim())
.with_virtual_hosted_style_request(!storage.path_style) .with_virtual_hosted_style_request(!storage.path_style)
.with_allow_http(storage.allow_http)
.with_client_options(client_options) .with_client_options(client_options)
.with_retry(retry) .with_retry(retry)
.with_checksum_algorithm(Checksum::SHA256); .with_checksum_algorithm(Checksum::SHA256);
@@ -470,7 +470,6 @@ pub async fn migrate_attachments(
) { ) {
Ok(path) => path, Ok(path) => path,
Err(error) => { Err(error) => {
// A logical DB key may already point at the configured destination after a previous run.
if attachment.storage_key == target_key if attachment.storage_key == target_key
&& destination.exists(&target_key).await? && destination.exists(&target_key).await?
{ {
@@ -583,6 +582,13 @@ pub async fn migrate_attachments(
mod tests { mod tests {
use super::*; use super::*;
use crate::config::AppConfig; use crate::config::AppConfig;
use axum::{
Router,
body::Body,
extract::State,
http::{Response, StatusCode, header},
};
use std::sync::atomic::{AtomicUsize, Ordering};
use tempfile::TempDir; use tempfile::TempDir;
#[tokio::test] #[tokio::test]
@@ -621,6 +627,66 @@ mod tests {
assert!(!storage.exists(&key).await.expect("missing")); assert!(!storage.exists(&key).await.expect("missing"));
} }
async fn s3_http_probe(State(hits): State<Arc<AtomicUsize>>) -> Response<Body> {
hits.fetch_add(1, Ordering::SeqCst);
let mut response = Response::new(Body::empty());
*response.status_mut() = StatusCode::OK;
response
.headers_mut()
.insert(header::CONTENT_LENGTH, "0".parse().expect("content length"));
response.headers_mut().insert(
header::LAST_MODIFIED,
"Sat, 31 Oct 2026 20:00:00 GMT"
.parse()
.expect("last modified"),
);
response
.headers_mut()
.insert(header::ETAG, "\"rustbox-test\"".parse().expect("etag"));
response
}
#[tokio::test]
async fn s3_http_endpoint_works_when_explicitly_allowed() {
let hits = Arc::new(AtomicUsize::new(0));
let app = Router::new()
.fallback(s3_http_probe)
.with_state(hits.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test S3 endpoint");
let endpoint = format!(
"http://{}",
listener.local_addr().expect("test S3 endpoint address")
);
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("serve test S3 endpoint");
});
let storage = StorageConfig {
backend: "s3".to_string(),
bucket: Some("rustbox-test".to_string()),
region: "us-east-1".to_string(),
endpoint: Some(endpoint),
path_style: true,
allow_http: true,
access_key_id: Some("test-access-key".to_string()),
secret_access_key: Some("test-secret-key".to_string()),
max_retries: 0,
request_timeout_secs: 2,
connect_timeout_secs: 2,
retry_timeout_secs: 2,
..StorageConfig::default()
};
let backend = s3_storage(&storage).expect("S3 backend with HTTP enabled");
assert!(backend.exists("probe").await.expect("HTTP S3 HEAD request"));
assert_eq!(hits.load(Ordering::SeqCst), 1);
server.abort();
}
#[test] #[test]
fn attachment_keys_are_logical_and_path_safe() { fn attachment_keys_are_logical_and_path_safe() {
assert_eq!( assert_eq!(
+10 -5
View File
@@ -567,7 +567,8 @@
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><button id="shortcuts-open" class="button quiet settings-shortcuts" type="button">Keyboard shortcuts</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">
@@ -943,7 +944,8 @@
</div><button id="identity-new" class="button primary" type="button">New identity</button> </div><button id="identity-new" class="button primary" type="button">New identity</button>
</div> </div>
<div class="identity-overview"> <div class="identity-overview">
<div class="identity-overview-hint"><strong>Configured identities</strong><span id="identity-overview-help" class="muted">Select an <div class="identity-overview-hint"><strong>Configured identities</strong><span
id="identity-overview-help" class="muted">Select an
identity to edit its sender data, text/HTML footer, image and OpenPGP defaults.</span></div> identity to edit its sender data, text/HTML footer, image and OpenPGP defaults.</span></div>
<div id="identity-list" class="identity-list"></div> <div id="identity-list" class="identity-list"></div>
</div> </div>
@@ -1165,8 +1167,10 @@
<div class="settings-panel-heading account-session-heading"> <div class="settings-panel-heading account-session-heading">
<div> <div>
<h3>Reset settings</h3> <h3>Reset settings</h3>
<p class="muted">Restore appearance, mailbox, notifications, folder, calendar and OpenPGP preferences. Messages, identities, keys and templates are kept.</p> <p class="muted">Restore appearance, mailbox, notifications, folder, calendar and OpenPGP preferences.
</div><button id="reset-all-settings" class="button quiet danger" type="button">Reset all settings</button> 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> </div>
</section> </section>
<section class="settings-panel hidden" data-panel="about"> <section class="settings-panel hidden" data-panel="about">
@@ -1253,7 +1257,8 @@
</div> </div>
</section> </section>
<section class="identity-modal-section identity-modal-options"> <section class="identity-modal-section identity-modal-options">
<div class="identity-modal-heading"><strong>Defaults</strong><span id="identity-defaults-help" class="muted">Choose where this signature <div class="identity-modal-heading"><strong>Defaults</strong><span id="identity-defaults-help"
class="muted">Choose where this signature
is inserted and, when OpenPGP is configured, its default behavior.</span></div> is inserted and, when OpenPGP is configured, its default behavior.</span></div>
<div class="identity-toggle-grid"> <div class="identity-toggle-grid">
<label class="checkbox-row"><input id="identity-signature-new" type="checkbox"><span>Signature on new <label class="checkbox-row"><input id="identity-signature-new" type="checkbox"><span>Signature on new