1 parent
a116890e20
commit
02d0b13ec1
24 files changed
+245
-93
No files matched your search
@@ -1,5 +1,16 @@
|
|||||||
# RustBox changelog
|
# RustBox changelog
|
||||||
|
|
||||||
|
### 0.2.10
|
||||||
|
|
||||||
|
- Message-body links without an explicit `target` now open in a new browser tab/window instead of navigating inside the sandboxed mail preview iframe.
|
||||||
|
- Explicit link targets from the message are preserved; links that open a new tab/window receive `rel="noopener noreferrer"`.
|
||||||
|
- Authentication success toast now appears only after a real login request; restoring an existing session after page refresh stays silent.
|
||||||
|
- Improved iPhone/Safari mail rendering by explicitly declaring UTF-8 in message preview documents and using broader RFC 2047 header decoding (including ISO-8859-2/Windows-1250) for non-ASCII subjects/sender names.
|
||||||
|
- Changed PDF attachment preview to use the authenticated inline attachment URL instead of a `blob:` iframe, improving Safari/iPhone compatibility.
|
||||||
|
- Simplified Composing size-limit information to one compact line with only the attachment and complete-message limits.
|
||||||
|
- No schema migration is required; V049 remains the next migration number.
|
||||||
|
- Bumped the application/source release to 0.2.10.
|
||||||
|
|
||||||
### 0.2.9
|
### 0.2.9
|
||||||
|
|
||||||
- Made realtime new-mail notifications less aggressive: startup synchronization is silent, notifications are armed only after the application is open, and bulk move/copy/archive/junk/delete destinations suppress self-generated `NewMessages` toasts while the bulk summary toast remains.
|
- Made realtime new-mail notifications less aggressive: startup synchronization is silent, notifications are armed only after the application is open, and bulk move/copy/archive/junk/delete destinations suppress self-generated `NewMessages` toasts while the bulk summary toast remains.
|
||||||
|
|||||||
Generated
+2
-1
@@ -3506,7 +3506,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.9"
|
version = "0.2.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-imap",
|
"async-imap",
|
||||||
@@ -3517,6 +3517,7 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
"encoding_rs",
|
||||||
"figment",
|
"figment",
|
||||||
"futures",
|
"futures",
|
||||||
"governor",
|
"governor",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.9"
|
version = "0.2.10"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.99.1"
|
rust-version = "1.99.1"
|
||||||
default-run = "rustbox"
|
default-run = "rustbox"
|
||||||
@@ -28,6 +28,7 @@ rusqlite = { version = "0.34", features = ["bundled"] }
|
|||||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
mail-parser = "0.11.2"
|
mail-parser = "0.11.2"
|
||||||
|
encoding_rs = "0.8"
|
||||||
futures = "0.3.32"
|
futures = "0.3.32"
|
||||||
lettre = { version = "0.11", default-features = false, features = ["tokio1-native-tls", "smtp-transport", "builder"] }
|
lettre = { version = "0.11", default-features = false, features = ["tokio1-native-tls", "smtp-transport", "builder"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# RustBox attribution / notice
|
# RustBox attribution / notice
|
||||||
|
|
||||||
RustBox 0.2.9 is derived from the Oxi webmail project:
|
RustBox 0.2.10 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.9
|
# RustBox 0.2.10
|
||||||
|
|
||||||
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.9
|
## Storage and search in 0.2.10
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ Initialize Git after extracting the release archive:
|
|||||||
```bash
|
```bash
|
||||||
git init
|
git init
|
||||||
git add .
|
git add .
|
||||||
git commit -m "RustBox 0.2.9 release"
|
git commit -m "RustBox 0.2.10 release"
|
||||||
./scripts/setup-upstream.sh
|
./scripts/setup-upstream.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -1,11 +1,12 @@
|
|||||||
RustBox 0.2.9 release manifest
|
RustBox 0.2.10 release manifest
|
||||||
|
|
||||||
0.2.9 feature/configuration changes:
|
0.2.10 feature/configuration changes:
|
||||||
- suppresses startup new-mail toasts and arms realtime mail notifications only after authenticated startup is complete and the WebSocket has been open for a short grace period
|
- opens message-body links without an explicit target in a new tab/window instead of navigating inside the sandboxed mail-preview iframe; explicit targets remain intact
|
||||||
- suppresses self-generated destination-folder NewMessages notifications during bulk move/copy/archive/junk/delete operations while retaining the bulk summary toast
|
- shows the login success toast only after a real POST login; session restoration/page refresh is silent
|
||||||
- shows a success toast after authenticated startup/login
|
- improves non-ASCII/Polish mail display with explicit UTF-8 preview documents and broader RFC 2047 header decoding (including ISO-8859-2/Windows-1250)
|
||||||
- preserves browser/system Ctrl/Cmd shortcuts by limiting RustBox single-key shortcuts to unmodified keys; compose remains available on C
|
- previews PDFs from the authenticated inline attachment endpoint instead of a blob iframe for Safari/iPhone compatibility
|
||||||
- defaults mailbox pages/preferences to 300 messages and raises the supported per-page API/preference limit to 300
|
- presents compose attachment/message size limits as compact raw values without implementation-detail descriptions
|
||||||
|
- retains the 0.2.9 realtime notification, shortcut and 300-message mailbox behavior
|
||||||
- requires no runtime schema migration; V049 remains next
|
- requires no runtime schema migration; V049 remains next
|
||||||
Architecture:
|
Architecture:
|
||||||
- database_backend = sqlite | mysql | pgsql selects authoritative SQL only
|
- database_backend = sqlite | mysql | pgsql selects authoritative SQL only
|
||||||
@@ -42,14 +43,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.9 configuration
|
- README.md documents 0.2.10 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.9
|
- Cargo.toml version = 0.2.10
|
||||||
- rust-toolchain.toml pins Rust 1.99.1
|
- rust-toolchain.toml pins Rust 1.99.1
|
||||||
- Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing
|
- Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing
|
||||||
- CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build
|
- CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build
|
||||||
+12
-10
@@ -1,21 +1,23 @@
|
|||||||
RustBox 0.2.9 - final source validation
|
RustBox 0.2.10 - final source validation
|
||||||
|
|
||||||
0.2.9 scope:
|
0.2.10 scope:
|
||||||
- startup synchronization does not emit new-mail toasts; realtime mail notifications are armed after application startup
|
- message-body links without an explicit target open outside the sandboxed preview in a new tab/window, while explicit targets are preserved
|
||||||
- bulk move/copy/archive/junk/delete actions suppress self-generated destination-folder new-mail notifications and retain one summary toast
|
- page refresh/session restoration does not emit a false “Successfully logged in” toast
|
||||||
- authenticated startup/login shows a success toast
|
- real login still emits the success toast
|
||||||
- browser/system Ctrl/Cmd shortcuts are not intercepted by RustBox single-key mail shortcuts
|
- message preview documents explicitly use UTF-8 and RFC 2047 headers support UTF-8, ISO-8859-2 and Windows-1250 decoding
|
||||||
- mailbox pages default to 300 messages and accept up to 300 per page
|
- PDF attachment preview uses the authenticated inline URL for Safari/iPhone compatibility
|
||||||
|
- Composing shows only compact attachment/message size-limit values on one line
|
||||||
|
- 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact
|
||||||
- no runtime schema migration is added; V049 remains next
|
- no runtime schema migration is added; V049 remains next
|
||||||
- version metadata updated to 0.2.9
|
- version metadata updated to 0.2.10
|
||||||
|
|
||||||
Completed in this environment:
|
Completed in this environment:
|
||||||
- offline release validator: scripts/validate-release-static.py (340/340 checks passed)
|
- offline release validator: scripts/validate-release-static.py (351/351 checks passed)
|
||||||
- browser JavaScript assembled in manifest order passes `node --check`
|
- browser JavaScript assembled in manifest order passes `node --check`
|
||||||
- all JSON files parse successfully
|
- all JSON files parse successfully
|
||||||
- Python helper scripts compile successfully
|
- Python helper scripts compile successfully
|
||||||
- shell scripts pass `bash -n`
|
- shell scripts pass `bash -n`
|
||||||
- release metadata consistently identifies 0.2.9
|
- release metadata consistently identifies 0.2.10
|
||||||
- V048 migration files/catalog registration and SQLite upgrade execution are validated offline
|
- V048 migration files/catalog registration and SQLite upgrade execution are validated offline
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.2.9
|
# RustBox 0.2.10
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
port = 3001
|
port = 3001
|
||||||
environment = "production"
|
environment = "production"
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
# RustBox 0.2.9 - implementation status
|
# RustBox 0.2.10 - implementation status
|
||||||
|
|
||||||
This file tracks the `0.2.9` 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.10` 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.9 browser mail UX
|
## 0.2.10 browser mail UX
|
||||||
|
|
||||||
- Initial synchronization no longer emits new-mail toasts. Realtime notifications are armed after the authenticated application is open.
|
- Message-body links without an explicit `target` default to `target="_blank"` so external navigation opens outside the sandboxed mail-preview iframe; explicit targets are preserved and `_blank` links receive `noopener noreferrer`.
|
||||||
- Bulk move/copy/archive/junk/delete operations suppress destination-folder `NewMessages` notifications generated by the operation itself and keep the single bulk summary toast.
|
|
||||||
- Browser/system modifier shortcuts are preserved; RustBox mail shortcuts use unmodified single keys.
|
- Login success is shown only after a real login request; refreshing/restoring an existing authenticated session is silent.
|
||||||
|
- Message/HTML preview documents explicitly declare UTF-8, and encoded-word header decoding delegates first to `mail-parser` for broader charset coverage, including UTF-8, ISO-8859-2 and Windows-1250 subjects and display names.
|
||||||
|
- PDF attachments are previewed from the authenticated inline attachment URL instead of a `blob:` iframe, improving Safari/iPhone compatibility.
|
||||||
|
- Composing displays attachment/message size limits as one compact line without configuration/MIME implementation details.
|
||||||
|
- Initial synchronization remains silent for new-mail notifications; bulk operations retain one summary toast; browser/system modifier shortcuts remain preserved.
|
||||||
- Mailbox pages default to 300 messages and the API/preferences accept values up to 300.
|
- Mailbox pages default to 300 messages and the API/preferences accept values up to 300.
|
||||||
- No runtime schema migration is added; V049 remains next.
|
- No runtime schema migration is added; V049 remains next.
|
||||||
|
|
||||||
@@ -15,7 +19,7 @@ This file tracks the `0.2.9` source snapshot. Storage/search scope and outstandi
|
|||||||
- `security_headers_enabled` remains opt-in and disabled by default for compatibility; optional browser-security headers are centralized in this middleware, so disabling it removes the RustBox-added security profile while normal response semantics such as `Content-Type` and route-specific caching remain.
|
- `security_headers_enabled` remains opt-in and disabled by default for compatibility; optional browser-security headers are centralized in this middleware, so disabling it removes the RustBox-added security profile while normal response semantics such as `Content-Type` and route-specific caching remain.
|
||||||
- HTML/XHTML documents receive the full document profile: CSP, `X-Frame-Options: DENY`, `Permissions-Policy`, `Cross-Origin-Opener-Policy: same-origin`, `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`.
|
- HTML/XHTML documents receive the full document profile: CSP, `X-Frame-Options: DENY`, `Permissions-Policy`, `Cross-Origin-Opener-Policy: same-origin`, `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`.
|
||||||
- The HTML CSP allows only same-origin scripts (`script-src 'self'`) and disables inline script attributes; the theme bootstrap is an external fingerprinted asset. Stylesheets are same-origin only (`style-src 'self'`), while `style-src-attr 'unsafe-inline'` is narrowly retained because the current UI still sets per-element style attributes dynamically.
|
- The HTML CSP allows only same-origin scripts (`script-src 'self'`) and disables inline script attributes; the theme bootstrap is an external fingerprinted asset. Stylesheets are same-origin only (`style-src 'self'`), while `style-src-attr 'unsafe-inline'` is narrowly retained because the current UI still sets per-element style attributes dynamically.
|
||||||
- Private `/api` responses receive `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `Content-Security-Policy: frame-ancestors 'none'` and legacy-compatible `X-Frame-Options: DENY`. API JSON does not receive the full document CSP, `Permissions-Policy`, COOP or `Referrer-Policy`.
|
- Private `/api` responses receive `Cache-Control: no-store`, `X-Content-Type-Options: nosniff` and `Cross-Origin-Resource-Policy: same-origin`. API responses are normally non-frameable (`frame-ancestors 'none'` / `X-Frame-Options: DENY`); `application/pdf` is the narrow exception and allows same-origin framing for the attachment preview (`frame-ancestors 'self'` / `SAMEORIGIN`). API JSON does not receive the full document CSP, `Permissions-Policy`, COOP or `Referrer-Policy`.
|
||||||
- CSS receives `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`; application assets also receive same-origin CORP without redundant HTML document policies.
|
- CSS receives `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`; application assets also receive same-origin CORP without redundant HTML document policies.
|
||||||
- HSTS is deliberately not synthesized by RustBox because the application may sit behind a TLS-terminating reverse proxy and cannot reliably determine the public HTTPS boundary; HSTS belongs at that HTTPS edge.
|
- HSTS is deliberately not synthesized by RustBox because the application may sit behind a TLS-terminating reverse proxy and cannot reliably determine the public HTTPS boundary; HSTS belongs at that HTTPS edge.
|
||||||
- The public `/api/auth/policy` is data-minimized to the single login-form capability `rememberMeEnabled`. Optional feature availability (OpenPGP, Sieve, diagnostics, About) is returned only from authenticated `/api/auth/session-policy`; session/remember durations are not exposed to the browser policy endpoints.
|
- The public `/api/auth/policy` is data-minimized to the single login-form capability `rememberMeEnabled`. Optional feature availability (OpenPGP, Sieve, diagnostics, About) is returned only from authenticated `/api/auth/session-policy`; session/remember durations are not exposed to the browser policy endpoints.
|
||||||
|
|||||||
+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.9
|
## Updating an existing checkout to 0.2.10
|
||||||
|
|
||||||
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.9 storage/search operations
|
# RustBox 0.2.10 storage/search operations
|
||||||
|
|
||||||
RustBox 0.2.9 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed.
|
RustBox 0.2.10 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.9 keeps SQL, blob storage and search as independent concerns:
|
RustBox 0.2.10 keeps SQL, blob storage and search as independent concerns:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
database_backend = sqlite | mysql | pgsql
|
database_backend = sqlite | mysql | pgsql
|
||||||
@@ -37,7 +37,7 @@ Account data is isolated inside the selected physical database with a determinis
|
|||||||
|
|
||||||
Every schema-changing release must add the same logical migration for all supported SQL engines and register it in `src/db/pool.rs`. Do not update only `schema.sql`: fresh installs use the baseline while existing installations use the numbered migration path.
|
Every schema-changing release must add the same logical migration for all supported SQL engines and register it in `src/db/pool.rs`. Do not update only `schema.sql`: fresh installs use the baseline while existing installations use the numbered migration path.
|
||||||
|
|
||||||
RustBox 0.2.9 does not add a runtime schema migration; **V049** remains the next migration number.
|
RustBox 0.2.10 does not add a runtime schema migration; **V049** remains the next migration number.
|
||||||
|
|
||||||
Redis and Redis Sentinel do not use SQL migrations.
|
Redis and Redis Sentinel do not use SQL migrations.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.2.9 - packaged production configuration (DEB/RPM)
|
# RustBox 0.2.10 - 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,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Static/offline release checks for RustBox 0.2.9.
|
"""Static/offline release checks for RustBox 0.2.10.
|
||||||
|
|
||||||
These checks intentionally avoid pretending to replace `cargo test`: they validate
|
These checks intentionally avoid pretending to replace `cargo test`: they validate
|
||||||
configuration, SQL migrations, Docker examples, query-catalog parity and release
|
configuration, SQL migrations, Docker examples, query-catalog parity and release
|
||||||
@@ -49,14 +49,16 @@ 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.9", "Cargo package version is 0.2.9")
|
require(cargo["package"]["version"] == "0.2.10", "Cargo package version is 0.2.10")
|
||||||
require(cargo["package"]["rust-version"] == "1.99.1", "Cargo rust-version is 1.99.1")
|
require(cargo["package"]["rust-version"] == "1.99.1", "Cargo rust-version is 1.99.1")
|
||||||
toolchain = tomllib.loads(read("rust-toolchain.toml"))
|
toolchain = tomllib.loads(read("rust-toolchain.toml"))
|
||||||
require(toolchain["toolchain"]["channel"] == "1.99.1", "rust-toolchain pins Rust 1.99.1")
|
require(toolchain["toolchain"]["channel"] == "1.99.1", "rust-toolchain pins Rust 1.99.1")
|
||||||
require((ROOT / "Cargo.lock").is_file(), "Cargo.lock is retained for reproducible application builds")
|
require((ROOT / "Cargo.lock").is_file(), "Cargo.lock is retained for reproducible application builds")
|
||||||
lock = tomllib.loads(read("Cargo.lock"))
|
lock = tomllib.loads(read("Cargo.lock"))
|
||||||
lock_rustbox = next((pkg for pkg in lock.get("package", []) if pkg.get("name") == "rustbox"), {})
|
lock_rustbox = next((pkg for pkg in lock.get("package", []) if pkg.get("name") == "rustbox"), {})
|
||||||
require(lock_rustbox.get("version") == "0.2.9", "Cargo.lock RustBox package version is 0.2.9")
|
require(lock_rustbox.get("version") == "0.2.10", "Cargo.lock RustBox package version is 0.2.10")
|
||||||
|
require(cargo["dependencies"].get("encoding_rs") == "0.8", "direct charset decoder dependency is pinned")
|
||||||
|
require("encoding_rs" in lock_rustbox.get("dependencies", []), "Cargo.lock records direct charset decoder dependency")
|
||||||
require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script generates Cargo.lock when missing")
|
require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script generates Cargo.lock when missing")
|
||||||
release_script = read("scripts/build-release.sh")
|
release_script = read("scripts/build-release.sh")
|
||||||
release_script_flat = " ".join(release_script.replace("\\", " ").split())
|
release_script_flat = " ".join(release_script.replace("\\", " ").split())
|
||||||
@@ -69,14 +71,16 @@ 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.9"), "README identifies RustBox 0.2.9")
|
require(read("README.md").startswith("# RustBox 0.2.10"), "README identifies RustBox 0.2.10")
|
||||||
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.9"), "release manifest identifies RustBox 0.2.9")
|
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.10"), "release manifest identifies RustBox 0.2.10")
|
||||||
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.9"), "release validation identifies RustBox 0.2.9")
|
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.10"), "release validation identifies RustBox 0.2.10")
|
||||||
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.9"), "implementation status identifies RustBox 0.2.9")
|
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.10"), "implementation status identifies RustBox 0.2.10")
|
||||||
bootstrap_js = read("src/web/js/00-bootstrap.js")
|
bootstrap_js = read("src/web/js/00-bootstrap.js")
|
||||||
bindings_js = read("src/web/js/95-bindings-boot.js")
|
bindings_js = read("src/web/js/95-bindings-boot.js")
|
||||||
realtime_js = read("src/web/js/80-settings-realtime.js")
|
realtime_js = read("src/web/js/80-settings-realtime.js")
|
||||||
mail_actions_js = read("src/web/js/60-mail-actions-search.js")
|
mail_actions_js = read("src/web/js/60-mail-actions-search.js")
|
||||||
|
auth_js = read("src/web/js/20-preferences-auth.js")
|
||||||
|
reader_js = read("src/web/js/40-messages-reader.js")
|
||||||
require("perPage: 300" in bootstrap_js and "messages_per_page: 300" in bootstrap_js, "browser mailbox default is 300 messages")
|
require("perPage: 300" in bootstrap_js and "messages_per_page: 300" in bootstrap_js, "browser mailbox default is 300 messages")
|
||||||
require(re.search(r"default_per_page\(\)\s*->\s*u32\s*\{\s*300\s*\}", read("src/routes/messages/types.rs")) is not None, "folder message API default page size is 300")
|
require(re.search(r"default_per_page\(\)\s*->\s*u32\s*\{\s*300\s*\}", read("src/routes/messages/types.rs")) is not None, "folder message API default page size is 300")
|
||||||
require('1..=300' in read("src/routes/messages/mod.rs"), "folder message API accepts up to 300 messages per page")
|
require('1..=300' in read("src/routes/messages/mod.rs"), "folder message API accepts up to 300 messages per page")
|
||||||
@@ -84,6 +88,15 @@ require('20..=300' in read("src/db/mail_preferences.rs"), "mailbox preference ac
|
|||||||
require('const appShortcut = keyboardIdle && !event.ctrlKey && !event.metaKey && !event.altKey;' in bindings_js, "single-key shortcuts preserve Ctrl/Cmd/Alt browser shortcuts")
|
require('const appShortcut = keyboardIdle && !event.ctrlKey && !event.metaKey && !event.altKey;' in bindings_js, "single-key shortcuts preserve Ctrl/Cmd/Alt browser shortcuts")
|
||||||
require('realtimeNotificationsReady' in realtime_js and 'suppressRealtimeNotificationsForFolder' in realtime_js, "realtime new-mail notification gating is present")
|
require('realtimeNotificationsReady' in realtime_js and 'suppressRealtimeNotificationsForFolder' in realtime_js, "realtime new-mail notification gating is present")
|
||||||
require('suppressRealtimeNotificationsForFolder(notificationDestination)' in mail_actions_js, "bulk destination notifications are suppressed")
|
require('suppressRealtimeNotificationsForFolder(notificationDestination)' in mail_actions_js, "bulk destination notifications are suppressed")
|
||||||
|
require('finishAuthenticatedStartup({ showLoginToast = false } = {})' in auth_js and 'finishAuthenticatedStartup({ showLoginToast: true })' in auth_js and bindings_js.count('finishAuthenticatedStartup();') >= 1, "login success toast is limited to real login, not refresh/session restoration")
|
||||||
|
require('<meta charset="utf-8">${csp}' in reader_js, "message preview srcdoc explicitly declares UTF-8")
|
||||||
|
require('function sanitizeMessagePreviewHtml(html)' in bootstrap_js and 'if (!node.hasAttribute("target")) node.setAttribute("target", "_blank");' in bootstrap_js and '!["_self", "_parent", "_top"].includes(target)' in bootstrap_js and 'rel.add("noopener");' in bootstrap_js and 'rel.add("noreferrer");' in bootstrap_js and 'sanitizeMessagePreviewHtml(detail.html)' in reader_js, "message preview links default to safe new-window navigation without overriding explicit targets")
|
||||||
|
require('attachmentContentUrl(folder, uid, attachment)' in reader_js and 'frame.src = attachmentContentUrl(folder, uid, attachment);' in reader_js and 'URL.createObjectURL(blob)' in reader_js, "PDF preview uses authenticated inline URL while other attachment previews retain blob handling")
|
||||||
|
require('mail_parser::MessageParser::default().parse(synthetic.as_bytes())' in read("src/imap/parse.rs") and 'decode_header_bytes' in read("src/imap/parse.rs"), "RFC 2047 header decoding uses mail-parser and tolerant envelope byte handling")
|
||||||
|
require('encoding_rs::Encoding::for_label' in read("src/imap/parse.rs") and 'decodes_polish_rfc2047_subjects' in read("src/imap/parse.rs"), "Polish UTF-8/ISO-8859-2/Windows-1250 header decoding is covered")
|
||||||
|
require('let is_pdf = response_content_type_is(&response, "application/pdf");' in read("src/routes/mod.rs") and 'HeaderValue::from_static("SAMEORIGIN")' in read("src/routes/mod.rs") and "HeaderValue::from_static(\"frame-ancestors 'self'\")" in read("src/routes/mod.rs"), "security headers allow same-origin framing for inline PDF previews only")
|
||||||
|
index_html = read("src/web/index.html")
|
||||||
|
require('class="compose-limit-summary"' in index_html and 'Per-file upload limit set with' not in index_html and 'Hard limit for the complete RFC822/MIME message' not in index_html, "Composing shows compact raw size limits without implementation details")
|
||||||
require("V048" in read("RELEASE-MANIFEST.txt") and "V049" in read("RELEASE-MANIFEST.txt"), "release manifest documents V048 and reserves V049")
|
require("V048" in read("RELEASE-MANIFEST.txt") and "V049" in read("RELEASE-MANIFEST.txt"), "release manifest documents V048 and reserves V049")
|
||||||
require("V048" in read("migrations/README.md") and "V049" in read("migrations/README.md"), "migration guide documents V048 and reserves V049")
|
require("V048" in read("migrations/README.md") and "V049" in read("migrations/README.md"), "migration guide documents V048 and reserves V049")
|
||||||
require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists")
|
require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists")
|
||||||
@@ -177,6 +190,7 @@ route_tests = read("src/routes/tests.rs")
|
|||||||
auth_routes = read("src/routes/auth.rs")
|
auth_routes = read("src/routes/auth.rs")
|
||||||
auth_js = read("src/web/js/20-preferences-auth.js")
|
auth_js = read("src/web/js/20-preferences-auth.js")
|
||||||
require("security_headers_are_opt_in_and_content_type_aware" in route_tests, "security-header regression test covers content-type-aware behavior")
|
require("security_headers_are_opt_in_and_content_type_aware" in route_tests, "security-header regression test covers content-type-aware behavior")
|
||||||
|
require("pdf_api_responses_allow_same_origin_preview_frames" in route_tests, "PDF preview framing has a same-origin security regression test")
|
||||||
require("auth_policy_exposes_only_login_requirements_before_authentication" in route_tests, "public auth-policy minimization has a regression test")
|
require("auth_policy_exposes_only_login_requirements_before_authentication" in route_tests, "public auth-policy minimization has a regression test")
|
||||||
require("pub struct SessionPolicyResponse" in auth_routes and '.route("/session-policy", get(auth::session_policy))' in routes_mod, "feature policy is exposed only behind the authenticated session route")
|
require("pub struct SessionPolicyResponse" in auth_routes and '.route("/session-policy", get(auth::session_policy))' in routes_mod, "feature policy is exposed only behind the authenticated session route")
|
||||||
require('request("/auth/session-policy"' in auth_js and "async function loadSessionPolicy()" in auth_js, "frontend loads optional feature policy only after authentication")
|
require('request("/auth/session-policy"' in auth_js and "async function loadSessionPolicy()" in auth_js, "frontend loads optional feature policy only after authentication")
|
||||||
|
|||||||
+2
-6
@@ -3,7 +3,7 @@ use futures::StreamExt;
|
|||||||
|
|
||||||
use super::connection::map_imap_error;
|
use super::connection::map_imap_error;
|
||||||
use super::parse::{
|
use super::parse::{
|
||||||
decode_rfc2047, flag_to_string, has_attachments, imap_address_to_email,
|
decode_header_bytes, flag_to_string, has_attachments, imap_address_to_email,
|
||||||
name_attribute_to_string,
|
name_attribute_to_string,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -407,11 +407,7 @@ impl ImapClient for RealImapClient {
|
|||||||
.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
|
.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
|
||||||
|
|
||||||
let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() {
|
let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() {
|
||||||
let subject = env
|
let subject = env.subject.as_ref().map(|b| decode_header_bytes(b));
|
||||||
.subject
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|b| std::str::from_utf8(b).ok())
|
|
||||||
.map(decode_rfc2047);
|
|
||||||
|
|
||||||
let from: Vec<EmailAddress> = env
|
let from: Vec<EmailAddress> = env
|
||||||
.from
|
.from
|
||||||
|
|||||||
+57
-11
@@ -38,6 +38,19 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
|
|||||||
return input.to_string();
|
return input.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Let mail-parser handle RFC 2047 encoded words first. It supports a wider
|
||||||
|
// charset set than the small fallback below (including common Central
|
||||||
|
// European encodings used by older mail clients).
|
||||||
|
let synthetic = format!("Subject: {input}\r\n\r\n");
|
||||||
|
if let Some(parsed) = mail_parser::MessageParser::default().parse(synthetic.as_bytes())
|
||||||
|
&& let Some(subject) = parsed.subject()
|
||||||
|
{
|
||||||
|
let decoded = subject.to_string();
|
||||||
|
if !decoded.is_empty() && decoded != input {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut result = String::with_capacity(input.len());
|
let mut result = String::with_capacity(input.len());
|
||||||
let mut remaining = input;
|
let mut remaining = input;
|
||||||
|
|
||||||
@@ -112,23 +125,35 @@ pub(crate) fn decode_quoted_printable_header(input: &str) -> Option<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn decode_charset(charset: &str, bytes: &[u8]) -> String {
|
pub(crate) fn decode_charset(charset: &str, bytes: &[u8]) -> String {
|
||||||
match charset.to_ascii_lowercase().as_str() {
|
if let Some(encoding) = encoding_rs::Encoding::for_label(charset.trim().as_bytes()) {
|
||||||
"utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(),
|
let (decoded, _, _) = encoding.decode(bytes);
|
||||||
"iso-8859-1" | "latin1" | "latin-1" => bytes.iter().map(|&b| b as char).collect(),
|
return decoded.into_owned();
|
||||||
|
|
||||||
_ => String::from_utf8(bytes.to_vec())
|
|
||||||
.unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String::from_utf8(bytes.to_vec()).unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode_header_bytes(bytes: &[u8]) -> String {
|
||||||
|
let mut synthetic = Vec::with_capacity(bytes.len() + 13);
|
||||||
|
synthetic.extend_from_slice(b"Subject: ");
|
||||||
|
synthetic.extend_from_slice(bytes);
|
||||||
|
synthetic.extend_from_slice(b"\r\n\r\n");
|
||||||
|
if let Some(parsed) = mail_parser::MessageParser::default().parse(&synthetic)
|
||||||
|
&& let Some(subject) = parsed.subject()
|
||||||
|
{
|
||||||
|
let decoded = subject.to_string();
|
||||||
|
if !decoded.is_empty() {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(bytes);
|
||||||
|
decode_rfc2047(&text)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn imap_address_to_email(
|
pub(crate) fn imap_address_to_email(
|
||||||
addr: &async_imap::imap_proto::types::Address<'_>,
|
addr: &async_imap::imap_proto::types::Address<'_>,
|
||||||
) -> EmailAddress {
|
) -> EmailAddress {
|
||||||
let name = addr
|
let name = addr.name.as_ref().map(|b| decode_header_bytes(b));
|
||||||
.name
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|b| std::str::from_utf8(b).ok())
|
|
||||||
.map(decode_rfc2047);
|
|
||||||
|
|
||||||
let mailbox = addr
|
let mailbox = addr
|
||||||
.mailbox
|
.mailbox
|
||||||
@@ -173,3 +198,24 @@ pub(crate) fn has_attachments(bs: &async_imap::imap_proto::types::BodyStructure<
|
|||||||
BodyStructure::Multipart { bodies, .. } => bodies.iter().any(has_attachments),
|
BodyStructure::Multipart { bodies, .. } => bodies.iter().any(has_attachments),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_polish_rfc2047_subjects() {
|
||||||
|
assert_eq!(
|
||||||
|
decode_rfc2047("=?utf-8?b?WmHFvMOzxYLEhyBnxJnFm2zEhSBqYcW6xYQ=?="),
|
||||||
|
"Zażółć gęślą jaźń"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_rfc2047("=?iso-8859-2?q?Za=BF=F3=B3=E6_g=EA=B6l=B1_ja=BC=F1?="),
|
||||||
|
"Zażółć gęślą jaźń"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_rfc2047("=?windows-1250?b?WmG/87PmIGfqnGy5IGphn/E=?="),
|
||||||
|
"Zażółć gęślą jaźń"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ use tokio::task::JoinHandle;
|
|||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::imap::client::ImapCredentials;
|
use crate::imap::client::ImapCredentials;
|
||||||
use crate::imap::connection::ImapStream;
|
use crate::imap::connection::ImapStream;
|
||||||
use crate::imap::parse::{decode_rfc2047, flag_to_string, has_attachments, imap_address_to_email};
|
use crate::imap::parse::{
|
||||||
|
decode_header_bytes, flag_to_string, has_attachments, imap_address_to_email,
|
||||||
|
};
|
||||||
use crate::realtime::events::{EventBus, MailEvent, MessageFlagChange};
|
use crate::realtime::events::{EventBus, MailEvent, MessageFlagChange};
|
||||||
use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex};
|
use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex};
|
||||||
use crate::{db, imap};
|
use crate::{db, imap};
|
||||||
@@ -897,11 +899,7 @@ async fn fetch_and_store_headers(
|
|||||||
raw_header_bytes.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
|
raw_header_bytes.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
|
||||||
|
|
||||||
let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() {
|
let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() {
|
||||||
let subject = env
|
let subject = env.subject.as_ref().map(|b| decode_header_bytes(b));
|
||||||
.subject
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|b| std::str::from_utf8(b).ok())
|
|
||||||
.map(decode_rfc2047);
|
|
||||||
|
|
||||||
let from: Vec<_> = env
|
let from: Vec<_> = env
|
||||||
.from
|
.from
|
||||||
|
|||||||
+14
-5
@@ -123,6 +123,7 @@ async fn add_security_headers(request: Request<Body>, next: middleware::Next) ->
|
|||||||
let is_html = response_content_type_is(&response, "text/html")
|
let is_html = response_content_type_is(&response, "text/html")
|
||||||
|| response_content_type_is(&response, "application/xhtml+xml");
|
|| response_content_type_is(&response, "application/xhtml+xml");
|
||||||
let is_css = response_content_type_is(&response, "text/css");
|
let is_css = response_content_type_is(&response, "text/css");
|
||||||
|
let is_pdf = response_content_type_is(&response, "application/pdf");
|
||||||
let headers = response.headers_mut();
|
let headers = response.headers_mut();
|
||||||
|
|
||||||
headers.insert(
|
headers.insert(
|
||||||
@@ -136,11 +137,19 @@ async fn add_security_headers(request: Request<Body>, next: middleware::Next) ->
|
|||||||
"cross-origin-resource-policy",
|
"cross-origin-resource-policy",
|
||||||
HeaderValue::from_static("same-origin"),
|
HeaderValue::from_static("same-origin"),
|
||||||
);
|
);
|
||||||
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
if is_pdf {
|
||||||
headers.insert(
|
headers.insert("x-frame-options", HeaderValue::from_static("SAMEORIGIN"));
|
||||||
"content-security-policy",
|
headers.insert(
|
||||||
HeaderValue::from_static("frame-ancestors 'none'"),
|
"content-security-policy",
|
||||||
);
|
HeaderValue::from_static("frame-ancestors 'self'"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
||||||
|
headers.insert(
|
||||||
|
"content-security-policy",
|
||||||
|
HeaderValue::from_static("frame-ancestors 'none'"),
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if is_html || is_css {
|
} else if is_html || is_css {
|
||||||
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -524,6 +524,40 @@ async fn security_headers_are_opt_in_and_content_type_aware() {
|
|||||||
assert!(api.headers().get("cross-origin-opener-policy").is_none());
|
assert!(api.headers().get("cross-origin-opener-policy").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pdf_api_responses_allow_same_origin_preview_frames() {
|
||||||
|
let app = Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/test.pdf",
|
||||||
|
get(|| async {
|
||||||
|
Response::builder()
|
||||||
|
.header("content-type", "application/pdf")
|
||||||
|
.body(Body::from("%PDF-1.4\n"))
|
||||||
|
.unwrap()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.layer(middleware::from_fn(add_security_headers));
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/test.pdf")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.headers()["x-frame-options"], "SAMEORIGIN");
|
||||||
|
assert_eq!(
|
||||||
|
response.headers()["content-security-policy"],
|
||||||
|
"frame-ancestors 'self'"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers()["cross-origin-resource-policy"],
|
||||||
|
"same-origin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[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();
|
||||||
|
|||||||
@@ -274,6 +274,21 @@
|
|||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compose-limit-summary {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-limit-summary strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
.appearance-presets {
|
.appearance-presets {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|||||||
+4
-7
@@ -885,13 +885,10 @@
|
|||||||
message text suggests a missing attachment</span></label>
|
message text suggests a missing attachment</span></label>
|
||||||
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser
|
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser
|
||||||
spellcheck while composing</span></label>
|
spellcheck while composing</span></label>
|
||||||
<label><span>Maximum attachment size</span><output
|
<div class="compose-limit-summary" aria-label="Compose size limits">
|
||||||
id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__ MiB</output><small
|
<span>Maximum attachment size: <strong id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__ MiB</strong></span>
|
||||||
class="muted">Per-file upload limit set with <code>max_attachment_size_mb</code> in
|
<span>Maximum message size: <strong id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__ MiB</strong></span>
|
||||||
rustbox.toml.</small></label>
|
</div>
|
||||||
<label><span>Maximum message size</span><output id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__
|
|
||||||
MiB</output><small class="muted">Hard limit for the complete RFC822/MIME message sent to the MTA,
|
|
||||||
including base64/MIME overhead.</small></label>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a
|
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a
|
||||||
plain-text fallback.</p>
|
plain-text fallback.</p>
|
||||||
|
|||||||
@@ -155,6 +155,21 @@
|
|||||||
return doc.body.innerHTML;
|
return doc.body.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeMessagePreviewHtml(html) {
|
||||||
|
const doc = new DOMParser().parseFromString(sanitizeMessageHtml(html), "text/html");
|
||||||
|
doc.querySelectorAll("a[href]").forEach((node) => {
|
||||||
|
if (!node.hasAttribute("target")) node.setAttribute("target", "_blank");
|
||||||
|
const target = String(node.getAttribute("target") || "").toLowerCase();
|
||||||
|
if (target && !["_self", "_parent", "_top"].includes(target)) {
|
||||||
|
const rel = new Set(String(node.getAttribute("rel") || "").split(/\s+/).filter(Boolean));
|
||||||
|
rel.add("noopener");
|
||||||
|
rel.add("noreferrer");
|
||||||
|
node.setAttribute("rel", [...rel].join(" "));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return doc.body.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeAttachmentHtml(html) {
|
function sanitizeAttachmentHtml(html) {
|
||||||
const doc = new DOMParser().parseFromString(sanitizeMessageHtml(html), "text/html");
|
const doc = new DOMParser().parseFromString(sanitizeMessageHtml(html), "text/html");
|
||||||
doc.querySelectorAll("a[href]").forEach((node) => node.removeAttribute("href"));
|
doc.querySelectorAll("a[href]").forEach((node) => node.removeAttribute("href"));
|
||||||
|
|||||||
@@ -458,10 +458,10 @@ function showApp() {
|
|||||||
$("#app-view").classList.remove("hidden");
|
$("#app-view").classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishAuthenticatedStartup() {
|
function finishAuthenticatedStartup({ showLoginToast = false } = {}) {
|
||||||
state.startupToastsMuted = false;
|
state.startupToastsMuted = false;
|
||||||
armRealtimeNotifications();
|
armRealtimeNotifications();
|
||||||
toast(tr("login_success", "Successfully logged in"), "success");
|
if (showLoginToast) toast(tr("login_success", "Successfully logged in"), "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareAuthenticatedStartup() {
|
function prepareAuthenticatedStartup() {
|
||||||
@@ -735,7 +735,7 @@ async function login(event) {
|
|||||||
await loadTrustedSenders();
|
await loadTrustedSenders();
|
||||||
startCalendarReminderWatcher();
|
startCalendarReminderWatcher();
|
||||||
connectRealtime();
|
connectRealtime();
|
||||||
finishAuthenticatedStartup();
|
finishAuthenticatedStartup({ showLoginToast: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorBox.textContent = error.message;
|
errorBox.textContent = error.message;
|
||||||
errorBox.classList.remove("hidden");
|
errorBox.classList.remove("hidden");
|
||||||
|
|||||||
@@ -688,8 +688,8 @@ function renderMessageBody(detail, forceRemote = false) {
|
|||||||
const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:";
|
const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:";
|
||||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${imageSources}; style-src 'unsafe-inline'; font-src data:">`;
|
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${imageSources}; style-src 'unsafe-inline'; font-src data:">`;
|
||||||
const quoteStyle = state.mailPrefs.collapse_quotes !== false ? "blockquote{max-height:16em;overflow:auto;border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}" : "blockquote{border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}";
|
const quoteStyle = state.mailPrefs.collapse_quotes !== false ? "blockquote{max-height:16em;overflow:auto;border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}" : "blockquote{border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}";
|
||||||
const safeHtml = sanitizeMessageHtml(detail.html);
|
const safeHtml = sanitizeMessagePreviewHtml(detail.html);
|
||||||
iframe.srcdoc = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:22px;color:#202622;background:#fff;font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:#356a56}pre{white-space:pre-wrap}${quoteStyle}</style></head><body>${safeHtml}</body></html>`;
|
iframe.srcdoc = `<!doctype html><html><head><meta charset="utf-8">${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:22px;color:#202622;background:#fff;font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:#356a56}pre{white-space:pre-wrap}${quoteStyle}</style></head><body>${safeHtml}</body></html>`;
|
||||||
body.append(iframe);
|
body.append(iframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,6 +722,12 @@ function attachmentMime(attachment, blob = null) {
|
|||||||
return String(blob?.type || attachment?.content_type || attachment?.contentType || "application/octet-stream").toLowerCase();
|
return String(blob?.type || attachment?.content_type || attachment?.contentType || "application/octet-stream").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachmentContentUrl(folder, uid, attachment) {
|
||||||
|
const path = apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`);
|
||||||
|
if (!state.accountId) return path;
|
||||||
|
return `${path}${path.includes("?") ? "&" : "?"}account_id=${encodeURIComponent(state.accountId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function attachmentPreviewLimitBytes(attachment) {
|
function attachmentPreviewLimitBytes(attachment) {
|
||||||
const mime = attachmentMime(attachment);
|
const mime = attachmentMime(attachment);
|
||||||
const filename = String(attachment?.filename || "").toLowerCase();
|
const filename = String(attachment?.filename || "").toLowerCase();
|
||||||
@@ -759,9 +765,11 @@ async function openAttachment(folder, uid, attachment) {
|
|||||||
await offerAttachmentDownload(folder, uid, attachment, "This attachment is too large for preview. Download it instead?");
|
await offerAttachmentDownload(folder, uid, attachment, "This attachment is too large for preview. Download it instead?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const blob = await fetchAttachmentBlob(folder, uid, attachment);
|
const declaredMime = attachmentMime(attachment);
|
||||||
const mime = attachmentMime(attachment, blob);
|
|
||||||
const filename = String(attachment?.filename || "").toLowerCase();
|
const filename = String(attachment?.filename || "").toLowerCase();
|
||||||
|
const isPdf = declaredMime === "application/pdf" || filename.endsWith(".pdf");
|
||||||
|
const blob = isPdf ? null : await fetchAttachmentBlob(folder, uid, attachment);
|
||||||
|
const mime = isPdf ? "application/pdf" : attachmentMime(attachment, blob);
|
||||||
const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename);
|
const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename);
|
||||||
const isImage = mime.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(filename);
|
const isImage = mime.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(filename);
|
||||||
const isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(filename);
|
const isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(filename);
|
||||||
@@ -771,7 +779,7 @@ async function openAttachment(folder, uid, attachment) {
|
|||||||
await offerAttachmentDownload(folder, uid, attachment, "Preview is not available for this attachment type. Download it instead?");
|
await offerAttachmentDownload(folder, uid, attachment, "Preview is not available for this attachment type. Download it instead?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (attachmentExceedsPreviewLimit(previewAttachment, blob.size)) {
|
if (blob && attachmentExceedsPreviewLimit(previewAttachment, blob.size)) {
|
||||||
await offerAttachmentDownload(folder, uid, attachment, "This attachment is too large for preview. Download it instead?");
|
await offerAttachmentDownload(folder, uid, attachment, "This attachment is too large for preview. Download it instead?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -794,12 +802,12 @@ async function openAttachment(folder, uid, attachment) {
|
|||||||
image.src = objectUrl;
|
image.src = objectUrl;
|
||||||
image.alt = attachment.filename || "Attachment image";
|
image.alt = attachment.filename || "Attachment image";
|
||||||
body.append(image);
|
body.append(image);
|
||||||
} else if (mime === "application/pdf" || filename.endsWith(".pdf")) {
|
} else if (isPdf) {
|
||||||
objectUrl = URL.createObjectURL(blob);
|
|
||||||
const frame = document.createElement("iframe");
|
const frame = document.createElement("iframe");
|
||||||
frame.src = objectUrl;
|
frame.src = attachmentContentUrl(folder, uid, attachment);
|
||||||
frame.title = attachment.filename || "PDF preview";
|
frame.title = attachment.filename || "PDF preview";
|
||||||
frame.referrerPolicy = "no-referrer";
|
frame.referrerPolicy = "no-referrer";
|
||||||
|
frame.loading = "eager";
|
||||||
body.append(frame);
|
body.append(frame);
|
||||||
} else if (isVideo) {
|
} else if (isVideo) {
|
||||||
objectUrl = URL.createObjectURL(blob);
|
objectUrl = URL.createObjectURL(blob);
|
||||||
@@ -815,7 +823,7 @@ async function openAttachment(folder, uid, attachment) {
|
|||||||
frame.referrerPolicy = "no-referrer";
|
frame.referrerPolicy = "no-referrer";
|
||||||
const safeHtml = sanitizeAttachmentHtml(await blob.text());
|
const safeHtml = sanitizeAttachmentHtml(await blob.text());
|
||||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:">`;
|
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:">`;
|
||||||
frame.srcdoc = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:20px;color:#202622;background:#fff;font:14px/1.55 system-ui,sans-serif}img{max-width:100%;height:auto}pre{white-space:pre-wrap}</style></head><body>${safeHtml}</body></html>`;
|
frame.srcdoc = `<!doctype html><html><head><meta charset="utf-8">${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:20px;color:#202622;background:#fff;font:14px/1.55 system-ui,sans-serif}img{max-width:100%;height:auto}pre{white-space:pre-wrap}</style></head><body>${safeHtml}</body></html>`;
|
||||||
body.append(frame);
|
body.append(frame);
|
||||||
} else {
|
} else {
|
||||||
const pre = document.createElement("pre");
|
const pre = document.createElement("pre");
|
||||||
|
|||||||
Reference in new issue
Block a user