From edda5fc9e4eb87e317bdda5475d550bca10cc0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 2 Nov 2026 15:34:49 +0100 Subject: [PATCH] v0.2.14 --- CHANGELOG.md | 9 + Cargo.lock | 2 +- Cargo.toml | 2 +- NOTICE.md | 2 +- README.md | 8 +- RELEASE-MANIFEST.txt | 15 +- RELEASE-VALIDATION.txt | 16 +- config.example.toml | 2 +- docs/IMPLEMENTATION_STATUS.md | 12 +- docs/RELEASING.md | 2 +- docs/STORAGE_SEARCH_OPERATIONS.md | 4 +- migrations/README.md | 15 +- .../V050__compact_rows_and_signature_mode.sql | 5 + migrations/mysql/schema.sql | 6 +- .../V050__compact_rows_and_signature_mode.sql | 5 + migrations/pgsql/schema.sql | 6 +- .../V050__compact_rows_and_signature_mode.sql | 5 + migrations/sqlite/schema.sql | 6 +- packaging/rustbox.toml | 2 +- scripts/validate-release-static.py | 51 ++++- src/db/display_preferences.rs | 40 +++- src/db/folder_preferences.rs | 1 + src/db/identities.rs | 41 +++- src/db/mail_preferences.rs | 11 +- src/db/pool.rs | 12 + src/db/settings.rs | 1 + src/queries/mysql/queries.rs | 8 +- src/queries/pgsql/queries.rs | 8 +- src/queries/sqlite/queries.rs | 8 +- src/routes/auth.rs | 1 + src/routes/send.rs | 214 +++++++++++++++++- src/web/css/20-shell.css | 6 + src/web/css/30-mail.css | 92 +++++++- src/web/css/40-compose.css | 6 + src/web/i18n/pl.json | 6 +- src/web/index.html | 10 +- src/web/js/00-bootstrap.js | 5 +- src/web/js/20-preferences-auth.js | 76 ++++++- src/web/js/50-compose.js | 30 ++- src/web/js/60-mail-actions-search.js | 1 + src/web/js/72-openpgp-identities.js | 95 ++++++-- src/web/js/95-bindings-boot.js | 8 +- 42 files changed, 733 insertions(+), 122 deletions(-) create mode 100644 migrations/mysql/V050__compact_rows_and_signature_mode.sql create mode 100644 migrations/pgsql/V050__compact_rows_and_signature_mode.sql create mode 100644 migrations/sqlite/V050__compact_rows_and_signature_mode.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e87da..07f0791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # RustBox changelog +### 0.2.14 + +- Made pane resizing tolerant of frontend/backend range mismatches: pane widths are normalized server-side, and a rejected width silently disables the in-view resizer for the current session instead of showing a validation error. +- Added a default-on compact message-list layout that consolidates sender, subject/snippet, date and status icons into one row when the message pane is wide enough; it can be disabled in Appearance settings. +- Added per-identity signature insertion mode: `silent` (default) keeps the composer clean and appends the signature during send, while `visible` inserts an editable/removable signature in the composer. +- Visible HTML signatures are structurally marked for de-duplication and keep inline signature images attached through the existing CID flow. +- Added runtime schema migration V050 for SQLite, MySQL and PostgreSQL (`mail_preferences.compact_wide_rows`, `identities.signature_insert_mode`). V051 is the next migration number. +- Bumped the application/source release to 0.2.14. + ### 0.2.13 - Added durable per-tenant blob deletion outbox for every configured blob backend (`filesystem` including shared NFS/POSIX, and S3-compatible storage). Attachment metadata deletion and cleanup enqueue now commit atomically, so a temporary storage failure cannot silently orphan blobs. diff --git a/Cargo.lock b/Cargo.lock index 829e3a0..32de63b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3506,7 +3506,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.13" +version = "0.2.14" dependencies = [ "aes-gcm", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index cb17099..2781221 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.2.13" +version = "0.2.14" edition = "2024" rust-version = "1.99.1" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index 76dcf51..8dd6670 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # RustBox attribution / notice -RustBox 0.2.13 is derived from the Oxi webmail project: +RustBox 0.2.14 is derived from the Oxi webmail project: - Upstream: `c0h1b4/oxi` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index 218a00e..cb37f9c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.2.13 +# RustBox 0.2.14 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.13 +## Storage and search in 0.2.14 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. @@ -168,7 +168,7 @@ rustbox search reindex Ready Docker examples are in `docker/`: standalone SQLite, PostgreSQL + SILO (S3-compatible) + OpenSearch, and MySQL + NFS + OpenSearch. -All executable application SQL is centralized by dialect under `src/queries/{sqlite,mysql,pgsql}/queries.rs`. The squashed compatibility baseline remains V046; RustBox 0.2.1 adds runtime migration V047 for attachment checksums/logical blob keys and the durable search outbox, RustBox 0.2.8 adds V048 for monotonic folder-counter generations used by race-safe IMAP counter reconciliation, and RustBox 0.2.13 adds V049 for durable blob deletion retries. The next migration number is V050. See `migrations/README.md`. +All executable application SQL is centralized by dialect under `src/queries/{sqlite,mysql,pgsql}/queries.rs`. The squashed compatibility baseline remains V046; RustBox 0.2.1 adds runtime migration V047 for attachment checksums/logical blob keys and the durable search outbox, RustBox 0.2.8 adds V048 for monotonic folder-counter generations used by race-safe IMAP counter reconciliation, RustBox 0.2.13 adds V049 for durable blob deletion retries, and RustBox 0.2.14 adds V050 for compact wide message-row preferences and per-identity signature insertion mode. The next migration number is V051. See `migrations/README.md`. ## Development @@ -237,7 +237,7 @@ Initialize Git after extracting the release archive: ```bash git init git add . -git commit -m "RustBox 0.2.13 release" +git commit -m "RustBox 0.2.14 release" ./scripts/setup-upstream.sh ``` diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 361814e..6060d97 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,6 +1,8 @@ -RustBox 0.2.13 release manifest +RustBox 0.2.14 release manifest -0.2.13 feature/configuration changes: +0.2.14 feature/configuration changes: +- defaults wide message panes to compact single-line rows, with an Appearance setting to disable the behavior +- adds per-identity signature insertion mode: silent append-on-send (default) or visible/editable in the composer - adds a durable SQL blob-deletion outbox used by filesystem/shared POSIX/NFS and S3 backends; attachment/draft cleanup is transactionally queued and retried until successful - failed finalized uploads and failed destination objects from storage migration are also queued for cleanup; storage health probes reuse a fixed key to avoid unbounded probe-object buildup - suppresses realtime “new message” toast/system notifications for Drafts saves while preserving normal folder/list refresh and `\Draft` + `\Seen` IMAP flags @@ -26,7 +28,7 @@ RustBox 0.2.13 release manifest - 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 -- adds runtime schema migration V049 for durable `blob_delete_outbox`; V050 remains next +- adds runtime schema migration V050 for `mail_preferences.compact_wide_rows` and `identities.signature_insert_mode`; V051 remains next Architecture: - database_backend = sqlite | mysql | pgsql selects authoritative SQL only - blob storage and search are independent from the SQL engine @@ -54,7 +56,8 @@ Database/schema: - runtime migration V047 adds attachment checksum metadata and search_outbox - runtime migration V048 adds `folders.counter_generation` for race-safe authoritative IMAP counter writes - runtime migration V049 adds `blob_delete_outbox` for durable physical blob cleanup -- next migration number: V050 +- runtime migration V050 adds compact-wide-row preference and per-identity signature insertion mode +- next migration number: V051 Docker examples: - docker/compose-single-sqlite.yml @@ -64,14 +67,14 @@ Docker examples: Operations/docs: - standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets -- README.md documents 0.2.13 configuration +- README.md documents 0.2.14 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.13 +- Cargo.toml version = 0.2.14 - 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 924a5e7..5008757 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,6 +1,8 @@ -RustBox 0.2.13 - final source validation +RustBox 0.2.14 - final source validation -0.2.13 scope: +0.2.14 scope: +- wide message panes use compact one-line rows by default, with a persisted Mailbox preference to disable them +- signatures support per-identity silent append-on-send (default) or visible/editable composer insertion - durable blob deletion outbox covers filesystem/shared POSIX/NFS and S3, pairing metadata deletion with cleanup enqueue in one SQL transaction and retrying storage failures without permanent dead-lettering - failed finalized uploads and failed storage-migration destination objects are queued for cleanup - saving/updating a draft stays silent in realtime: Drafts still refreshes, but no “new message” toast/system notification is emitted @@ -22,17 +24,17 @@ RustBox 0.2.13 - final source validation - 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 -- runtime schema migration V049 adds `blob_delete_outbox`; V050 remains next -- version metadata updated to 0.2.13 +- runtime schema migration V050 adds compact-wide-row and signature insertion preferences; V051 remains next +- version metadata updated to 0.2.14 Completed in this environment: -- offline release validator: scripts/validate-release-static.py (396/396 checks passed) +- offline release validator: scripts/validate-release-static.py (409/409 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.13 -- V048/V049 migration files/catalog registration and SQLite upgrade execution are validated offline +- release metadata consistently identifies 0.2.14 +- V048/V049/V050 migration files/catalog registration and SQLite upgrade execution are validated offline Not executable in this packaging environment: diff --git a/config.example.toml b/config.example.toml index 84dfff6..36f77ed 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,4 +1,4 @@ -# RustBox 0.2.13 +# RustBox 0.2.14 host = "127.0.0.1" port = 3001 environment = "production" diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 5a14127..ba81053 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,10 +1,12 @@ -# RustBox 0.2.13 - implementation status +# RustBox 0.2.14 - implementation status - Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification. -This file tracks the `0.2.13` 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.14` 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.13 browser mail UX +## 0.2.14 browser mail UX +- Wide message panes use a default-on compact single-line row layout (sender, subject/snippet, date and icons); users can disable it in Appearance settings. +- Each identity can choose signature insertion: `silent` appends only during send and keeps the editor clean, while `visible` inserts an editable/removable signature into the composer. - Large To/Cc lists collapse to a compact recipient summary and expand on demand. - Message pagination exposes both `Load more` and `Load all`; both actions stretch evenly across the full list width, and the reader menu label is shortened to `Details`. - The composer can collapse From/To/Cc/Bcc/Subject/options from its header so the editor receives the freed vertical space; invalid recipient input automatically reopens the fields. @@ -24,7 +26,7 @@ This file tracks the `0.2.13` source snapshot. Storage/search scope and outstand - 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. - Blob deletion uses a durable SQL outbox for filesystem/shared POSIX/NFS and S3 backends; attachment/draft metadata deletion is transactionally paired with cleanup enqueue and failed deletes retry in the background. -- Runtime migration V049 adds `blob_delete_outbox` on SQLite, MySQL and PostgreSQL; V050 is next. +- Runtime migration V050 adds `mail_preferences.compact_wide_rows` and `identities.signature_insert_mode` on SQLite, MySQL and PostgreSQL; V051 is next. ## 0.2.8 browser security headers @@ -44,7 +46,7 @@ This file tracks the `0.2.13` source snapshot. Storage/search scope and outstand - Draft attachment upload/read/delete/snapshot/send paths use the `BlobStorage` abstraction and logical object keys instead of assuming node-local paths. - OpenSearch is available as the cluster-capable indexer. Local Tantivy remains single-node only; search may also be disabled. - Search mutations use a durable SQL outbox with claim/lease, retry/backoff and dead-letter state; the index remains rebuildable derived state. -- V047 adds attachment checksum metadata and the search outbox across SQLite, MySQL and PostgreSQL. V048 adds monotonic folder-counter generations used by compare-and-set reconciliation of authoritative IMAP counts. V049 adds the durable blob deletion outbox; V050 is reserved as the next migration number. +- V047 adds attachment checksum metadata and the search outbox across SQLite, MySQL and PostgreSQL. V048 adds monotonic folder-counter generations used by compare-and-set reconciliation of authoritative IMAP counts. V049 adds the durable blob deletion outbox. V050 adds compact-wide-row and signature insertion preferences; V051 is reserved as the next migration number. - SQL global-schema initialization is centralized in `db::pool::configure()`; database-backed sessions do not repeat startup DDL for SQLite, MySQL or PostgreSQL. - Docker Compose examples cover standalone SQLite, PostgreSQL + SILO (S3-compatible) + OpenSearch, and MySQL + NFS + OpenSearch. - Live SILO/MinIO/AWS S3/Ceph RGW, live NFS multi-host, live OpenSearch and full multi-node failover acceptance remain release-environment validation items and are not represented as completed here. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3338f1c..831f0b9 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.13 +## Updating an existing checkout to 0.2.14 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 d9cc5f1..2d123af 100644 --- a/docs/STORAGE_SEARCH_OPERATIONS.md +++ b/docs/STORAGE_SEARCH_OPERATIONS.md @@ -1,6 +1,6 @@ -# RustBox 0.2.13 storage/search operations +# RustBox 0.2.14 storage/search operations -RustBox 0.2.13 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed. +RustBox 0.2.14 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 bfda70a..88502d2 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -1,6 +1,6 @@ # Database schemas and migrations -RustBox 0.2.13 keeps SQL, blob storage and search as independent concerns: +RustBox 0.2.14 keeps SQL, blob storage and search as independent concerns: ```text database_backend = sqlite | mysql | pgsql @@ -19,20 +19,23 @@ migrations/ │ ├── schema.sql │ ├── V047__blob_keys_and_search_outbox.sql │ ├── V048__folder_counter_generation.sql -│ └── V049__blob_delete_outbox.sql +│ ├── V049__blob_delete_outbox.sql +│ └── V050__compact_rows_and_signature_mode.sql ├── mysql/ │ ├── schema.sql │ ├── V047__blob_keys_and_search_outbox.sql │ ├── V048__folder_counter_generation.sql -│ └── V049__blob_delete_outbox.sql +│ ├── V049__blob_delete_outbox.sql +│ └── V050__compact_rows_and_signature_mode.sql └── pgsql/ ├── schema.sql ├── V047__blob_keys_and_search_outbox.sql ├── V048__folder_counter_generation.sql - └── V049__blob_delete_outbox.sql + ├── V049__blob_delete_outbox.sql + └── V050__compact_rows_and_signature_mode.sql ``` -Each `schema.sql` is the current complete schema for fresh installations. The historical compatibility baseline remains **V046**. RustBox 0.2.1 adds **V047**, which adds attachment SHA-256 metadata and the durable search outbox used by external indexing. RustBox 0.2.8 adds **V048**, which adds the monotonic `folders.counter_generation` used to reject stale IMAP counter writes even when the numeric counts temporarily return to an earlier value. RustBox 0.2.13 adds **V049**, the durable `blob_delete_outbox` used to retry physical object deletion after metadata is removed. +Each `schema.sql` is the current complete schema for fresh installations. The historical compatibility baseline remains **V046**. RustBox 0.2.1 adds **V047**, which adds attachment SHA-256 metadata and the durable search outbox used by external indexing. RustBox 0.2.8 adds **V048**, which adds the monotonic `folders.counter_generation` used to reject stale IMAP counter writes even when the numeric counts temporarily return to an earlier value. RustBox 0.2.13 adds **V049**, the durable `blob_delete_outbox` used to retry physical object deletion after metadata is removed. RustBox 0.2.14 adds **V050**, which stores the default-on compact wide-row preference and each identity's signature insertion mode (`silent` or `visible`). Account data is isolated inside the selected physical database with a deterministic, collision-checked table prefix derived from the account hash. The `{{p}}` marker in tenant SQL is replaced by RustBox at provisioning/migration time. Sessions are global and therefore are not prefixed. @@ -40,7 +43,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.13 adds runtime migration **V049** on SQLite, MySQL and PostgreSQL. **V050** is the next migration number. +RustBox 0.2.14 adds runtime migration **V050** on SQLite, MySQL and PostgreSQL. **V051** is the next migration number. Redis and Redis Sentinel do not use SQL migrations. diff --git a/migrations/mysql/V050__compact_rows_and_signature_mode.sql b/migrations/mysql/V050__compact_rows_and_signature_mode.sql new file mode 100644 index 0000000..905e9a2 --- /dev/null +++ b/migrations/mysql/V050__compact_rows_and_signature_mode.sql @@ -0,0 +1,5 @@ +ALTER TABLE {{p}}mail_preferences + ADD COLUMN compact_wide_rows BIGINT NOT NULL DEFAULT 1; + +ALTER TABLE {{p}}identities + ADD COLUMN signature_insert_mode VARCHAR(16) NOT NULL DEFAULT 'silent'; diff --git a/migrations/mysql/schema.sql b/migrations/mysql/schema.sql index f4c4e67..ad67033 100644 --- a/migrations/mysql/schema.sql +++ b/migrations/mysql/schema.sql @@ -1,7 +1,7 @@ -- RustBox 0.2.0 MySQL schema. -- One physical database contains global sessions plus isolated per-account tables. -- Application tables are materialized by replacing {{p}} with a collision-checked prefix. --- Squashed compatibility baseline: V046. Current runtime schema: V049. +-- Squashed compatibility baseline: V046. Current runtime schema: V050. -- RUSTBOX:GLOBAL-BEGIN CREATE TABLE IF NOT EXISTS rustbox_accounts ( @@ -281,6 +281,7 @@ CREATE TABLE {{p}}identities ( signature_reply BIGINT NOT NULL DEFAULT 1, signature_forward BIGINT NOT NULL DEFAULT 1, signature_position TEXT NOT NULL DEFAULT ('above'), + signature_insert_mode VARCHAR(16) NOT NULL DEFAULT ('silent'), pgp_fingerprint TEXT, pgp_sign_default BIGINT NOT NULL DEFAULT 0, pgp_encrypt_default BIGINT NOT NULL DEFAULT 0, @@ -304,7 +305,8 @@ CREATE TABLE {{p}}mail_preferences ( default_sort TEXT NOT NULL DEFAULT ('date_desc'), updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), flagged_on_top BIGINT NOT NULL DEFAULT 0, - sync_imap_keywords BIGINT NOT NULL DEFAULT 0 + sync_imap_keywords BIGINT NOT NULL DEFAULT 0, + compact_wide_rows BIGINT NOT NULL DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE {{p}}meeting_templates ( diff --git a/migrations/pgsql/V050__compact_rows_and_signature_mode.sql b/migrations/pgsql/V050__compact_rows_and_signature_mode.sql new file mode 100644 index 0000000..55d7e02 --- /dev/null +++ b/migrations/pgsql/V050__compact_rows_and_signature_mode.sql @@ -0,0 +1,5 @@ +ALTER TABLE {{p}}mail_preferences + ADD COLUMN compact_wide_rows BIGINT NOT NULL DEFAULT 1; + +ALTER TABLE {{p}}identities + ADD COLUMN signature_insert_mode TEXT NOT NULL DEFAULT 'silent'; diff --git a/migrations/pgsql/schema.sql b/migrations/pgsql/schema.sql index dba981f..93deb25 100644 --- a/migrations/pgsql/schema.sql +++ b/migrations/pgsql/schema.sql @@ -1,7 +1,7 @@ -- RustBox 0.2.0 PostgreSQL schema. -- One physical database contains global sessions plus isolated per-account tables. -- Application tables are materialized by replacing {{p}} with a collision-checked prefix. --- Squashed compatibility baseline: V046. Current runtime schema: V049. +-- Squashed compatibility baseline: V046. Current runtime schema: V050. -- RUSTBOX:GLOBAL-BEGIN CREATE TABLE IF NOT EXISTS rustbox_accounts ( @@ -283,6 +283,7 @@ CREATE TABLE {{p}}identities ( signature_reply BIGINT NOT NULL DEFAULT 1, signature_forward BIGINT NOT NULL DEFAULT 1, signature_position TEXT NOT NULL DEFAULT 'above', + signature_insert_mode TEXT NOT NULL DEFAULT 'silent', pgp_fingerprint TEXT, pgp_sign_default BIGINT NOT NULL DEFAULT 0, pgp_encrypt_default BIGINT NOT NULL DEFAULT 0, @@ -306,7 +307,8 @@ CREATE TABLE {{p}}mail_preferences ( default_sort TEXT NOT NULL DEFAULT 'date_desc', updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), flagged_on_top BIGINT NOT NULL DEFAULT 0, - sync_imap_keywords BIGINT NOT NULL DEFAULT 0 + sync_imap_keywords BIGINT NOT NULL DEFAULT 0, + compact_wide_rows BIGINT NOT NULL DEFAULT 1 ); CREATE TABLE {{p}}meeting_templates ( diff --git a/migrations/sqlite/V050__compact_rows_and_signature_mode.sql b/migrations/sqlite/V050__compact_rows_and_signature_mode.sql new file mode 100644 index 0000000..259ad58 --- /dev/null +++ b/migrations/sqlite/V050__compact_rows_and_signature_mode.sql @@ -0,0 +1,5 @@ +ALTER TABLE {{p}}mail_preferences + ADD COLUMN compact_wide_rows INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE {{p}}identities + ADD COLUMN signature_insert_mode TEXT NOT NULL DEFAULT 'silent'; diff --git a/migrations/sqlite/schema.sql b/migrations/sqlite/schema.sql index c37b994..7bd4f84 100644 --- a/migrations/sqlite/schema.sql +++ b/migrations/sqlite/schema.sql @@ -1,7 +1,7 @@ -- RustBox 0.2.0 SQLite schema. -- One physical database contains global sessions plus isolated per-account tables. -- Application tables are materialized from the tenant section by replacing {{p}}. --- Squashed compatibility baseline: V046. Current runtime schema: V049. +-- Squashed compatibility baseline: V046. Current runtime schema: V050. -- RUSTBOX:GLOBAL-BEGIN CREATE TABLE IF NOT EXISTS rustbox_accounts ( @@ -210,7 +210,7 @@ CREATE TABLE {{p}}identities ( is_default INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), - reply_to TEXT NOT NULL DEFAULT '', bcc TEXT NOT NULL DEFAULT '', organization TEXT NOT NULL DEFAULT '', signature_text TEXT NOT NULL DEFAULT '', signature_new INTEGER NOT NULL DEFAULT 1, signature_reply INTEGER NOT NULL DEFAULT 1, signature_forward INTEGER NOT NULL DEFAULT 1, signature_position TEXT NOT NULL DEFAULT 'above', pgp_fingerprint TEXT, pgp_sign_default INTEGER NOT NULL DEFAULT 0, pgp_encrypt_default INTEGER NOT NULL DEFAULT 0, pgp_inline_default INTEGER NOT NULL DEFAULT 0); + reply_to TEXT NOT NULL DEFAULT '', bcc TEXT NOT NULL DEFAULT '', organization TEXT NOT NULL DEFAULT '', signature_text TEXT NOT NULL DEFAULT '', signature_new INTEGER NOT NULL DEFAULT 1, signature_reply INTEGER NOT NULL DEFAULT 1, signature_forward INTEGER NOT NULL DEFAULT 1, signature_position TEXT NOT NULL DEFAULT 'above', signature_insert_mode TEXT NOT NULL DEFAULT 'silent', pgp_fingerprint TEXT, pgp_sign_default INTEGER NOT NULL DEFAULT 0, pgp_encrypt_default INTEGER NOT NULL DEFAULT 0, pgp_inline_default INTEGER NOT NULL DEFAULT 0); CREATE TABLE {{p}}known_addresses ( email TEXT NOT NULL, @@ -229,7 +229,7 @@ CREATE TABLE {{p}}mail_preferences ( show_snippets INTEGER NOT NULL DEFAULT 1, default_sort TEXT NOT NULL DEFAULT 'date_desc', updated_at TEXT NOT NULL DEFAULT (datetime('now')), - flagged_on_top INTEGER NOT NULL DEFAULT 0, sync_imap_keywords INTEGER NOT NULL DEFAULT 0); + flagged_on_top INTEGER NOT NULL DEFAULT 0, sync_imap_keywords INTEGER NOT NULL DEFAULT 0, compact_wide_rows INTEGER NOT NULL DEFAULT 1); CREATE TABLE {{p}}meeting_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/packaging/rustbox.toml b/packaging/rustbox.toml index b3c40bc..d36fc18 100644 --- a/packaging/rustbox.toml +++ b/packaging/rustbox.toml @@ -1,4 +1,4 @@ -# RustBox 0.2.13 - packaged production configuration (DEB/RPM) +# RustBox 0.2.14 - 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 e8cb95f..f14b302 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.13. +"""Static/offline release checks for RustBox 0.2.14. These checks intentionally avoid pretending to replace `cargo test`: they validate configuration, SQL migrations, Docker examples, query-catalog parity and release @@ -49,14 +49,14 @@ 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.13", "Cargo package version is 0.2.13") +require(cargo["package"]["version"] == "0.2.14", "Cargo package version is 0.2.14") 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.13", "Cargo.lock RustBox package version is 0.2.13") +require(lock_rustbox.get("version") == "0.2.14", "Cargo.lock RustBox package version is 0.2.14") 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") @@ -71,10 +71,10 @@ 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.13"), "README identifies RustBox 0.2.13") -require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.13"), "release manifest identifies RustBox 0.2.13") -require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.13"), "release validation identifies RustBox 0.2.13") -require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.13"), "implementation status identifies RustBox 0.2.13") +require(read("README.md").startswith("# RustBox 0.2.14"), "README identifies RustBox 0.2.14") +require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.14"), "release manifest identifies RustBox 0.2.14") +require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.14"), "release validation identifies RustBox 0.2.14") +require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.14"), "implementation status identifies RustBox 0.2.14") 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") @@ -108,6 +108,14 @@ require("const attachments = mergeComposeAttachments(state.composeAttachments);" require('grid-template-columns: repeat(2, minmax(0, 1fr));' in mail_css and '.load-more {' in mail_css and 'width: 100%;' in mail_css.split('.load-more {', 1)[1].split('}', 1)[0], "message load actions share the full available list width") require('id="compose-fields-toggle"' in read("src/web/index.html") and 'function setComposeFieldsCollapsed' in compose_js and 'function toggleComposeFields' in compose_js and '.compose-dialog.compose-fields-collapsed .compose-addresses' in compose_css and 'setComposeFieldsCollapsed(false)' in bindings_js, "composer addressing fields can collapse to maximize editor space and reopen on invalid recipient input") require('id="compose-save-draft"' in read("src/web/index.html") and '$("#compose-save-draft").addEventListener("click", saveComposeDraftToImap);' in bindings_js and 'saveCurrentDraft(false, true, true)' in compose_js, "composer exposes an explicit manual IMAP draft save before cancel") +require('data-panel="appearance"' in read("src/web/index.html") and 'id="pref-compact-wide-rows"' in read("src/web/index.html") and 'id="mail-pref-compact-wide-rows"' not in read("src/web/index.html") and 'saveCompactWideRowsAppearance' in auth_js and 'compact_wide_rows: true' in bootstrap_js and 'data-compact-wide-rows="true"' not in read("src/web/index.html") and '@container message-pane (min-width: 520px)' in mail_css, "wide message-row compaction is an Appearance preference, persisted, default-on, and width-gated") +display_prefs_rs = read("src/db/display_preferences.rs") +index_html = read("src/web/index.html") +require('bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760)' in bindings_js and 'value.clamp(300, 760).to_db_value()' in display_prefs_rs and 'id="pref-message-width" type="range"\n min="300" max="760"' in index_html, "message pane resize limits are aligned at 300..760 px and normalized server-side") +require('bindPaneResizer("#folder-resizer", "folderPaneWidth", 180, 420)' in bindings_js and 'value.clamp(180, 420).to_db_value()' in display_prefs_rs and 'id="pref-folder-width" type="range"\n min="180" max="420"' in index_html, "folder pane resize limits are aligned at 180..420 px and normalized server-side") +require('suppressPaneWidthValidationError' in auth_js and 'temporarilyDisablePanePreference' in auth_js and 'data.temporarilyDisabled' not in auth_js and 'handle.dataset.temporarilyDisabled = "true"' in auth_js and 'if (!suppressPaneWidthValidationError(error)) toast(error.message, "error")' in auth_js, "pane-width validation incompatibilities disable the in-view resizer without a user-facing error") +require('id="identity-signature-mode"' in read("src/web/index.html") and 'value="silent"' in read("src/web/index.html") and 'value="visible"' in read("src/web/index.html") and 'signature_insert_mode' in read("src/web/js/72-openpgp-identities.js"), "identity UI exposes silent and visible signature insertion modes") +require('identity.signature_insert_mode != "visible"' in read("src/routes/send.rs") and 'data-rustbox-signature' in read("src/web/js/72-openpgp-identities.js"), "silent signatures append server-side while visible signatures remain composer-owned and de-duplicatable") require('id="compose-delete-draft"' in read("src/web/index.html") and '$("#compose-delete-draft").addEventListener("click", deleteComposeDraft);' in bindings_js and 'function deleteComposeDraft()' in compose_js and 'imap_uid_validity' in compose_js, "composer exposes permanent draft deletion with current IMAP coordinates") require('if req.sync_imap {' in read("src/routes/drafts.rs") and 'Failed to save draft to IMAP' in read("src/routes/drafts.rs") and 'AppError::ServiceUnavailable' in read("src/routes/drafts.rs"), "manual IMAP draft save fails visibly when the IMAP snapshot cannot be appended") drafts_rs = read("src/routes/drafts.rs") @@ -124,8 +132,8 @@ require('encoding_rs::Encoding::for_label' in read("src/imap/parse.rs") and 'dec 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("V049" in read("RELEASE-MANIFEST.txt") and "V050" in read("RELEASE-MANIFEST.txt"), "release manifest documents V049 and reserves V050") -require("V049" in read("migrations/README.md") and "V050" in read("migrations/README.md"), "migration guide documents V049 and reserves V050") +require("V050" in read("RELEASE-MANIFEST.txt") and "V051" in read("RELEASE-MANIFEST.txt"), "release manifest documents V050 and reserves V051") +require("V050" in read("migrations/README.md") and "V051" in read("migrations/README.md"), "migration guide documents V050 and reserves V051") require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists") require('imap_host = ""' not in read("config.dev.toml"), "development config does not override IMAP host with an invalid empty value") require(not re.search(r"(?m)^IMAP_HOST=\s*$", read(".env.example")), "environment example does not set an invalid empty IMAP host") @@ -612,13 +620,35 @@ try: except Exception as exc: errors.append(f"SQLite V049 migration execution failed: {exc}") +# V050 adds compact-wide-row and signature insertion preferences. +try: + conn = sqlite3.connect(":memory:") + conn.executescript( + "CREATE TABLE u_test__mail_preferences (id INTEGER PRIMARY KEY);" + "CREATE TABLE u_test__identities (id INTEGER PRIMARY KEY);" + ) + migration = read("migrations/sqlite/V050__compact_rows_and_signature_mode.sql").replace("{{p}}", "u_test__") + conn.executescript(migration) + mail_columns = {row[1]: row for row in conn.execute("PRAGMA table_info(u_test__mail_preferences)")} + identity_columns = {row[1]: row for row in conn.execute("PRAGMA table_info(u_test__identities)")} + require("compact_wide_rows" in mail_columns and str(mail_columns["compact_wide_rows"][4]) == "1", "V050 defaults compact wide rows on in SQLite") + require("signature_insert_mode" in identity_columns and identity_columns["signature_insert_mode"][4] == "'silent'", "V050 defaults identity signature insertion to silent in SQLite") + conn.execute("INSERT INTO u_test__mail_preferences(id) VALUES (1)") + conn.execute("INSERT INTO u_test__identities(id) VALUES (1)") + require(conn.execute("SELECT compact_wide_rows FROM u_test__mail_preferences WHERE id=1").fetchone()[0] == 1, "V050 applies compact-row default to existing/new SQLite rows") + require(conn.execute("SELECT signature_insert_mode FROM u_test__identities WHERE id=1").fetchone()[0] == "silent", "V050 applies silent-signature default to existing/new SQLite rows") + conn.close() +except Exception as exc: + errors.append(f"SQLite V050 migration execution failed: {exc}") + # Migration/query parity across SQL engines. for dialect in ("sqlite", "mysql", "pgsql"): require((ROOT / f"migrations/{dialect}/V047__blob_keys_and_search_outbox.sql").is_file(), f"V047 migration exists for {dialect}") require((ROOT / f"migrations/{dialect}/V048__folder_counter_generation.sql").is_file(), f"V048 migration exists for {dialect}") require((ROOT / f"migrations/{dialect}/V049__blob_delete_outbox.sql").is_file(), f"V049 migration exists for {dialect}") + require((ROOT / f"migrations/{dialect}/V050__compact_rows_and_signature_mode.sql").is_file(), f"V050 migration exists for {dialect}") schema_text = read(f"migrations/{dialect}/schema.sql") - require("Current runtime schema: V049" in schema_text and "counter_generation" in schema_text and "blob_delete_outbox" in schema_text, f"fresh {dialect} schema includes V049 blob deletion outbox") + require("Current runtime schema: V050" in schema_text and "counter_generation" in schema_text and "blob_delete_outbox" in schema_text and "compact_wide_rows" in schema_text and "signature_insert_mode" in schema_text, f"fresh {dialect} schema includes V050 compact-row/signature preferences") queries = read(f"src/queries/{dialect}/queries.rs") folder_block = re.search(r"pub mod db_folders\s*\{(.*?)\n\}", queries, re.S) require(folder_block is not None and "Q_032" in folder_block.group(1) and "counter_generation = counter_generation + 1" in folder_block.group(1), f"folder counter CAS increments generation for {dialect}") @@ -638,6 +668,7 @@ for dialect in ("SQLITE", "MYSQL", "PGSQL"): require(f"{dialect}_MIGRATIONS" in pool and "V047__blob_keys_and_search_outbox.sql" in pool, f"runtime migration catalog includes V047 ({dialect.lower()})") require(f"{dialect}_MIGRATIONS" in pool and "V048__folder_counter_generation.sql" in pool, f"runtime migration catalog includes V048 ({dialect.lower()})") require(f"{dialect}_MIGRATIONS" in pool and "V049__blob_delete_outbox.sql" in pool, f"runtime migration catalog includes V049 ({dialect.lower()})") + require(f"{dialect}_MIGRATIONS" in pool and "V050__compact_rows_and_signature_mode.sql" in pool, f"runtime migration catalog includes V050 ({dialect.lower()})") require("const BASE_SCHEMA_VERSION: u32 = 46" in pool, "legacy squashed baseline stays V046") require("latest_schema_version" in pool, "runtime schema version is computed from migrations") require( diff --git a/src/db/display_preferences.rs b/src/db/display_preferences.rs index dd24e8c..b9abfa5 100644 --- a/src/db/display_preferences.rs +++ b/src/db/display_preferences.rs @@ -414,12 +414,6 @@ pub fn update_preferences( if let Some(v) = data.row_height { range("row_height", v, 42, 88)?; } - if let Some(v) = data.folder_pane_width { - range("folder_pane_width", v, 180, 360)?; - } - if let Some(v) = data.message_pane_width { - range("message_pane_width", v, 300, 720)?; - } if let Some(v) = data.border_radius { range("border_radius", v, 0, 12)?; } @@ -505,8 +499,16 @@ pub fn update_preferences( set_text!(font_family, "font_family"); set_int!(font_size, "font_size"); set_int!(row_height, "row_height"); - set_int!(folder_pane_width, "folder_pane_width"); - set_int!(message_pane_width, "message_pane_width"); + if let Some(value) = data.folder_pane_width { + sets.push(format!("folder_pane_width = ?{idx}")); + values.push(value.clamp(180, 420).to_db_value()); + idx += 1; + } + if let Some(value) = data.message_pane_width { + sets.push(format!("message_pane_width = ?{idx}")); + values.push(value.clamp(300, 760).to_db_value()); + idx += 1; + } set_int!(border_radius, "border_radius"); set_text!(contrast, "contrast"); set_bool!(show_avatars, "show_avatars"); @@ -578,6 +580,28 @@ mod tests { assert!(prefs.preset_scale_unlocked); } + #[test] + fn pane_widths_are_clamped_instead_of_rejected() { + let conn = open_test_db(); + let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({ + "folder_pane_width": 9999, + "message_pane_width": 9999 + })) + .unwrap(); + let prefs = update_preferences(&conn, &update).unwrap(); + assert_eq!(prefs.folder_pane_width, 420); + assert_eq!(prefs.message_pane_width, 760); + + let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({ + "folder_pane_width": -1, + "message_pane_width": -1 + })) + .unwrap(); + let prefs = update_preferences(&conn, &update).unwrap(); + assert_eq!(prefs.folder_pane_width, 180); + assert_eq!(prefs.message_pane_width, 300); + } + #[test] fn built_in_interface_skins_are_accepted() { for skin in [ diff --git a/src/db/folder_preferences.rs b/src/db/folder_preferences.rs index e6d6c8f..9dac327 100644 --- a/src/db/folder_preferences.rs +++ b/src/db/folder_preferences.rs @@ -116,6 +116,7 @@ mod tests { default_sort: None, flagged_on_top: Some(true), sync_imap_keywords: None, + compact_wide_rows: None, }, ) .unwrap(); diff --git a/src/db/identities.rs b/src/db/identities.rs index 491b3a6..5729c37 100644 --- a/src/db/identities.rs +++ b/src/db/identities.rs @@ -17,6 +17,7 @@ pub struct Identity { pub signature_reply: bool, pub signature_forward: bool, pub signature_position: String, + pub signature_insert_mode: String, pub pgp_fingerprint: Option, pub pgp_sign_default: bool, pub pgp_encrypt_default: bool, @@ -50,6 +51,8 @@ pub struct CreateIdentity { pub signature_forward: bool, #[serde(default = "default_signature_position")] pub signature_position: String, + #[serde(default = "default_signature_insert_mode")] + pub signature_insert_mode: String, #[serde(default)] pub pgp_fingerprint: Option, #[serde(default)] @@ -74,6 +77,7 @@ pub struct UpdateIdentity { pub signature_reply: Option, pub signature_forward: Option, pub signature_position: Option, + pub signature_insert_mode: Option, pub pgp_fingerprint: Option>, pub pgp_sign_default: Option, pub pgp_encrypt_default: Option, @@ -87,6 +91,18 @@ fn default_signature_position() -> String { "above".to_string() } +fn default_signature_insert_mode() -> String { + "silent".to_string() +} + +fn validate_signature_insert_mode(value: &str) -> Result<(), String> { + if matches!(value, "silent" | "visible") { + Ok(()) + } else { + Err(format!("Invalid signature_insert_mode: {value}")) + } +} + fn validate_signature_position(value: &str) -> Result<(), String> { if matches!(value, "above" | "below") { Ok(()) @@ -112,10 +128,11 @@ fn row_to_identity(row: &crate::db::pool::Row<'_>) -> Result(12)? != 0, signature_forward: row.get::<_, i32>(13)? != 0, signature_position: row.get(14)?, - pgp_fingerprint: row.get(15)?, - pgp_sign_default: row.get::<_, i32>(16)? != 0, - pgp_encrypt_default: row.get::<_, i32>(17)? != 0, - pgp_inline_default: row.get::<_, i32>(18)? != 0, + signature_insert_mode: row.get(15)?, + pgp_fingerprint: row.get(16)?, + pgp_sign_default: row.get::<_, i32>(17)? != 0, + pgp_encrypt_default: row.get::<_, i32>(18)? != 0, + pgp_inline_default: row.get::<_, i32>(19)? != 0, }) } @@ -155,6 +172,7 @@ pub fn get_default_identity(conn: &Connection) -> Result, Strin pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result { validate_signature_position(&data.signature_position)?; + validate_signature_insert_mode(&data.signature_insert_mode)?; if data.is_default { clear_default(conn)?; } @@ -174,6 +192,7 @@ pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result = Vec::new(); let mut idx = 1usize; @@ -231,6 +253,7 @@ pub fn update_identity( boolean!(signature_reply, "signature_reply"); boolean!(signature_forward, "signature_forward"); text!(signature_position, "signature_position"); + text!(signature_insert_mode, "signature_insert_mode"); if let Some(ref value) = data.pgp_fingerprint { sets.push(format!("pgp_fingerprint = ?{idx}")); values.push(value.to_db_value()); @@ -293,6 +316,7 @@ mod tests { signature_reply: true, signature_forward: true, signature_position: "above".into(), + signature_insert_mode: "silent".into(), pgp_fingerprint: None, pgp_sign_default: false, pgp_encrypt_default: false, @@ -313,6 +337,7 @@ mod tests { assert_eq!(identity.bcc, "archive@example.com"); assert_eq!(identity.organization, "Example"); assert_eq!(identity.signature_position, "below"); + assert_eq!(identity.signature_insert_mode, "silent"); } #[test] @@ -324,6 +349,14 @@ mod tests { assert!(!get_identity(&conn, first.id).unwrap().unwrap().is_default); } + #[test] + fn rejects_invalid_signature_insert_mode() { + let conn = open_test_db(); + let mut value = sample("a@example.com", true); + value.signature_insert_mode = "magic".into(); + assert!(create_identity(&conn, &value).is_err()); + } + #[test] fn rejects_invalid_signature_position() { let conn = open_test_db(); diff --git a/src/db/mail_preferences.rs b/src/db/mail_preferences.rs index 876b3b1..bb8fc16 100644 --- a/src/db/mail_preferences.rs +++ b/src/db/mail_preferences.rs @@ -13,6 +13,7 @@ pub struct MailPreferences { pub default_sort: String, pub flagged_on_top: bool, pub sync_imap_keywords: bool, + pub compact_wide_rows: bool, pub updated_at: String, } @@ -28,6 +29,7 @@ pub struct UpdateMailPreferences { pub default_sort: Option, pub flagged_on_top: Option, pub sync_imap_keywords: Option, + pub compact_wide_rows: Option, } pub fn get_preferences(conn: &Connection) -> Result { @@ -43,7 +45,8 @@ pub fn get_preferences(conn: &Connection) -> Result { default_sort: row.get(7)?, flagged_on_top: row.get::<_, i32>(8)? != 0, sync_imap_keywords: row.get::<_, i32>(9)? != 0, - updated_at: row.get(10)?, + compact_wide_rows: row.get::<_, i32>(10)? != 0, + updated_at: row.get(11)?, }) }) .map_err(|error| format!("Failed to read mail preferences: {error}")) @@ -121,6 +124,7 @@ pub fn update_preferences( let sync_imap_keywords = data .sync_imap_keywords .unwrap_or(current.sync_imap_keywords); + let compact_wide_rows = data.compact_wide_rows.unwrap_or(current.compact_wide_rows); conn.execute( crate::dbq!(db_mail_preferences::Q_003), @@ -134,7 +138,8 @@ pub fn update_preferences( show_snippets as i32, default_sort, flagged_on_top as i32, - sync_imap_keywords as i32 + sync_imap_keywords as i32, + compact_wide_rows as i32 ], ) .map_err(|error| format!("Failed to update mail preferences: {error}"))?; @@ -167,6 +172,7 @@ mod tests { default_sort: Some("date_asc".to_string()), flagged_on_top: Some(true), sync_imap_keywords: Some(true), + compact_wide_rows: Some(false), }, ) .unwrap(); @@ -175,5 +181,6 @@ mod tests { assert_eq!(updated.thread_mode, "flat"); assert!(updated.flagged_on_top); assert!(updated.sync_imap_keywords); + assert!(!updated.compact_wide_rows); } } diff --git a/src/db/pool.rs b/src/db/pool.rs index bca51da..f184941 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -34,6 +34,10 @@ const SQLITE_MIGRATIONS: &[(u32, &str)] = &[ 49, include_str!("../../migrations/sqlite/V049__blob_delete_outbox.sql"), ), + ( + 50, + include_str!("../../migrations/sqlite/V050__compact_rows_and_signature_mode.sql"), + ), ]; const MYSQL_MIGRATIONS: &[(u32, &str)] = &[ ( @@ -48,6 +52,10 @@ const MYSQL_MIGRATIONS: &[(u32, &str)] = &[ 49, include_str!("../../migrations/mysql/V049__blob_delete_outbox.sql"), ), + ( + 50, + include_str!("../../migrations/mysql/V050__compact_rows_and_signature_mode.sql"), + ), ]; const PGSQL_MIGRATIONS: &[(u32, &str)] = &[ ( @@ -62,6 +70,10 @@ const PGSQL_MIGRATIONS: &[(u32, &str)] = &[ 49, include_str!("../../migrations/pgsql/V049__blob_delete_outbox.sql"), ), + ( + 50, + include_str!("../../migrations/pgsql/V050__compact_rows_and_signature_mode.sql"), + ), ]; const TENANT_TABLES: &[&str] = &[ diff --git a/src/db/settings.rs b/src/db/settings.rs index 1cb6ad1..ae12136 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -62,6 +62,7 @@ mod tests { default_sort: None, flagged_on_top: None, sync_imap_keywords: None, + compact_wide_rows: None, }, ) .unwrap(); diff --git a/src/queries/mysql/queries.rs b/src/queries/mysql/queries.rs index b8a3a22..212acaa 100644 --- a/src/queries/mysql/queries.rs +++ b/src/queries/mysql/queries.rs @@ -257,8 +257,8 @@ pub mod db_header_sync { } pub mod db_identities { - pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default"; - pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; + pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, signature_insert_mode, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default"; + pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,signature_insert_mode,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; pub const Q_002: &str = "DELETE FROM identities WHERE id = ?"; pub const Q_003: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM identities"; pub const Q_004: &str = @@ -272,10 +272,10 @@ pub mod db_identities { } pub mod db_mail_preferences { - pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1"; + pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1"; pub const Q_002: &str = "INSERT IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)"; - pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?, mark_read_delay_ms = ?, remote_images = ?, preferred_body = ?, thread_mode = ?, collapse_quotes = ?, show_snippets = ?, default_sort = ?, flagged_on_top = ?, sync_imap_keywords = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1"; + pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?, mark_read_delay_ms = ?, remote_images = ?, preferred_body = ?, thread_mode = ?, collapse_quotes = ?, show_snippets = ?, default_sort = ?, flagged_on_top = ?, sync_imap_keywords = ?, compact_wide_rows = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1"; } pub mod db_messages { diff --git a/src/queries/pgsql/queries.rs b/src/queries/pgsql/queries.rs index 453b27b..4c527ba 100644 --- a/src/queries/pgsql/queries.rs +++ b/src/queries/pgsql/queries.rs @@ -258,8 +258,8 @@ pub mod db_header_sync { } pub mod db_identities { - pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default"; - pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)"; + pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, signature_insert_mode, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default"; + pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,signature_insert_mode,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)"; pub const Q_002: &str = "DELETE FROM identities WHERE id = $1"; pub const Q_003: &str = "SELECT COUNT(*) FROM identities"; pub const Q_004: &str = "UPDATE identities SET is_default = 0, updated_at = CURRENT_TIMESTAMP::text WHERE is_default = 1"; @@ -272,9 +272,9 @@ pub mod db_identities { } pub mod db_mail_preferences { - pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1"; + pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1"; pub const Q_002: &str = "INSERT INTO mail_preferences (id, messages_per_page) VALUES (1, 300) ON CONFLICT (id) DO NOTHING"; - pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = $1, mark_read_delay_ms = $2, remote_images = $3, preferred_body = $4, thread_mode = $5, collapse_quotes = $6, show_snippets = $7, default_sort = $8, flagged_on_top = $9, sync_imap_keywords = $10, updated_at = CURRENT_TIMESTAMP::text WHERE id = 1"; + pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = $1, mark_read_delay_ms = $2, remote_images = $3, preferred_body = $4, thread_mode = $5, collapse_quotes = $6, show_snippets = $7, default_sort = $8, flagged_on_top = $9, sync_imap_keywords = $10, compact_wide_rows = $11, updated_at = CURRENT_TIMESTAMP::text WHERE id = 1"; } pub mod db_messages { diff --git a/src/queries/sqlite/queries.rs b/src/queries/sqlite/queries.rs index 7957d95..f25f78e 100644 --- a/src/queries/sqlite/queries.rs +++ b/src/queries/sqlite/queries.rs @@ -256,8 +256,8 @@ pub mod db_header_sync { } pub mod db_identities { - pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default"; - pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)"; + pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, signature_insert_mode, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default"; + pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,signature_insert_mode,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)"; pub const Q_002: &str = "DELETE FROM identities WHERE id = ?1"; pub const Q_003: &str = "SELECT COUNT(*) FROM identities"; pub const Q_004: &str = @@ -271,10 +271,10 @@ pub mod db_identities { } pub mod db_mail_preferences { - pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1"; + pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1"; pub const Q_002: &str = "INSERT OR IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)"; - pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?1, mark_read_delay_ms = ?2, remote_images = ?3, preferred_body = ?4, thread_mode = ?5, collapse_quotes = ?6, show_snippets = ?7, default_sort = ?8, flagged_on_top = ?9, sync_imap_keywords = ?10, updated_at = datetime('now') WHERE id = 1"; + pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?1, mark_read_delay_ms = ?2, remote_images = ?3, preferred_body = ?4, thread_mode = ?5, collapse_quotes = ?6, show_snippets = ?7, default_sort = ?8, flagged_on_top = ?9, sync_imap_keywords = ?10, compact_wide_rows = ?11, updated_at = datetime('now') WHERE id = 1"; } pub mod db_messages { diff --git a/src/routes/auth.rs b/src/routes/auth.rs index a8c61c6..645f98a 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -283,6 +283,7 @@ pub async fn login( signature_reply: true, signature_forward: true, signature_position: "above".to_string(), + signature_insert_mode: "silent".to_string(), pgp_fingerprint: None, pgp_sign_default: false, pgp_encrypt_default: false, diff --git a/src/routes/send.rs b/src/routes/send.rs index ca88421..571d9dd 100644 --- a/src/routes/send.rs +++ b/src/routes/send.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; +use base64::Engine as _; +use regex::Regex; use serde::{Deserialize, Serialize}; use crate::auth::session::SessionState; @@ -28,6 +30,8 @@ pub struct SendRequest { pub in_reply_to: Option, pub references: Option, + pub compose_action: Option, + pub draft_id: Option, pub from_identity_id: Option, @@ -60,6 +64,181 @@ fn default_priority() -> String { "normal".to_string() } +fn signature_enabled( + identity: &db::identities::Identity, + compose_action: Option<&str>, + in_reply_to: Option<&str>, +) -> bool { + let action = compose_action + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| { + if in_reply_to.is_some() { + "reply" + } else { + "new" + } + }); + match action { + "reply" | "reply-all" => identity.signature_reply, + "forward" => identity.signature_forward, + _ => identity.signature_new, + } +} + +fn body_already_has_signature( + identity: &db::identities::Identity, + text_body: Option<&str>, + html_body: Option<&str>, +) -> bool { + if html_body.is_some_and(|html| { + html.contains("compose-signature") || html.contains("data-rustbox-signature") + }) { + return true; + } + let signature = identity.signature_text.trim_matches(|ch| matches!(ch, '\r' | '\n')); + if signature.trim().is_empty() { + return false; + } + let suffix = format!("-- \n{signature}"); + text_body.is_some_and(|body| body.trim_end().ends_with(&suffix)) +} + +fn append_plain_signature(body: &mut String, signature: &str) { + let signature = signature.trim_matches(|ch| matches!(ch, '\r' | '\n')); + if signature.trim().is_empty() { + return; + } + while body.ends_with('\r') || body.ends_with('\n') { + body.pop(); + } + if !body.is_empty() { + body.push_str("\n\n"); + } + body.push_str("-- \n"); + body.push_str(signature); + body.push('\n'); +} + +fn signature_html_to_text(html: &str) -> String { + let break_tags = Regex::new(r"(?is)<\s*(?:br\s*/?|/p|/div|/li|/tr|/h[1-6])\s*[^>]*>") + .expect("valid signature break-tag regex"); + let tags = Regex::new(r"(?is)<[^>]+>").expect("valid signature tag regex"); + let text = break_tags.replace_all(html, "\n"); + let text = tags.replace_all(&text, ""); + text.replace(" ", " ") + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .trim() + .to_string() +} + +fn materialize_signature_images(html: &str, attachments: &mut Vec) -> String { + let data_image = Regex::new( + r"(?is)data:(image/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\r\n]+)", + ) + .expect("valid signature data-image regex"); + let mut image_index = 0usize; + data_image + .replace_all(html, |captures: ®ex::Captures<'_>| { + let mime = captures.get(1).map(|m| m.as_str()).unwrap_or("image/png"); + let encoded = captures + .get(2) + .map(|m| { + m.as_str() + .chars() + .filter(|ch| !matches!(ch, '\r' | '\n')) + .collect::() + }) + .unwrap_or_default(); + let Ok(data) = base64::engine::general_purpose::STANDARD.decode(encoded) else { + return captures + .get(0) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + }; + image_index += 1; + let extension = match mime.to_ascii_lowercase().as_str() { + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + _ => "png", + }; + let content_id = format!("rustbox-signature-{}", uuid::Uuid::new_v4()); + attachments.push(AttachmentData { + filename: format!("signature-{image_index}.{extension}"), + content_type: mime.to_string(), + data, + content_id: Some(content_id.clone()), + }); + format!("cid:{content_id}") + }) + .into_owned() +} + +fn append_html_signature(body: &mut String, signature: &str, position: &str) { + let wrapper = format!( + "
{signature}
" + ); + if position != "below" { + let quote = Regex::new( + r#"(?is)<[a-z0-9]+\b[^>]*\bclass\s*=\s*["'][^"']*\bcompose-quote\b[^"']*["'][^>]*>"#, + ) + .expect("valid compose quote regex"); + if let Some(found) = quote.find(body) { + body.insert_str(found.start(), &wrapper); + return; + } + } + if !body.trim().is_empty() { + body.push('\n'); + } + body.push_str(&wrapper); +} + +fn append_identity_signature( + identity: &db::identities::Identity, + text_body: &mut String, + html_body: &mut Option, + attachments: &mut Vec, +) { + let html_signature = identity.signature_html.trim(); + let text_signature = identity.signature_text.trim(); + + if let Some(html) = html_body.as_mut() + && !html_signature.is_empty() + { + let signature = materialize_signature_images(html_signature, attachments); + append_html_signature(html, &signature, &identity.signature_position); + + let plain = if text_signature.is_empty() { + signature_html_to_text(html_signature) + } else { + text_signature.to_string() + }; + append_plain_signature(text_body, &plain); + return; + } + + if !text_signature.is_empty() { + append_plain_signature(text_body, text_signature); + if let Some(html) = html_body.as_mut() { + let escaped = text_signature + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('`', "`") + .replace('\n', "
"); + append_html_signature(html, &escaped, &identity.signature_position); + } + } +} + fn validate_mailbox(label: &str, value: &str) -> Result<(), AppError> { value .parse::() @@ -231,6 +410,18 @@ pub async fn send_message_handler( &req.cc, &bcc, )?; + let append_signature = selected_identity.as_ref().is_some_and(|identity| { + identity.signature_insert_mode != "visible" + && signature_enabled( + identity, + req.compose_action.as_deref(), + req.in_reply_to.as_deref(), + ) && !body_already_has_signature( + identity, + req.text_body.as_deref(), + req.html_body.as_deref(), + ) + }); drop(conn); validate_mailbox("From", &from_address)?; @@ -305,6 +496,25 @@ pub async fn send_message_handler( }); } + let mut text_body = req.text_body.unwrap_or_default(); + let mut html_body = req.html_body; + if append_signature + && let Some(identity) = selected_identity.as_ref() + { + append_identity_signature( + identity, + &mut text_body, + &mut html_body, + &mut attachments, + ); + } + + if pgp_inline && pgp_mode != PgpMode::None && !attachments.is_empty() { + return Err(AppError::BadRequest( + "Inline OpenPGP cannot carry attachments or inline signature images; use PGP/MIME or remove images from the signature".to_string(), + )); + } + let message = SendableMessage { from: from_address, to: req.to, @@ -313,8 +523,8 @@ pub async fn send_message_handler( reply_to, organization, subject: req.subject, - text_body: req.text_body.unwrap_or_default(), - html_body: req.html_body, + text_body, + html_body, in_reply_to: req.in_reply_to, references: req.references, read_receipt_to: req.request_mdn.then_some(receipt_address), diff --git a/src/web/css/20-shell.css b/src/web/css/20-shell.css index ae8337e..3e3e575 100644 --- a/src/web/css/20-shell.css +++ b/src/web/css/20-shell.css @@ -214,6 +214,8 @@ } .message-pane { + container-name: message-pane; + container-type: inline-size; overflow: hidden; } @@ -309,6 +311,10 @@ background: transparent; } +.pane-resizer[data-temporarily-disabled="true"] { + display: none; +} + .pane-resizer::after { content: ""; position: absolute; diff --git a/src/web/css/30-mail.css b/src/web/css/30-mail.css index b7f3dd7..2f60df1 100644 --- a/src/web/css/30-mail.css +++ b/src/web/css/30-mail.css @@ -1354,4 +1354,94 @@ body.reader-fullscreen-open { .reader-toolbar .toolbar-menu>summary { min-width: max-content; } -} \ No newline at end of file +} +/* Optional wide-pane single-line message rows. The preference is on by default, + but the layout only activates once the message pane has enough horizontal room. */ +@container message-pane (min-width: 520px) { + html[data-compact-wide-rows="true"] .message-row { + min-height: min(var(--row-height), 36px); + grid-template-columns: 19px minmax(110px, .34fr) minmax(0, 1fr) auto auto; + grid-template-rows: minmax(24px, auto); + column-gap: 8px; + row-gap: 0; + align-items: center; + padding-top: 4px; + padding-bottom: 4px; + } + + html[data-compact-wide-rows="true"] .message-row .message-state { + grid-column: 1; + grid-row: 1; + } + + html[data-compact-wide-rows="true"] .message-row .message-main { + display: contents; + } + + html[data-compact-wide-rows="true"] .message-row .message-from { + grid-column: 2; + grid-row: 1; + min-width: 0; + } + + html[data-compact-wide-rows="true"] .message-row .message-subject-line { + grid-column: 3; + grid-row: 1; + min-width: 0; + } + + html[data-compact-wide-rows="true"] .message-row .message-subject { + flex: 0 1 auto; + min-width: 0; + } + + html[data-compact-wide-rows="true"] .message-row .message-snippet { + flex: 1 1 120px; + min-width: 0; + } + + html[data-compact-wide-rows="true"] .message-row .message-snippet:not(:empty)::before { + content: "· "; + color: var(--subtle); + } + + html[data-compact-wide-rows="true"] .message-row .message-date { + grid-column: 4; + grid-row: 1; + } + + html[data-compact-wide-rows="true"] .message-row .message-icons { + grid-column: 5; + grid-row: 1; + } + + html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row { + grid-template-columns: 18px 19px minmax(110px, .34fr) minmax(0, 1fr) auto auto; + } + + html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-select { + grid-column: 1; + grid-row: 1; + } + + html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-state { + grid-column: 2; + } + + html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-from { + grid-column: 3; + } + + html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-subject-line { + grid-column: 4; + } + + html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-date { + grid-column: 5; + } + + html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-icons { + grid-column: 6; + } +} + diff --git a/src/web/css/40-compose.css b/src/web/css/40-compose.css index 2d2aac8..4c311a8 100644 --- a/src/web/css/40-compose.css +++ b/src/web/css/40-compose.css @@ -906,6 +906,12 @@ line-height: 1.45; } +.compose-signature { + margin-top: 10px; + padding-top: 8px; + border-top: 1px dashed var(--line-strong); +} + .compose-signature img { max-width: 100%; height: auto; diff --git a/src/web/i18n/pl.json b/src/web/i18n/pl.json index 3431b1c..6350901 100644 --- a/src/web/i18n/pl.json +++ b/src/web/i18n/pl.json @@ -639,7 +639,11 @@ "Hide fields": "Ukryj pola", "Show fields": "Pokaż pola", "Hide addressing fields": "Ukryj pola adresowe", - "Show addressing fields": "Pokaż pola adresowe" + "Show addressing fields": "Pokaż pola adresowe", + "Use single-line rows when the message list is wide": "Używaj jednowierszowej listy, gdy panel wiadomości jest szeroki", + "Signature insertion": "Wstawianie podpisu", + "Quietly when sending": "Cicho przy wysyłce", + "Show in composer": "Pokaż w edytorze" }, "fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję presetu poniżej, aby przy pierwszej zmianie utworzyć jego edytowalną kopię.", "custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.", diff --git a/src/web/index.html b/src/web/index.html index 836ee1e..29f507c 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -708,10 +708,10 @@ value="58"> + min="180" max="420" step="4" value="228"> + min="300" max="760" step="10" value="420"> + + diff --git a/src/web/js/00-bootstrap.js b/src/web/js/00-bootstrap.js index d807ef5..8541801 100644 --- a/src/web/js/00-bootstrap.js +++ b/src/web/js/00-bootstrap.js @@ -45,7 +45,7 @@ savedSearches: [], responseTemplates: [], notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" }, - mailPrefs: { messages_per_page: 300, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false }, + mailPrefs: { messages_per_page: 300, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false, compact_wide_rows: true }, currentFolderFlaggedOnTop: false, markReadTimer: null, ws: null, @@ -82,9 +82,10 @@ composeUploading: false, composeUploadController: null, composeReplyMeta: null, + composeAction: "new", + composeSignatureGeneration: 0, composeInsertRange: null, composeSelectedImage: null, - composeSignatureGeneration: 0, identitySignatureRange: null, inlineAttachmentIds: new Set(), inlineImageCleanupTimer: null, diff --git a/src/web/js/20-preferences-auth.js b/src/web/js/20-preferences-auth.js index 26588c3..0efb719 100644 --- a/src/web/js/20-preferences-auth.js +++ b/src/web/js/20-preferences-auth.js @@ -20,6 +20,7 @@ let presetPromotionTimer = null; let presetPromotionInFlight = false; let presetPromotionNeedsAnotherSave = false; let presetPromotionActive = false; +const temporarilyDisabledPanePreferences = new Set(); const interfaceStyleProfiles = { "roundcube-classic": { @@ -348,9 +349,29 @@ async function loadMailPreferences() { if (flaggedTop) flaggedTop.checked = Boolean(state.mailPrefs.flagged_on_top); const syncKeywords = $("#mail-pref-sync-keywords"); if (syncKeywords) syncKeywords.checked = Boolean(state.mailPrefs.sync_imap_keywords); + const compactWideRows = $("#pref-compact-wide-rows"); + if (compactWideRows) compactWideRows.checked = state.mailPrefs.compact_wide_rows !== false; + root.dataset.compactWideRows = state.mailPrefs.compact_wide_rows === false ? "false" : "true"; } catch (error) { console.warn("Could not load mailbox preferences", error); } } +async function saveCompactWideRowsAppearance(enabled) { + const previous = state.mailPrefs.compact_wide_rows !== false; + root.dataset.compactWideRows = enabled ? "true" : "false"; + try { + state.mailPrefs = await request("/settings/mailbox", { + method: "PUT", + body: JSON.stringify({ compact_wide_rows: Boolean(enabled) }), + }); + root.dataset.compactWideRows = state.mailPrefs.compact_wide_rows === false ? "false" : "true"; + } catch (error) { + const input = $("#pref-compact-wide-rows"); + if (input) input.checked = previous; + root.dataset.compactWideRows = previous ? "true" : "false"; + toast(error.message, "error"); + } +} + async function saveMailPreferences() { const payload = { messages_per_page: Number($("#mail-pref-per-page").value), @@ -367,6 +388,7 @@ async function saveMailPreferences() { try { state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify(payload) }); state.perPage = Number(state.mailPrefs.messages_per_page || 300); + root.dataset.compactWideRows = state.mailPrefs.compact_wide_rows === false ? "false" : "true"; toast("Mailbox settings saved"); if (state.currentDetail) renderMessageDetail(state.currentDetail); await loadMessages(true); @@ -410,6 +432,39 @@ const presetEditablePreferenceKeys = new Set([ "folderBackground", "messageBackground", "readerBackground", ]); + +function panePreferenceKeyFromValidationError(error) { + const message = String(error?.message || ""); + if (/Invalid\s+message_pane_width\s*:/i.test(message)) return "messagePaneWidth"; + if (/Invalid\s+folder_pane_width\s*:/i.test(message)) return "folderPaneWidth"; + return null; +} + +function temporarilyDisablePanePreference(key, error = null) { + if (!["folderPaneWidth", "messagePaneWidth"].includes(key)) return false; + temporarilyDisabledPanePreferences.add(key); + const handle = $(key === "folderPaneWidth" ? "#folder-resizer" : "#message-resizer"); + if (handle) { + handle.dataset.temporarilyDisabled = "true"; + handle.setAttribute("aria-disabled", "true"); + } + if (error) console.warn(`Pane resize temporarily disabled for ${key}`, error); + return true; +} + +function restorePanePreference(key) { + if (!temporarilyDisabledPanePreferences.delete(key)) return; + const handle = $(key === "folderPaneWidth" ? "#folder-resizer" : key === "messagePaneWidth" ? "#message-resizer" : null); + if (!handle) return; + delete handle.dataset.temporarilyDisabled; + handle.removeAttribute("aria-disabled"); +} + +function suppressPaneWidthValidationError(error) { + const key = panePreferenceKeyFromValidationError(error); + return key ? temporarilyDisablePanePreference(key, error) : false; +} + function presetCssHex(styles, name, fallback) { const value = styles.getPropertyValue(name).trim(); if (/^#[0-9a-f]{6}$/i.test(value)) return value; @@ -441,8 +496,10 @@ function schedulePromotedAppearanceSave() { presetPromotionNeedsAnotherSave = false; try { await request("/settings/display", { method: "PUT", body: JSON.stringify(customAppearancePayload(state.prefs)) }); + restorePanePreference("folderPaneWidth"); + restorePanePreference("messagePaneWidth"); } catch (error) { - toast(error.message, "error"); + if (!suppressPaneWidthValidationError(error)) toast(error.message, "error"); } finally { presetPromotionInFlight = false; if (presetPromotionNeedsAnotherSave) schedulePromotedAppearanceSave(); @@ -498,16 +555,25 @@ function savePreference(key, value) { if (!field) return; const payload = { [field]: state.prefs[key] }; if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) payload.color_preset = "custom"; - try { await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }); } - catch (error) { toast(error.message, "error"); } + try { + await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }); + restorePanePreference(key); + } catch (error) { if (!suppressPaneWidthValidationError(error)) toast(error.message, "error"); } }, 180); preferenceTimers.set(key, timer); } async function resetAppearance() { const payload = { theme: root.dataset.defaultTheme || "system", interface_skin: "default", preset_scale_unlocked: false, density: "compact", preview_pane: "right", ui_scale: "normal", color_preset: "sage", accent_color: "#6f9c86", background_color: "#121513", panel_color: "#181c19", text_color: "#edf0ec", font_family: "system", font_size: 13, row_height: 58, folder_pane_width: 228, message_pane_width: 420, border_radius: 4, contrast: "normal", show_avatars: true, show_attachment_icons: true, show_flags: true, reduce_motion: false, date_format: "locale", time_format: "locale", icon_size: 18, toolbar_density: "normal", sidebar_item_height: 29, reader_max_width: 0, composer_width: 900, selection_color: "#28352e", unread_font_weight: 730, unread_badge_style: "dot", compact_headers: false, show_borders: true, scrollbar_density: "thin", monochrome: false, folder_background: "", message_background: "", reader_background: "" }; - try { normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) })); applyPreferences(); toast("Appearance reset"); } - catch (error) { toast(error.message, "error"); } + try { + normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) })); + state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ compact_wide_rows: true }) }); + const compactWideRows = $("#pref-compact-wide-rows"); + if (compactWideRows) compactWideRows.checked = true; + root.dataset.compactWideRows = "true"; + applyPreferences(); + toast("Appearance reset"); + } catch (error) { toast(error.message, "error"); } } diff --git a/src/web/js/50-compose.js b/src/web/js/50-compose.js index 5e7d76e..2ffa0d1 100644 --- a/src/web/js/50-compose.js +++ b/src/web/js/50-compose.js @@ -125,6 +125,19 @@ function estimateComposeMessageBytes(extraFiles = []) { const mode = $("#compose-mode")?.value || "html"; if (mode === "html") bytes += utf8Bytes(editor.innerHTML); else if (mode === "markdown") bytes += Math.ceil(plainBytes * 1.25); + + const identity = typeof selectedIdentity === "function" ? selectedIdentity() : null; + const action = state.composeAction || "new"; + const signatureEnabled = identity && (action === "reply" + ? identity.signature_reply !== false + : action === "forward" + ? identity.signature_forward !== false + : identity.signature_new !== false); + if (signatureEnabled && identity.signature_insert_mode !== "visible") { + const signatureText = identity.signature_text || ""; + bytes += utf8Bytes(signatureText) + 8; + if (mode === "html") bytes += utf8Bytes(identity.signature_html || "") + 96; + } } for (const attachment of state.composeAttachments) bytes += estimatedMimeAttachmentBytes(attachment.size); for (const file of extraFiles || []) bytes += estimatedMimeAttachmentBytes(file.size); @@ -551,6 +564,7 @@ function composeDraftPayload() { references: state.composeReplyMeta?.references || null, request_mdn: Boolean($("#compose-mdn")?.checked), priority: $("#compose-priority")?.value || "normal", + compose_action: state.composeAction || "new", compose_format: $("#compose-mode")?.value || "html", pgp_mode: $("#compose-pgp-mode")?.value || "none", pgp_inline: Boolean($("#compose-pgp-inline")?.checked), @@ -1414,8 +1428,7 @@ async function uploadInlineImage(files, insertionRange = null) { async function openImapDraftComposer(folder, uid) { const draft = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" }); - openCompose("new"); - state.composeSignatureGeneration += 1; + openCompose("new", { skipSignature: true }); state.composeDraftId = ensureDraftId(); state.composeRemoteDraft = draft.imap_source || { folder, uid, uid_validity: 0 }; state.composeAttachments = mergeComposeAttachments(draft.attachments); @@ -1425,6 +1438,7 @@ async function openImapDraftComposer(folder, uid) { inReplyTo: draft.in_reply_to || null, references: draft.references || null, }; + state.composeAction = draft.in_reply_to ? "reply" : (/^fwd:/i.test(draft.subject || "") ? "forward" : "new"); $("#compose-to").value = draft.to || ""; $("#compose-cc").value = draft.cc || ""; $("#compose-bcc").value = draft.bcc || ""; @@ -1446,6 +1460,7 @@ async function openImapDraftComposer(folder, uid) { const editor = $("#compose-editor"); if (mode === "html") editor.innerHTML = hydrateComposeHtml(draft.html_body || "", state.composeAttachments, state.composeDraftId); else editor.textContent = draft.text_body || ""; + insertIdentitySignature(editor, state.composeAction, { preserveExisting: true }); renderComposeAttachments(); state.composeDirty = false; state.composeNeedsRemoteSync = false; @@ -1458,7 +1473,6 @@ async function openImapDraftComposer(folder, uid) { } function applyRecoveredDraft(draft, sourceLabel = "Saved draft") { - state.composeSignatureGeneration += 1; state.composeDraftId = draft.id || null; state.composeRemoteDraft = draft.imap_source || null; state.composeAttachments = mergeComposeAttachments(draft.attachments); @@ -1468,6 +1482,7 @@ function applyRecoveredDraft(draft, sourceLabel = "Saved draft") { inReplyTo: draft.in_reply_to || null, references: draft.references || null, }; + state.composeAction = draft.compose_action || (draft.in_reply_to ? "reply" : (/^fwd:/i.test(draft.subject || "") ? "forward" : "new")); $("#compose-to").value = draft.to || ""; $("#compose-cc").value = draft.cc || ""; $("#compose-bcc").value = draft.bcc || ""; @@ -1485,6 +1500,7 @@ function applyRecoveredDraft(draft, sourceLabel = "Saved draft") { const editor = $("#compose-editor"); if (mode === "html" && draft.html_body) editor.innerHTML = hydrateComposeHtml(draft.html_body, state.composeAttachments, state.composeDraftId); else editor.textContent = draft.text_body || ""; + insertIdentitySignature(editor, state.composeAction, { preserveExisting: true }); renderComposeAttachments(); state.composeDirty = sourceLabel === "Browser recovery"; state.composeNeedsRemoteSync = sourceLabel === "Browser recovery"; @@ -1560,7 +1576,6 @@ async function closeComposer() { if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200); } if ($("#compose-dialog").open) $("#compose-dialog").close(); - state.composeSignatureGeneration += 1; } function attachmentReminderNeeded() { @@ -1649,7 +1664,7 @@ function toggleComposeFields() { if (collapse) $("#compose-editor")?.focus(); } -function openCompose(mode = "new") { +function openCompose(mode = "new", options = {}) { if (mode === "forward" && state.prefs.forwardMode === "attachment") { void forwardAsAttachment(); return; @@ -1662,13 +1677,13 @@ function openCompose(mode = "new") { state.composeDraftId = null; state.composeRemoteDraft = null; state.composeAttachments = []; - state.composeSignatureGeneration += 1; state.inlineAttachmentIds = new Set(); setSelectedInlineImage(null); setComposeFieldsCollapsed(false); state.composeDirty = false; state.composeNeedsRemoteSync = false; state.composeReplyMeta = null; + state.composeAction = mode === "reply" || mode === "reply-all" ? "reply" : mode === "forward" ? "forward" : "new"; setComposeUploadProgress(false); renderComposeAttachments(); updateComposeDraftDeleteAction(); @@ -1728,7 +1743,7 @@ function openCompose(mode = "new") { } editor.append(quote); } - insertIdentitySignature(editor, mode); + if (!options.skipSignature) insertIdentitySignature(editor, state.composeAction); let fullScreen = $("#compose-dialog").classList.contains("compose-fullscreen"); try { fullScreen = localStorage.getItem("rustbox.composeFullscreen") === "1"; } catch (_) { } setComposeFullscreen(fullScreen, false); @@ -1791,6 +1806,7 @@ async function sendMessage(event) { from_identity_id: $("#compose-from").value ? Number($("#compose-from").value) : null, request_mdn: Boolean($("#compose-mdn").checked), priority: $("#compose-priority").value || "normal", + compose_action: state.composeAction || "new", pgp_mode: $("#compose-pgp-mode").value || "none", pgp_inline: Boolean($("#compose-pgp-inline").checked), pgp_passphrase: $("#compose-pgp-passphrase").value || "", diff --git a/src/web/js/60-mail-actions-search.js b/src/web/js/60-mail-actions-search.js index 21e9dc3..9e1753f 100644 --- a/src/web/js/60-mail-actions-search.js +++ b/src/web/js/60-mail-actions-search.js @@ -904,6 +904,7 @@ async function forwardAsAttachment() { try { const blob = await fetchEmlBlob(detail); openCompose("new"); + state.composeAction = "forward"; $("#compose-title").textContent = trText("Forward as attachment"); $("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`; const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`; diff --git a/src/web/js/72-openpgp-identities.js b/src/web/js/72-openpgp-identities.js index 2d2a37c..1a867ff 100644 --- a/src/web/js/72-openpgp-identities.js +++ b/src/web/js/72-openpgp-identities.js @@ -371,6 +371,7 @@ function editIdentity(identity = null) { $("#identity-signature-text").value = identity?.signature_text || ""; $("#identity-signature").innerHTML = sanitizeMessageHtml(identity?.signature_html || ""); $("#identity-signature-position").value = identity?.signature_position || "above"; + $("#identity-signature-mode").value = identity?.signature_insert_mode || "silent"; renderIdentityPgpOptions(identity?.pgp_fingerprint || ""); $("#identity-pgp-sign").checked = Boolean(identity?.pgp_sign_default); $("#identity-pgp-encrypt").checked = Boolean(identity?.pgp_encrypt_default); @@ -403,6 +404,7 @@ async function saveIdentity(event) { signature_text: $("#identity-signature-text").value, signature_html: signatureHtml, signature_position: $("#identity-signature-position").value, + signature_insert_mode: $("#identity-signature-mode").value, pgp_fingerprint: $("#identity-pgp-key").value, pgp_sign_default: $("#identity-pgp-sign").checked, pgp_encrypt_default: $("#identity-pgp-encrypt").checked, @@ -440,11 +442,15 @@ function selectedIdentity() { return state.identities.find((identity) => identity.id === id) || state.identities.find((identity) => identity.is_default) || null; } -function composeActionFromTitle() { - const title = $("#compose-title").textContent; - if (title === "Reply" || title === "Reply all") return "reply"; - if (title === "Forward") return "forward"; - return "new"; +function signatureEnabledForCompose(identity, action = state.composeAction || "new") { + if (!identity) return false; + if (action === "reply" || action === "reply-all") return identity.signature_reply !== false; + if (action === "forward") return identity.signature_forward !== false; + return identity.signature_new !== false; +} + +function identityUsesVisibleSignature(identity = selectedIdentity()) { + return Boolean(identity && identity.signature_insert_mode === "visible"); } function dataUrlToSignatureFile(src, index = 0) { @@ -462,10 +468,12 @@ function dataUrlToSignatureFile(src, index = 0) { } } -async function materializeSignatureImages(signature, generation) { +async function materializeVisibleSignatureImages(signature, generation) { const images = [...signature.querySelectorAll('img[src^="data:image/"]')]; if (!images.length) return; - const pairs = images.map((image, index) => ({ image, file: dataUrlToSignatureFile(image.getAttribute("src"), index) })).filter((item) => item.file); + const pairs = images + .map((image, index) => ({ image, file: dataUrlToSignatureFile(image.getAttribute("src"), index) })) + .filter((item) => item.file); if (!pairs.length) return; const uploaded = await uploadComposeFiles(pairs.map((item) => item.file), { announce: false }); if (!uploaded.length) return; @@ -488,29 +496,76 @@ async function materializeSignatureImages(signature, generation) { scheduleDraftSave(); } -function insertIdentitySignature(editor, action = composeActionFromTitle()) { +function plainIdentitySignatureSuffix(identity) { + const text = String(identity?.signature_text || "").replace(/[\r\n]+$/g, ""); + return text.trim() ? `-- \n${text}` : ""; +} + +function stripLegacyPlainSignature(editor) { + if (!editor || $("#compose-mode")?.value === "html") return false; + let text = editor.innerText || ""; + const original = text; + for (const identity of state.identities) { + const suffix = plainIdentitySignatureSuffix(identity); + if (!suffix) continue; + const trimmed = text.replace(/[\r\n]+$/g, ""); + if (!trimmed.endsWith(suffix)) continue; + text = trimmed.slice(0, -suffix.length).replace(/[\r\n]+$/g, ""); + break; + } + if (text === original) return false; + editor.textContent = text; + return true; +} + +function removeVisibleComposeSignature(editor = $("#compose-editor"), options = {}) { + if (!editor) return; state.composeSignatureGeneration += 1; - const generation = state.composeSignatureGeneration; - editor.querySelectorAll(".compose-signature").forEach((node) => node.remove()); + editor.querySelectorAll('.compose-signature, [data-rustbox-signature="true"]').forEach((node) => node.remove()); + if (options.stripPlainLegacy !== false) stripLegacyPlainSignature(editor); scheduleUnusedInlineAttachmentCleanup(); +} + +function composeHasVisibleSignature(editor, identity) { + if (!editor || !identity) return false; + if (editor.querySelector('.compose-signature, [data-rustbox-signature="true"]')) return true; + if ($("#compose-mode")?.value === "html") return false; + const suffix = plainIdentitySignatureSuffix(identity); + return Boolean(suffix && (editor.innerText || "").replace(/[\r\n]+$/g, "").endsWith(suffix)); +} + +function insertIdentitySignature(editor = $("#compose-editor"), action = state.composeAction || "new", options = {}) { + if (!editor) return; const identity = selectedIdentity(); - if (!identity) return; - const enabled = action === "reply" ? identity.signature_reply !== false : action === "forward" ? identity.signature_forward !== false : identity.signature_new !== false; - if (!enabled) return; - const format = $("#compose-mode").value; - const signature = el("div", "compose-signature"); + const visible = identityUsesVisibleSignature(identity) && signatureEnabledForCompose(identity, action); + if (options.preserveExisting && visible && composeHasVisibleSignature(editor, identity)) return; + + removeVisibleComposeSignature(editor); + if (!visible) return; + + const format = $("#compose-mode")?.value || "html"; + const signature = el("div", "compose-signature rustbox-signature"); + signature.dataset.rustboxSignature = "true"; if (format === "html") { - if (!identity.signature_html) return; - signature.innerHTML = sanitizeMessageHtml(identity.signature_html); + const html = String(identity.signature_html || "").trim(); + const text = String(identity.signature_text || "").trim(); + if (html) signature.innerHTML = sanitizeMessageHtml(html); + else if (text) { + signature.textContent = text; + signature.style.whiteSpace = "pre-wrap"; + } else return; } else { - const text = identity.signature_text || ""; - if (!text) return; + const text = String(identity.signature_text || "").replace(/[\r\n]+$/g, ""); + if (!text.trim()) return; signature.textContent = `\n-- \n${text}\n`; } + const quote = editor.querySelector(".compose-quote"); if (quote && identity.signature_position !== "below") editor.insertBefore(signature, quote); else editor.append(signature); - if (format === "html") void materializeSignatureImages(signature, generation); + + const generation = state.composeSignatureGeneration; + if (format === "html") void materializeVisibleSignatureImages(signature, generation); } function readFileAsDataUrl(file) { diff --git a/src/web/js/95-bindings-boot.js b/src/web/js/95-bindings-boot.js index 73f611d..8a380b9 100644 --- a/src/web/js/95-bindings-boot.js +++ b/src/web/js/95-bindings-boot.js @@ -33,6 +33,7 @@ function bindPaneResizer(selector, preferenceKey, minPx, maxPx) { const handle = $(selector); if (!handle) return; handle.addEventListener("pointerdown", (event) => { + if (handle.dataset.temporarilyDisabled === "true" || temporarilyDisabledPanePreferences.has(preferenceKey)) return; if (innerWidth <= 820 || event.button !== 0) return; event.preventDefault(); const scaleFactor = Number(getComputedStyle(root).getPropertyValue("--ui-scale-factor")) || 1; @@ -123,7 +124,7 @@ function bindToolbarMenus() { function bindEvents() { bindToolbarMenus(); - bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420); + bindPaneResizer("#folder-resizer", "folderPaneWidth", 180, 420); bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760); bindRecipientAutocomplete("#compose-to"); bindRecipientAutocomplete("#compose-cc"); @@ -192,11 +193,11 @@ function bindEvents() { return; } setComposeMode(next, true); - insertIdentitySignature(editor); + insertIdentitySignature(editor, state.composeAction); scheduleDraftSave(); updateComposeMessageSize(); }); - $("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); updateComposeMessageSize(); }); + $("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor"), state.composeAction); updateComposePgpUi(true); updateComposeMessageSize(); scheduleDraftSave(); }); ["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => { state.pgpRecipientStatus = []; updateComposePgpUi(); @@ -429,6 +430,7 @@ function bindEvents() { $("#pref-unread-badge").addEventListener("change", (event) => savePreference("unreadBadgeStyle", event.target.value)); $("#pref-scrollbar-density").addEventListener("change", (event) => savePreference("scrollbarDensity", event.target.value)); $("#pref-compact-headers").addEventListener("change", (event) => savePreference("compactHeaders", event.target.checked)); + $("#pref-compact-wide-rows").addEventListener("change", (event) => saveCompactWideRowsAppearance(event.target.checked)); $("#pref-show-borders").addEventListener("change", (event) => savePreference("showBorders", event.target.checked)); $("#pref-monochrome").addEventListener("change", (event) => savePreference("monochrome", event.target.checked)); [["folder", "folderBackground", "#101310"], ["message", "messageBackground", "#181c19"], ["reader", "readerBackground", "#181c19"]].forEach(([name, key, fallback]) => {