From 02d0b13ec1af39574d7ba3777c241017253957ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 1 Nov 2026 23:52:18 +0100 Subject: [PATCH] v0.2.10 --- CHANGELOG.md | 11 +++++ Cargo.lock | 3 +- Cargo.toml | 3 +- NOTICE.md | 2 +- README.md | 6 +-- RELEASE-MANIFEST.txt | 19 +++++---- RELEASE-VALIDATION.txt | 22 +++++----- config.example.toml | 2 +- docs/IMPLEMENTATION_STATUS.md | 18 +++++--- docs/RELEASING.md | 2 +- docs/STORAGE_SEARCH_OPERATIONS.md | 4 +- migrations/README.md | 4 +- packaging/rustbox.toml | 2 +- scripts/validate-release-static.py | 28 +++++++++--- src/imap/client.rs | 8 +--- src/imap/parse.rs | 68 +++++++++++++++++++++++++----- src/realtime/idle.rs | 10 ++--- src/routes/mod.rs | 19 ++++++--- src/routes/tests.rs | 34 +++++++++++++++ src/web/css/50-settings.css | 15 +++++++ src/web/index.html | 11 ++--- src/web/js/00-bootstrap.js | 15 +++++++ src/web/js/20-preferences-auth.js | 6 +-- src/web/js/40-messages-reader.js | 26 ++++++++---- 24 files changed, 245 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c82ba..f82daa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 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 - 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. diff --git a/Cargo.lock b/Cargo.lock index 6239f78..7e46209 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3506,7 +3506,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.9" +version = "0.2.10" dependencies = [ "aes-gcm", "async-imap", @@ -3517,6 +3517,7 @@ dependencies = [ "base64 0.22.1", "bytes", "dashmap", + "encoding_rs", "figment", "futures", "governor", diff --git a/Cargo.toml b/Cargo.toml index aad5b62..355c309 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.2.9" +version = "0.2.10" edition = "2024" rust-version = "1.99.1" default-run = "rustbox" @@ -28,6 +28,7 @@ rusqlite = { version = "0.34", features = ["bundled"] } axum-extra = { version = "0.10", features = ["cookie"] } async-trait = "0.1.92" mail-parser = "0.11.2" +encoding_rs = "0.8" futures = "0.3.32" lettre = { version = "0.11", default-features = false, features = ["tokio1-native-tls", "smtp-transport", "builder"] } uuid = { version = "1", features = ["v4"] } diff --git a/NOTICE.md b/NOTICE.md index dfea5d9..54030ee 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # 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` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index b50085f..33d211c 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -34,7 +34,7 @@ rustbox/ `build.rs` combines the CSS modules from `src/web/css/manifest.txt` and JavaScript modules from `src/web/js/manifest.txt`. Cargo embeds the generated CSS/JS bundles, HTML and SVG sprite in the RustBox executable. There is no standalone source `src/web/app.js`; `src/web/` is the single source of truth and Cargo generates the embedded bundles in `OUT_DIR`. -## Storage and search in 0.2.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. @@ -235,7 +235,7 @@ Initialize Git after extracting the release archive: ```bash git init git add . -git commit -m "RustBox 0.2.9 release" +git commit -m "RustBox 0.2.10 release" ./scripts/setup-upstream.sh ``` diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index e680ce2..1207906 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,11 +1,12 @@ -RustBox 0.2.9 release manifest +RustBox 0.2.10 release manifest -0.2.9 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 -- suppresses self-generated destination-folder NewMessages notifications during bulk move/copy/archive/junk/delete operations while retaining the bulk summary toast -- shows a success toast after authenticated startup/login -- preserves browser/system Ctrl/Cmd shortcuts by limiting RustBox single-key shortcuts to unmodified keys; compose remains available on C -- defaults mailbox pages/preferences to 300 messages and raises the supported per-page API/preference limit to 300 +0.2.10 feature/configuration changes: +- 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 +- shows the login success toast only after a real POST login; session restoration/page refresh is silent +- improves non-ASCII/Polish mail display with explicit UTF-8 preview documents and broader RFC 2047 header decoding (including ISO-8859-2/Windows-1250) +- previews PDFs from the authenticated inline attachment endpoint instead of a blob iframe for Safari/iPhone compatibility +- 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 Architecture: - 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) Operations/docs: -- README.md documents 0.2.9 configuration +- README.md documents 0.2.10 configuration - docker/README.md documents Compose variants - docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior - TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending Build/release metadata: - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts -- Cargo.toml version = 0.2.9 +- Cargo.toml version = 0.2.10 - rust-toolchain.toml pins Rust 1.99.1 - 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 diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 2e7b3c3..3cd5974 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,21 +1,23 @@ -RustBox 0.2.9 - final source validation +RustBox 0.2.10 - final source validation -0.2.9 scope: -- startup synchronization does not emit new-mail toasts; realtime mail notifications are armed after application startup -- bulk move/copy/archive/junk/delete actions suppress self-generated destination-folder new-mail notifications and retain one summary toast -- authenticated startup/login shows a success toast -- browser/system Ctrl/Cmd shortcuts are not intercepted by RustBox single-key mail shortcuts -- mailbox pages default to 300 messages and accept up to 300 per page +0.2.10 scope: +- message-body links without an explicit target open outside the sandboxed preview in a new tab/window, while explicit targets are preserved +- page refresh/session restoration does not emit a false “Successfully logged in” toast +- real login still emits the success toast +- message preview documents explicitly use UTF-8 and RFC 2047 headers support UTF-8, ISO-8859-2 and Windows-1250 decoding +- 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 -- version metadata updated to 0.2.9 +- version metadata updated to 0.2.10 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` - all JSON files parse successfully - Python helper scripts compile successfully - 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 diff --git a/config.example.toml b/config.example.toml index 798d1bb..134bc9e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,4 +1,4 @@ -# RustBox 0.2.9 +# RustBox 0.2.10 host = "127.0.0.1" port = 3001 environment = "production" diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 98f21cd..d81e926 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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. -- 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. +- 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`. + +- 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. - 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. - HTML/XHTML documents receive the full document profile: CSP, `X-Frame-Options: DENY`, `Permissions-Policy`, `Cross-Origin-Opener-Policy: same-origin`, `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`. - The HTML CSP allows only same-origin scripts (`script-src 'self'`) and disables inline script attributes; the theme bootstrap is an external fingerprinted asset. Stylesheets are same-origin only (`style-src 'self'`), while `style-src-attr 'unsafe-inline'` is narrowly retained because the current UI still sets per-element style attributes dynamically. -- Private `/api` responses receive `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `Content-Security-Policy: frame-ancestors 'none'` and legacy-compatible `X-Frame-Options: DENY`. API JSON does not receive the full document CSP, `Permissions-Policy`, COOP or `Referrer-Policy`. +- 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. - 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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b7a3ad3..fd5fe16 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -26,7 +26,7 @@ RUSTBOX_CONFIG=./config.dev.toml ./target/release/rustbox --check-config Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source. -## Updating an existing checkout to 0.2.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. diff --git a/docs/STORAGE_SEARCH_OPERATIONS.md b/docs/STORAGE_SEARCH_OPERATIONS.md index 77691fa..bf6a523 100644 --- a/docs/STORAGE_SEARCH_OPERATIONS.md +++ b/docs/STORAGE_SEARCH_OPERATIONS.md @@ -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 diff --git a/migrations/README.md b/migrations/README.md index 90e4a5b..d5981d5 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -1,6 +1,6 @@ # 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 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. -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. diff --git a/packaging/rustbox.toml b/packaging/rustbox.toml index 229a650..8342eb3 100644 --- a/packaging/rustbox.toml +++ b/packaging/rustbox.toml @@ -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. # Commented options are optional and do not change the packaged defaults. diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index 4c03200..0ff8255 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Static/offline release checks for RustBox 0.2.9. +"""Static/offline release checks for RustBox 0.2.10. These checks intentionally avoid pretending to replace `cargo test`: they validate 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. 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") toolchain = tomllib.loads(read("rust-toolchain.toml")) 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") lock = tomllib.loads(read("Cargo.lock")) 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") release_script = read("scripts/build-release.sh") 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("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate") require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate") -require(read("README.md").startswith("# RustBox 0.2.9"), "README identifies RustBox 0.2.9") -require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.9"), "release manifest identifies RustBox 0.2.9") -require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.9"), "release validation identifies RustBox 0.2.9") -require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.9"), "implementation status 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.10"), "release manifest identifies RustBox 0.2.10") +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.10"), "implementation status identifies RustBox 0.2.10") bootstrap_js = read("src/web/js/00-bootstrap.js") bindings_js = read("src/web/js/95-bindings-boot.js") realtime_js = read("src/web/js/80-settings-realtime.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(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") @@ -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('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('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('${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("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") @@ -177,6 +190,7 @@ route_tests = read("src/routes/tests.rs") auth_routes = read("src/routes/auth.rs") auth_js = read("src/web/js/20-preferences-auth.js") require("security_headers_are_opt_in_and_content_type_aware" in route_tests, "security-header regression test covers content-type-aware behavior") +require("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("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") diff --git a/src/imap/client.rs b/src/imap/client.rs index a82ac7a..dfc01f2 100644 --- a/src/imap/client.rs +++ b/src/imap/client.rs @@ -3,7 +3,7 @@ use futures::StreamExt; use super::connection::map_imap_error; 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, }; @@ -407,11 +407,7 @@ impl ImapClient for RealImapClient { .and_then(|raw| mail_parser::MessageParser::default().parse(raw)); let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() { - let subject = env - .subject - .as_ref() - .and_then(|b| std::str::from_utf8(b).ok()) - .map(decode_rfc2047); + let subject = env.subject.as_ref().map(|b| decode_header_bytes(b)); let from: Vec = env .from diff --git a/src/imap/parse.rs b/src/imap/parse.rs index 7ffa684..6e0aa4b 100644 --- a/src/imap/parse.rs +++ b/src/imap/parse.rs @@ -38,6 +38,19 @@ pub(crate) fn decode_rfc2047(input: &str) -> 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 remaining = input; @@ -112,23 +125,35 @@ pub(crate) fn decode_quoted_printable_header(input: &str) -> Option> { } pub(crate) fn decode_charset(charset: &str, bytes: &[u8]) -> String { - match charset.to_ascii_lowercase().as_str() { - "utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(), - "iso-8859-1" | "latin1" | "latin-1" => 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()), + if let Some(encoding) = encoding_rs::Encoding::for_label(charset.trim().as_bytes()) { + let (decoded, _, _) = encoding.decode(bytes); + return decoded.into_owned(); } + + 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( addr: &async_imap::imap_proto::types::Address<'_>, ) -> EmailAddress { - let name = addr - .name - .as_ref() - .and_then(|b| std::str::from_utf8(b).ok()) - .map(decode_rfc2047); + let name = addr.name.as_ref().map(|b| decode_header_bytes(b)); let mailbox = addr .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), } } + +#[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źń" + ); + } +} diff --git a/src/realtime/idle.rs b/src/realtime/idle.rs index cc19d6e..e12d1b1 100644 --- a/src/realtime/idle.rs +++ b/src/realtime/idle.rs @@ -8,7 +8,9 @@ use tokio::task::JoinHandle; use crate::config::AppConfig; use crate::imap::client::ImapCredentials; 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::search::engine::{IndexableMessage, SearchEngine, UserIndex}; 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)); let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() { - let subject = env - .subject - .as_ref() - .and_then(|b| std::str::from_utf8(b).ok()) - .map(decode_rfc2047); + let subject = env.subject.as_ref().map(|b| decode_header_bytes(b)); let from: Vec<_> = env .from diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 4f416cb..b0d7b11 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -123,6 +123,7 @@ async fn add_security_headers(request: Request, next: middleware::Next) -> let is_html = response_content_type_is(&response, "text/html") || response_content_type_is(&response, "application/xhtml+xml"); let is_css = response_content_type_is(&response, "text/css"); + let is_pdf = response_content_type_is(&response, "application/pdf"); let headers = response.headers_mut(); headers.insert( @@ -136,11 +137,19 @@ async fn add_security_headers(request: Request, next: middleware::Next) -> "cross-origin-resource-policy", HeaderValue::from_static("same-origin"), ); - headers.insert("x-frame-options", HeaderValue::from_static("DENY")); - headers.insert( - "content-security-policy", - HeaderValue::from_static("frame-ancestors 'none'"), - ); + if is_pdf { + headers.insert("x-frame-options", HeaderValue::from_static("SAMEORIGIN")); + headers.insert( + "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 { headers.insert("referrer-policy", HeaderValue::from_static("no-referrer")); } diff --git a/src/routes/tests.rs b/src/routes/tests.rs index 2a0949d..8840c5b 100644 --- a/src/routes/tests.rs +++ b/src/routes/tests.rs @@ -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()); } +#[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] async fn root_serves_embedded_rustbox_ui() { let dir = setup_static_dir(); diff --git a/src/web/css/50-settings.css b/src/web/css/50-settings.css index 0ee8636..a3d8ddb 100644 --- a/src/web/css/50-settings.css +++ b/src/web/css/50-settings.css @@ -274,6 +274,21 @@ 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 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/src/web/index.html b/src/web/index.html index 1849b96..fa302b1 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -885,13 +885,10 @@ message text suggests a missing attachment - - +
+ Maximum attachment size: __RUSTBOX_MAX_ATTACHMENT_MB__ MiB + Maximum message size: __RUSTBOX_MAX_MESSAGE_MB__ MiB +

Markdown is stored as source text and sent as a safe rendered HTML alternative with a plain-text fallback.

diff --git a/src/web/js/00-bootstrap.js b/src/web/js/00-bootstrap.js index 2972263..40e6eb7 100644 --- a/src/web/js/00-bootstrap.js +++ b/src/web/js/00-bootstrap.js @@ -155,6 +155,21 @@ 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) { const doc = new DOMParser().parseFromString(sanitizeMessageHtml(html), "text/html"); doc.querySelectorAll("a[href]").forEach((node) => node.removeAttribute("href")); diff --git a/src/web/js/20-preferences-auth.js b/src/web/js/20-preferences-auth.js index 26e6499..5da9d20 100644 --- a/src/web/js/20-preferences-auth.js +++ b/src/web/js/20-preferences-auth.js @@ -458,10 +458,10 @@ function showApp() { $("#app-view").classList.remove("hidden"); } -function finishAuthenticatedStartup() { +function finishAuthenticatedStartup({ showLoginToast = false } = {}) { state.startupToastsMuted = false; armRealtimeNotifications(); - toast(tr("login_success", "Successfully logged in"), "success"); + if (showLoginToast) toast(tr("login_success", "Successfully logged in"), "success"); } function prepareAuthenticatedStartup() { @@ -735,7 +735,7 @@ async function login(event) { await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); - finishAuthenticatedStartup(); + finishAuthenticatedStartup({ showLoginToast: true }); } catch (error) { errorBox.textContent = error.message; errorBox.classList.remove("hidden"); diff --git a/src/web/js/40-messages-reader.js b/src/web/js/40-messages-reader.js index a358044..b1e9d4e 100644 --- a/src/web/js/40-messages-reader.js +++ b/src/web/js/40-messages-reader.js @@ -688,8 +688,8 @@ function renderMessageBody(detail, forceRemote = false) { const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:"; const csp = ``; 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); - iframe.srcdoc = `${csp}${safeHtml}`; + const safeHtml = sanitizeMessagePreviewHtml(detail.html); + iframe.srcdoc = `${csp}${safeHtml}`; 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(); } +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) { const mime = attachmentMime(attachment); 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?"); return; } - const blob = await fetchAttachmentBlob(folder, uid, attachment); - const mime = attachmentMime(attachment, blob); + const declaredMime = attachmentMime(attachment); 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 isImage = mime.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/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?"); 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?"); return; } @@ -794,12 +802,12 @@ async function openAttachment(folder, uid, attachment) { image.src = objectUrl; image.alt = attachment.filename || "Attachment image"; body.append(image); - } else if (mime === "application/pdf" || filename.endsWith(".pdf")) { - objectUrl = URL.createObjectURL(blob); + } else if (isPdf) { const frame = document.createElement("iframe"); - frame.src = objectUrl; + frame.src = attachmentContentUrl(folder, uid, attachment); frame.title = attachment.filename || "PDF preview"; frame.referrerPolicy = "no-referrer"; + frame.loading = "eager"; body.append(frame); } else if (isVideo) { objectUrl = URL.createObjectURL(blob); @@ -815,7 +823,7 @@ async function openAttachment(folder, uid, attachment) { frame.referrerPolicy = "no-referrer"; const safeHtml = sanitizeAttachmentHtml(await blob.text()); const csp = ``; - frame.srcdoc = `${csp}${safeHtml}`; + frame.srcdoc = `${csp}${safeHtml}`; body.append(frame); } else { const pre = document.createElement("pre");