1 parent
12e0c99850
commit
edda5fc9e4
42 files changed
+733
-122
No files matched your search
@@ -1,5 +1,14 @@
|
|||||||
# RustBox changelog
|
# 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
|
### 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.
|
- 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.
|
||||||
|
|||||||
Generated
+1
-1
@@ -3506,7 +3506,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.13"
|
version = "0.2.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-imap",
|
"async-imap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.13"
|
version = "0.2.14"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.99.1"
|
rust-version = "1.99.1"
|
||||||
default-run = "rustbox"
|
default-run = "rustbox"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# RustBox attribution / notice
|
# 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`
|
- Upstream: `c0h1b4/oxi`
|
||||||
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
|
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
|
||||||
|
|||||||
@@ -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`.
|
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ rustbox/
|
|||||||
|
|
||||||
`build.rs` combines the CSS modules from `src/web/css/manifest.txt` and JavaScript modules from `src/web/js/manifest.txt`. Cargo embeds the generated CSS/JS bundles, HTML and SVG sprite in the RustBox executable. There is no standalone source `src/web/app.js`; `src/web/` is the single source of truth and Cargo generates the embedded bundles in `OUT_DIR`.
|
`build.rs` combines the CSS modules from `src/web/css/manifest.txt` and JavaScript modules from `src/web/js/manifest.txt`. Cargo embeds the generated CSS/JS bundles, HTML and SVG sprite in the RustBox executable. There is no standalone source `src/web/app.js`; `src/web/` is the single source of truth and Cargo generates the embedded bundles in `OUT_DIR`.
|
||||||
|
|
||||||
## Storage and search in 0.2.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.
|
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.
|
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
|
## Development
|
||||||
@@ -237,7 +237,7 @@ Initialize Git after extracting the release archive:
|
|||||||
```bash
|
```bash
|
||||||
git init
|
git init
|
||||||
git add .
|
git add .
|
||||||
git commit -m "RustBox 0.2.13 release"
|
git commit -m "RustBox 0.2.14 release"
|
||||||
./scripts/setup-upstream.sh
|
./scripts/setup-upstream.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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:
|
Architecture:
|
||||||
- database_backend = sqlite | mysql | pgsql selects authoritative SQL only
|
- database_backend = sqlite | mysql | pgsql selects authoritative SQL only
|
||||||
- blob storage and search are independent from the SQL engine
|
- 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 V047 adds attachment checksum metadata and search_outbox
|
||||||
- runtime migration V048 adds `folders.counter_generation` for race-safe authoritative IMAP counter writes
|
- 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
|
- 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 examples:
|
||||||
- docker/compose-single-sqlite.yml
|
- docker/compose-single-sqlite.yml
|
||||||
@@ -64,14 +67,14 @@ Docker examples:
|
|||||||
|
|
||||||
Operations/docs:
|
Operations/docs:
|
||||||
- standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets
|
- 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
|
- docker/README.md documents Compose variants
|
||||||
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
|
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
|
||||||
- TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending
|
- TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending
|
||||||
|
|
||||||
Build/release metadata:
|
Build/release metadata:
|
||||||
- `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts
|
- `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts
|
||||||
- Cargo.toml version = 0.2.13
|
- Cargo.toml version = 0.2.14
|
||||||
- rust-toolchain.toml pins Rust 1.99.1
|
- rust-toolchain.toml pins Rust 1.99.1
|
||||||
- Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing
|
- Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing
|
||||||
- CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build
|
- CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build
|
||||||
@@ -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
|
- 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
|
- 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
|
- 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
|
- PDF attachment preview uses the authenticated inline URL for Safari/iPhone compatibility
|
||||||
- Composing shows only compact attachment/message size-limit values on one line
|
- 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
|
- 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
|
- runtime schema migration V050 adds compact-wide-row and signature insertion preferences; V051 remains next
|
||||||
- version metadata updated to 0.2.13
|
- version metadata updated to 0.2.14
|
||||||
|
|
||||||
Completed in this environment:
|
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`
|
- browser JavaScript assembled in manifest order passes `node --check`
|
||||||
- all JSON files parse successfully
|
- all JSON files parse successfully
|
||||||
- Python helper scripts compile successfully
|
- Python helper scripts compile successfully
|
||||||
- shell scripts pass `bash -n`
|
- shell scripts pass `bash -n`
|
||||||
- release metadata consistently identifies 0.2.13
|
- release metadata consistently identifies 0.2.14
|
||||||
- V048/V049 migration files/catalog registration and SQLite upgrade execution are validated offline
|
- V048/V049/V050 migration files/catalog registration and SQLite upgrade execution are validated offline
|
||||||
|
|
||||||
|
|
||||||
Not executable in this packaging environment:
|
Not executable in this packaging environment:
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.2.13
|
# RustBox 0.2.14
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
port = 3001
|
port = 3001
|
||||||
environment = "production"
|
environment = "production"
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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`.
|
- 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.
|
- 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.
|
- Initial synchronization remains silent for new-mail notifications; bulk operations retain one summary toast; browser/system modifier shortcuts remain preserved.
|
||||||
- Mailbox pages default to 300 messages and the API/preferences accept values up to 300.
|
- Mailbox pages default to 300 messages and the API/preferences accept values up to 300.
|
||||||
- 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.
|
- 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
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ RUSTBOX_CONFIG=./config.dev.toml ./target/release/rustbox --check-config
|
|||||||
|
|
||||||
Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source.
|
Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source.
|
||||||
|
|
||||||
## Updating an existing checkout to 0.2.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.
|
Do not overlay the release with `cp *` or another command that skips dotfiles. That can retain stale `.github`, `.env.example`, or `Cargo.lock` files.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# RustBox 0.2.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
|
## Deployment matrix
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Database schemas and migrations
|
# 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
|
```text
|
||||||
database_backend = sqlite | mysql | pgsql
|
database_backend = sqlite | mysql | pgsql
|
||||||
@@ -19,20 +19,23 @@ migrations/
|
|||||||
│ ├── schema.sql
|
│ ├── schema.sql
|
||||||
│ ├── V047__blob_keys_and_search_outbox.sql
|
│ ├── V047__blob_keys_and_search_outbox.sql
|
||||||
│ ├── V048__folder_counter_generation.sql
|
│ ├── V048__folder_counter_generation.sql
|
||||||
│ └── V049__blob_delete_outbox.sql
|
│ ├── V049__blob_delete_outbox.sql
|
||||||
|
│ └── V050__compact_rows_and_signature_mode.sql
|
||||||
├── mysql/
|
├── mysql/
|
||||||
│ ├── schema.sql
|
│ ├── schema.sql
|
||||||
│ ├── V047__blob_keys_and_search_outbox.sql
|
│ ├── V047__blob_keys_and_search_outbox.sql
|
||||||
│ ├── V048__folder_counter_generation.sql
|
│ ├── V048__folder_counter_generation.sql
|
||||||
│ └── V049__blob_delete_outbox.sql
|
│ ├── V049__blob_delete_outbox.sql
|
||||||
|
│ └── V050__compact_rows_and_signature_mode.sql
|
||||||
└── pgsql/
|
└── pgsql/
|
||||||
├── schema.sql
|
├── schema.sql
|
||||||
├── V047__blob_keys_and_search_outbox.sql
|
├── V047__blob_keys_and_search_outbox.sql
|
||||||
├── V048__folder_counter_generation.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.
|
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.
|
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.
|
Redis and Redis Sentinel do not use SQL migrations.
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
-- RustBox 0.2.0 MySQL schema.
|
-- RustBox 0.2.0 MySQL schema.
|
||||||
-- One physical database contains global sessions plus isolated per-account tables.
|
-- One physical database contains global sessions plus isolated per-account tables.
|
||||||
-- Application tables are materialized by replacing {{p}} with a collision-checked prefix.
|
-- 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
|
-- RUSTBOX:GLOBAL-BEGIN
|
||||||
CREATE TABLE IF NOT EXISTS rustbox_accounts (
|
CREATE TABLE IF NOT EXISTS rustbox_accounts (
|
||||||
@@ -281,6 +281,7 @@ CREATE TABLE {{p}}identities (
|
|||||||
signature_reply BIGINT NOT NULL DEFAULT 1,
|
signature_reply BIGINT NOT NULL DEFAULT 1,
|
||||||
signature_forward BIGINT NOT NULL DEFAULT 1,
|
signature_forward BIGINT NOT NULL DEFAULT 1,
|
||||||
signature_position TEXT NOT NULL DEFAULT ('above'),
|
signature_position TEXT NOT NULL DEFAULT ('above'),
|
||||||
|
signature_insert_mode VARCHAR(16) NOT NULL DEFAULT ('silent'),
|
||||||
pgp_fingerprint TEXT,
|
pgp_fingerprint TEXT,
|
||||||
pgp_sign_default BIGINT NOT NULL DEFAULT 0,
|
pgp_sign_default BIGINT NOT NULL DEFAULT 0,
|
||||||
pgp_encrypt_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'),
|
default_sort TEXT NOT NULL DEFAULT ('date_desc'),
|
||||||
updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)),
|
updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)),
|
||||||
flagged_on_top BIGINT NOT NULL DEFAULT 0,
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE {{p}}meeting_templates (
|
CREATE TABLE {{p}}meeting_templates (
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
-- RustBox 0.2.0 PostgreSQL schema.
|
-- RustBox 0.2.0 PostgreSQL schema.
|
||||||
-- One physical database contains global sessions plus isolated per-account tables.
|
-- One physical database contains global sessions plus isolated per-account tables.
|
||||||
-- Application tables are materialized by replacing {{p}} with a collision-checked prefix.
|
-- 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
|
-- RUSTBOX:GLOBAL-BEGIN
|
||||||
CREATE TABLE IF NOT EXISTS rustbox_accounts (
|
CREATE TABLE IF NOT EXISTS rustbox_accounts (
|
||||||
@@ -283,6 +283,7 @@ CREATE TABLE {{p}}identities (
|
|||||||
signature_reply BIGINT NOT NULL DEFAULT 1,
|
signature_reply BIGINT NOT NULL DEFAULT 1,
|
||||||
signature_forward BIGINT NOT NULL DEFAULT 1,
|
signature_forward BIGINT NOT NULL DEFAULT 1,
|
||||||
signature_position TEXT NOT NULL DEFAULT 'above',
|
signature_position TEXT NOT NULL DEFAULT 'above',
|
||||||
|
signature_insert_mode TEXT NOT NULL DEFAULT 'silent',
|
||||||
pgp_fingerprint TEXT,
|
pgp_fingerprint TEXT,
|
||||||
pgp_sign_default BIGINT NOT NULL DEFAULT 0,
|
pgp_sign_default BIGINT NOT NULL DEFAULT 0,
|
||||||
pgp_encrypt_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',
|
default_sort TEXT NOT NULL DEFAULT 'date_desc',
|
||||||
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
|
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
|
||||||
flagged_on_top BIGINT NOT NULL DEFAULT 0,
|
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 (
|
CREATE TABLE {{p}}meeting_templates (
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
-- RustBox 0.2.0 SQLite schema.
|
-- RustBox 0.2.0 SQLite schema.
|
||||||
-- One physical database contains global sessions plus isolated per-account tables.
|
-- One physical database contains global sessions plus isolated per-account tables.
|
||||||
-- Application tables are materialized from the tenant section by replacing {{p}}.
|
-- 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
|
-- RUSTBOX:GLOBAL-BEGIN
|
||||||
CREATE TABLE IF NOT EXISTS rustbox_accounts (
|
CREATE TABLE IF NOT EXISTS rustbox_accounts (
|
||||||
@@ -210,7 +210,7 @@ CREATE TABLE {{p}}identities (
|
|||||||
is_default INTEGER NOT NULL DEFAULT 0,
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_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 (
|
CREATE TABLE {{p}}known_addresses (
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
@@ -229,7 +229,7 @@ CREATE TABLE {{p}}mail_preferences (
|
|||||||
show_snippets INTEGER NOT NULL DEFAULT 1,
|
show_snippets INTEGER NOT NULL DEFAULT 1,
|
||||||
default_sort TEXT NOT NULL DEFAULT 'date_desc',
|
default_sort TEXT NOT NULL DEFAULT 'date_desc',
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
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 (
|
CREATE TABLE {{p}}meeting_templates (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -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.
|
# This file contains all supported RustBox configuration options.
|
||||||
# Commented options are optional and do not change the packaged defaults.
|
# Commented options are optional and do not change the packaged defaults.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Static/offline release checks for RustBox 0.2.13.
|
"""Static/offline release checks for RustBox 0.2.14.
|
||||||
|
|
||||||
These checks intentionally avoid pretending to replace `cargo test`: they validate
|
These checks intentionally avoid pretending to replace `cargo test`: they validate
|
||||||
configuration, SQL migrations, Docker examples, query-catalog parity and release
|
configuration, SQL migrations, Docker examples, query-catalog parity and release
|
||||||
@@ -49,14 +49,14 @@ def extract_section(text: str, begin: str, end: str) -> str:
|
|||||||
|
|
||||||
# Release/toolchain metadata.
|
# Release/toolchain metadata.
|
||||||
cargo = tomllib.loads(read("Cargo.toml"))
|
cargo = tomllib.loads(read("Cargo.toml"))
|
||||||
require(cargo["package"]["version"] == "0.2.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")
|
require(cargo["package"]["rust-version"] == "1.99.1", "Cargo rust-version is 1.99.1")
|
||||||
toolchain = tomllib.loads(read("rust-toolchain.toml"))
|
toolchain = tomllib.loads(read("rust-toolchain.toml"))
|
||||||
require(toolchain["toolchain"]["channel"] == "1.99.1", "rust-toolchain pins Rust 1.99.1")
|
require(toolchain["toolchain"]["channel"] == "1.99.1", "rust-toolchain pins Rust 1.99.1")
|
||||||
require((ROOT / "Cargo.lock").is_file(), "Cargo.lock is retained for reproducible application builds")
|
require((ROOT / "Cargo.lock").is_file(), "Cargo.lock is retained for reproducible application builds")
|
||||||
lock = tomllib.loads(read("Cargo.lock"))
|
lock = tomllib.loads(read("Cargo.lock"))
|
||||||
lock_rustbox = next((pkg for pkg in lock.get("package", []) if pkg.get("name") == "rustbox"), {})
|
lock_rustbox = next((pkg for pkg in lock.get("package", []) if pkg.get("name") == "rustbox"), {})
|
||||||
require(lock_rustbox.get("version") == "0.2.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(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("encoding_rs" in lock_rustbox.get("dependencies", []), "Cargo.lock records direct charset decoder dependency")
|
||||||
require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script generates Cargo.lock when missing")
|
require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script generates Cargo.lock when missing")
|
||||||
@@ -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("cargo generate-lockfile" in read(".github/workflows/ci.yml"), "CI regenerates Cargo.lock before --locked checks")
|
||||||
require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate")
|
require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate")
|
||||||
require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate")
|
require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate")
|
||||||
require(read("README.md").startswith("# RustBox 0.2.13"), "README 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.13"), "release manifest identifies RustBox 0.2.13")
|
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.13"), "release validation identifies RustBox 0.2.13")
|
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.13"), "implementation status identifies RustBox 0.2.13")
|
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")
|
bootstrap_js = read("src/web/js/00-bootstrap.js")
|
||||||
bindings_js = read("src/web/js/95-bindings-boot.js")
|
bindings_js = read("src/web/js/95-bindings-boot.js")
|
||||||
realtime_js = read("src/web/js/80-settings-realtime.js")
|
realtime_js = read("src/web/js/80-settings-realtime.js")
|
||||||
@@ -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('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-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('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('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")
|
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")
|
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")
|
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")
|
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('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("V050" in read("RELEASE-MANIFEST.txt") and "V051" in read("RELEASE-MANIFEST.txt"), "release manifest documents V050 and reserves V051")
|
||||||
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("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((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists")
|
||||||
require('imap_host = ""' not in read("config.dev.toml"), "development config does not override IMAP host with an invalid empty value")
|
require('imap_host = ""' not in read("config.dev.toml"), "development config does not override IMAP host with an invalid empty value")
|
||||||
require(not re.search(r"(?m)^IMAP_HOST=\s*$", read(".env.example")), "environment example does not set an invalid empty IMAP host")
|
require(not re.search(r"(?m)^IMAP_HOST=\s*$", read(".env.example")), "environment example does not set an invalid empty IMAP host")
|
||||||
@@ -612,13 +620,35 @@ try:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"SQLite V049 migration execution failed: {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.
|
# Migration/query parity across SQL engines.
|
||||||
for dialect in ("sqlite", "mysql", "pgsql"):
|
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}/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}/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}/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")
|
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")
|
queries = read(f"src/queries/{dialect}/queries.rs")
|
||||||
folder_block = re.search(r"pub mod db_folders\s*\{(.*?)\n\}", queries, re.S)
|
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}")
|
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 "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 "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 "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("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("latest_schema_version" in pool, "runtime schema version is computed from migrations")
|
||||||
require(
|
require(
|
||||||
|
|||||||
@@ -414,12 +414,6 @@ pub fn update_preferences(
|
|||||||
if let Some(v) = data.row_height {
|
if let Some(v) = data.row_height {
|
||||||
range("row_height", v, 42, 88)?;
|
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 {
|
if let Some(v) = data.border_radius {
|
||||||
range("border_radius", v, 0, 12)?;
|
range("border_radius", v, 0, 12)?;
|
||||||
}
|
}
|
||||||
@@ -505,8 +499,16 @@ pub fn update_preferences(
|
|||||||
set_text!(font_family, "font_family");
|
set_text!(font_family, "font_family");
|
||||||
set_int!(font_size, "font_size");
|
set_int!(font_size, "font_size");
|
||||||
set_int!(row_height, "row_height");
|
set_int!(row_height, "row_height");
|
||||||
set_int!(folder_pane_width, "folder_pane_width");
|
if let Some(value) = data.folder_pane_width {
|
||||||
set_int!(message_pane_width, "message_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_int!(border_radius, "border_radius");
|
||||||
set_text!(contrast, "contrast");
|
set_text!(contrast, "contrast");
|
||||||
set_bool!(show_avatars, "show_avatars");
|
set_bool!(show_avatars, "show_avatars");
|
||||||
@@ -578,6 +580,28 @@ mod tests {
|
|||||||
assert!(prefs.preset_scale_unlocked);
|
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]
|
#[test]
|
||||||
fn built_in_interface_skins_are_accepted() {
|
fn built_in_interface_skins_are_accepted() {
|
||||||
for skin in [
|
for skin in [
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ mod tests {
|
|||||||
default_sort: None,
|
default_sort: None,
|
||||||
flagged_on_top: Some(true),
|
flagged_on_top: Some(true),
|
||||||
sync_imap_keywords: None,
|
sync_imap_keywords: None,
|
||||||
|
compact_wide_rows: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
+37
-4
@@ -17,6 +17,7 @@ pub struct Identity {
|
|||||||
pub signature_reply: bool,
|
pub signature_reply: bool,
|
||||||
pub signature_forward: bool,
|
pub signature_forward: bool,
|
||||||
pub signature_position: String,
|
pub signature_position: String,
|
||||||
|
pub signature_insert_mode: String,
|
||||||
pub pgp_fingerprint: Option<String>,
|
pub pgp_fingerprint: Option<String>,
|
||||||
pub pgp_sign_default: bool,
|
pub pgp_sign_default: bool,
|
||||||
pub pgp_encrypt_default: bool,
|
pub pgp_encrypt_default: bool,
|
||||||
@@ -50,6 +51,8 @@ pub struct CreateIdentity {
|
|||||||
pub signature_forward: bool,
|
pub signature_forward: bool,
|
||||||
#[serde(default = "default_signature_position")]
|
#[serde(default = "default_signature_position")]
|
||||||
pub signature_position: String,
|
pub signature_position: String,
|
||||||
|
#[serde(default = "default_signature_insert_mode")]
|
||||||
|
pub signature_insert_mode: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pgp_fingerprint: Option<String>,
|
pub pgp_fingerprint: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -74,6 +77,7 @@ pub struct UpdateIdentity {
|
|||||||
pub signature_reply: Option<bool>,
|
pub signature_reply: Option<bool>,
|
||||||
pub signature_forward: Option<bool>,
|
pub signature_forward: Option<bool>,
|
||||||
pub signature_position: Option<String>,
|
pub signature_position: Option<String>,
|
||||||
|
pub signature_insert_mode: Option<String>,
|
||||||
pub pgp_fingerprint: Option<Option<String>>,
|
pub pgp_fingerprint: Option<Option<String>>,
|
||||||
pub pgp_sign_default: Option<bool>,
|
pub pgp_sign_default: Option<bool>,
|
||||||
pub pgp_encrypt_default: Option<bool>,
|
pub pgp_encrypt_default: Option<bool>,
|
||||||
@@ -87,6 +91,18 @@ fn default_signature_position() -> String {
|
|||||||
"above".to_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> {
|
fn validate_signature_position(value: &str) -> Result<(), String> {
|
||||||
if matches!(value, "above" | "below") {
|
if matches!(value, "above" | "below") {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -112,10 +128,11 @@ fn row_to_identity(row: &crate::db::pool::Row<'_>) -> Result<Identity, crate::db
|
|||||||
signature_reply: row.get::<_, i32>(12)? != 0,
|
signature_reply: row.get::<_, i32>(12)? != 0,
|
||||||
signature_forward: row.get::<_, i32>(13)? != 0,
|
signature_forward: row.get::<_, i32>(13)? != 0,
|
||||||
signature_position: row.get(14)?,
|
signature_position: row.get(14)?,
|
||||||
pgp_fingerprint: row.get(15)?,
|
signature_insert_mode: row.get(15)?,
|
||||||
pgp_sign_default: row.get::<_, i32>(16)? != 0,
|
pgp_fingerprint: row.get(16)?,
|
||||||
pgp_encrypt_default: row.get::<_, i32>(17)? != 0,
|
pgp_sign_default: row.get::<_, i32>(17)? != 0,
|
||||||
pgp_inline_default: row.get::<_, i32>(18)? != 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<Option<Identity>, Strin
|
|||||||
|
|
||||||
pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result<Identity, String> {
|
pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result<Identity, String> {
|
||||||
validate_signature_position(&data.signature_position)?;
|
validate_signature_position(&data.signature_position)?;
|
||||||
|
validate_signature_insert_mode(&data.signature_insert_mode)?;
|
||||||
if data.is_default {
|
if data.is_default {
|
||||||
clear_default(conn)?;
|
clear_default(conn)?;
|
||||||
}
|
}
|
||||||
@@ -174,6 +192,7 @@ pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result<Ident
|
|||||||
data.signature_reply as i32,
|
data.signature_reply as i32,
|
||||||
data.signature_forward as i32,
|
data.signature_forward as i32,
|
||||||
data.signature_position,
|
data.signature_position,
|
||||||
|
data.signature_insert_mode,
|
||||||
data.pgp_fingerprint,
|
data.pgp_fingerprint,
|
||||||
data.pgp_sign_default as i32,
|
data.pgp_sign_default as i32,
|
||||||
data.pgp_encrypt_default as i32,
|
data.pgp_encrypt_default as i32,
|
||||||
@@ -198,6 +217,9 @@ pub fn update_identity(
|
|||||||
if let Some(value) = data.signature_position.as_deref() {
|
if let Some(value) = data.signature_position.as_deref() {
|
||||||
validate_signature_position(value)?;
|
validate_signature_position(value)?;
|
||||||
}
|
}
|
||||||
|
if let Some(value) = data.signature_insert_mode.as_deref() {
|
||||||
|
validate_signature_insert_mode(value)?;
|
||||||
|
}
|
||||||
let mut sets = Vec::new();
|
let mut sets = Vec::new();
|
||||||
let mut values: Vec<DbValue> = Vec::new();
|
let mut values: Vec<DbValue> = Vec::new();
|
||||||
let mut idx = 1usize;
|
let mut idx = 1usize;
|
||||||
@@ -231,6 +253,7 @@ pub fn update_identity(
|
|||||||
boolean!(signature_reply, "signature_reply");
|
boolean!(signature_reply, "signature_reply");
|
||||||
boolean!(signature_forward, "signature_forward");
|
boolean!(signature_forward, "signature_forward");
|
||||||
text!(signature_position, "signature_position");
|
text!(signature_position, "signature_position");
|
||||||
|
text!(signature_insert_mode, "signature_insert_mode");
|
||||||
if let Some(ref value) = data.pgp_fingerprint {
|
if let Some(ref value) = data.pgp_fingerprint {
|
||||||
sets.push(format!("pgp_fingerprint = ?{idx}"));
|
sets.push(format!("pgp_fingerprint = ?{idx}"));
|
||||||
values.push(value.to_db_value());
|
values.push(value.to_db_value());
|
||||||
@@ -293,6 +316,7 @@ mod tests {
|
|||||||
signature_reply: true,
|
signature_reply: true,
|
||||||
signature_forward: true,
|
signature_forward: true,
|
||||||
signature_position: "above".into(),
|
signature_position: "above".into(),
|
||||||
|
signature_insert_mode: "silent".into(),
|
||||||
pgp_fingerprint: None,
|
pgp_fingerprint: None,
|
||||||
pgp_sign_default: false,
|
pgp_sign_default: false,
|
||||||
pgp_encrypt_default: false,
|
pgp_encrypt_default: false,
|
||||||
@@ -313,6 +337,7 @@ mod tests {
|
|||||||
assert_eq!(identity.bcc, "archive@example.com");
|
assert_eq!(identity.bcc, "archive@example.com");
|
||||||
assert_eq!(identity.organization, "Example");
|
assert_eq!(identity.organization, "Example");
|
||||||
assert_eq!(identity.signature_position, "below");
|
assert_eq!(identity.signature_position, "below");
|
||||||
|
assert_eq!(identity.signature_insert_mode, "silent");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -324,6 +349,14 @@ mod tests {
|
|||||||
assert!(!get_identity(&conn, first.id).unwrap().unwrap().is_default);
|
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]
|
#[test]
|
||||||
fn rejects_invalid_signature_position() {
|
fn rejects_invalid_signature_position() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub struct MailPreferences {
|
|||||||
pub default_sort: String,
|
pub default_sort: String,
|
||||||
pub flagged_on_top: bool,
|
pub flagged_on_top: bool,
|
||||||
pub sync_imap_keywords: bool,
|
pub sync_imap_keywords: bool,
|
||||||
|
pub compact_wide_rows: bool,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ pub struct UpdateMailPreferences {
|
|||||||
pub default_sort: Option<String>,
|
pub default_sort: Option<String>,
|
||||||
pub flagged_on_top: Option<bool>,
|
pub flagged_on_top: Option<bool>,
|
||||||
pub sync_imap_keywords: Option<bool>,
|
pub sync_imap_keywords: Option<bool>,
|
||||||
|
pub compact_wide_rows: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_preferences(conn: &Connection) -> Result<MailPreferences, String> {
|
pub fn get_preferences(conn: &Connection) -> Result<MailPreferences, String> {
|
||||||
@@ -43,7 +45,8 @@ pub fn get_preferences(conn: &Connection) -> Result<MailPreferences, String> {
|
|||||||
default_sort: row.get(7)?,
|
default_sort: row.get(7)?,
|
||||||
flagged_on_top: row.get::<_, i32>(8)? != 0,
|
flagged_on_top: row.get::<_, i32>(8)? != 0,
|
||||||
sync_imap_keywords: row.get::<_, i32>(9)? != 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}"))
|
.map_err(|error| format!("Failed to read mail preferences: {error}"))
|
||||||
@@ -121,6 +124,7 @@ pub fn update_preferences(
|
|||||||
let sync_imap_keywords = data
|
let sync_imap_keywords = data
|
||||||
.sync_imap_keywords
|
.sync_imap_keywords
|
||||||
.unwrap_or(current.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(
|
conn.execute(
|
||||||
crate::dbq!(db_mail_preferences::Q_003),
|
crate::dbq!(db_mail_preferences::Q_003),
|
||||||
@@ -134,7 +138,8 @@ pub fn update_preferences(
|
|||||||
show_snippets as i32,
|
show_snippets as i32,
|
||||||
default_sort,
|
default_sort,
|
||||||
flagged_on_top as i32,
|
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}"))?;
|
.map_err(|error| format!("Failed to update mail preferences: {error}"))?;
|
||||||
@@ -167,6 +172,7 @@ mod tests {
|
|||||||
default_sort: Some("date_asc".to_string()),
|
default_sort: Some("date_asc".to_string()),
|
||||||
flagged_on_top: Some(true),
|
flagged_on_top: Some(true),
|
||||||
sync_imap_keywords: Some(true),
|
sync_imap_keywords: Some(true),
|
||||||
|
compact_wide_rows: Some(false),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -175,5 +181,6 @@ mod tests {
|
|||||||
assert_eq!(updated.thread_mode, "flat");
|
assert_eq!(updated.thread_mode, "flat");
|
||||||
assert!(updated.flagged_on_top);
|
assert!(updated.flagged_on_top);
|
||||||
assert!(updated.sync_imap_keywords);
|
assert!(updated.sync_imap_keywords);
|
||||||
|
assert!(!updated.compact_wide_rows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,10 @@ const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
|
|||||||
49,
|
49,
|
||||||
include_str!("../../migrations/sqlite/V049__blob_delete_outbox.sql"),
|
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)] = &[
|
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||||
(
|
(
|
||||||
@@ -48,6 +52,10 @@ const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
|
|||||||
49,
|
49,
|
||||||
include_str!("../../migrations/mysql/V049__blob_delete_outbox.sql"),
|
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)] = &[
|
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||||
(
|
(
|
||||||
@@ -62,6 +70,10 @@ const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
|
|||||||
49,
|
49,
|
||||||
include_str!("../../migrations/pgsql/V049__blob_delete_outbox.sql"),
|
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] = &[
|
const TENANT_TABLES: &[&str] = &[
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ mod tests {
|
|||||||
default_sort: None,
|
default_sort: None,
|
||||||
flagged_on_top: None,
|
flagged_on_top: None,
|
||||||
sync_imap_keywords: None,
|
sync_imap_keywords: None,
|
||||||
|
compact_wide_rows: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -257,8 +257,8 @@ pub mod db_header_sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod db_identities {
|
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 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,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
|
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_002: &str = "DELETE FROM identities WHERE id = ?";
|
||||||
pub const Q_003: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM identities";
|
pub const Q_003: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM identities";
|
||||||
pub const Q_004: &str =
|
pub const Q_004: &str =
|
||||||
@@ -272,10 +272,10 @@ pub mod db_identities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod db_mail_preferences {
|
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 =
|
pub const Q_002: &str =
|
||||||
"INSERT IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)";
|
"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 {
|
pub mod db_messages {
|
||||||
|
|||||||
@@ -258,8 +258,8 @@ pub mod db_header_sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod db_identities {
|
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 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,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 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_002: &str = "DELETE FROM identities WHERE id = $1";
|
||||||
pub const Q_003: &str = "SELECT COUNT(*) FROM identities";
|
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";
|
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 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_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 {
|
pub mod db_messages {
|
||||||
|
|||||||
@@ -256,8 +256,8 @@ pub mod db_header_sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod db_identities {
|
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 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,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 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_002: &str = "DELETE FROM identities WHERE id = ?1";
|
||||||
pub const Q_003: &str = "SELECT COUNT(*) FROM identities";
|
pub const Q_003: &str = "SELECT COUNT(*) FROM identities";
|
||||||
pub const Q_004: &str =
|
pub const Q_004: &str =
|
||||||
@@ -271,10 +271,10 @@ pub mod db_identities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod db_mail_preferences {
|
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 =
|
pub const Q_002: &str =
|
||||||
"INSERT OR IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)";
|
"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 {
|
pub mod db_messages {
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ pub async fn login(
|
|||||||
signature_reply: true,
|
signature_reply: true,
|
||||||
signature_forward: true,
|
signature_forward: true,
|
||||||
signature_position: "above".to_string(),
|
signature_position: "above".to_string(),
|
||||||
|
signature_insert_mode: "silent".to_string(),
|
||||||
pgp_fingerprint: None,
|
pgp_fingerprint: None,
|
||||||
pgp_sign_default: false,
|
pgp_sign_default: false,
|
||||||
pgp_encrypt_default: false,
|
pgp_encrypt_default: false,
|
||||||
|
|||||||
+212
-2
@@ -2,6 +2,8 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::{Extension, Json};
|
use axum::{Extension, Json};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::auth::session::SessionState;
|
use crate::auth::session::SessionState;
|
||||||
@@ -28,6 +30,8 @@ pub struct SendRequest {
|
|||||||
pub in_reply_to: Option<String>,
|
pub in_reply_to: Option<String>,
|
||||||
pub references: Option<String>,
|
pub references: Option<String>,
|
||||||
|
|
||||||
|
pub compose_action: Option<String>,
|
||||||
|
|
||||||
pub draft_id: Option<String>,
|
pub draft_id: Option<String>,
|
||||||
|
|
||||||
pub from_identity_id: Option<i64>,
|
pub from_identity_id: Option<i64>,
|
||||||
@@ -60,6 +64,181 @@ fn default_priority() -> String {
|
|||||||
"normal".to_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<AttachmentData>) -> 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::<String>()
|
||||||
|
})
|
||||||
|
.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!(
|
||||||
|
"<div class=\"rustbox-signature\" data-rustbox-signature=\"true\">{signature}</div>"
|
||||||
|
);
|
||||||
|
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<String>,
|
||||||
|
attachments: &mut Vec<AttachmentData>,
|
||||||
|
) {
|
||||||
|
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', "<br>");
|
||||||
|
append_html_signature(html, &escaped, &identity.signature_position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_mailbox(label: &str, value: &str) -> Result<(), AppError> {
|
fn validate_mailbox(label: &str, value: &str) -> Result<(), AppError> {
|
||||||
value
|
value
|
||||||
.parse::<lettre::message::Mailbox>()
|
.parse::<lettre::message::Mailbox>()
|
||||||
@@ -231,6 +410,18 @@ pub async fn send_message_handler(
|
|||||||
&req.cc,
|
&req.cc,
|
||||||
&bcc,
|
&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);
|
drop(conn);
|
||||||
|
|
||||||
validate_mailbox("From", &from_address)?;
|
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 {
|
let message = SendableMessage {
|
||||||
from: from_address,
|
from: from_address,
|
||||||
to: req.to,
|
to: req.to,
|
||||||
@@ -313,8 +523,8 @@ pub async fn send_message_handler(
|
|||||||
reply_to,
|
reply_to,
|
||||||
organization,
|
organization,
|
||||||
subject: req.subject,
|
subject: req.subject,
|
||||||
text_body: req.text_body.unwrap_or_default(),
|
text_body,
|
||||||
html_body: req.html_body,
|
html_body,
|
||||||
in_reply_to: req.in_reply_to,
|
in_reply_to: req.in_reply_to,
|
||||||
references: req.references,
|
references: req.references,
|
||||||
read_receipt_to: req.request_mdn.then_some(receipt_address),
|
read_receipt_to: req.request_mdn.then_some(receipt_address),
|
||||||
|
|||||||
@@ -214,6 +214,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-pane {
|
.message-pane {
|
||||||
|
container-name: message-pane;
|
||||||
|
container-type: inline-size;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +311,10 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pane-resizer[data-temporarily-disabled="true"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.pane-resizer::after {
|
.pane-resizer::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
+91
-1
@@ -1354,4 +1354,94 @@ body.reader-fullscreen-open {
|
|||||||
.reader-toolbar .toolbar-menu>summary {
|
.reader-toolbar .toolbar-menu>summary {
|
||||||
min-width: max-content;
|
min-width: max-content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -906,6 +906,12 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compose-signature {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.compose-signature img {
|
.compose-signature img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|||||||
@@ -639,7 +639,11 @@
|
|||||||
"Hide fields": "Ukryj pola",
|
"Hide fields": "Ukryj pola",
|
||||||
"Show fields": "Pokaż pola",
|
"Show fields": "Pokaż pola",
|
||||||
"Hide addressing fields": "Ukryj pola adresowe",
|
"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ę.",
|
"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.",
|
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
|
||||||
|
|||||||
+8
-2
@@ -708,10 +708,10 @@
|
|||||||
value="58"></label>
|
value="58"></label>
|
||||||
<label><span><span data-i18n="folder_width">Folder pane width</span> <output
|
<label><span><span data-i18n="folder_width">Folder pane width</span> <output
|
||||||
id="pref-folder-width-value">228 px</output></span><input id="pref-folder-width" type="range"
|
id="pref-folder-width-value">228 px</output></span><input id="pref-folder-width" type="range"
|
||||||
min="180" max="360" step="4" value="228"></label>
|
min="180" max="420" step="4" value="228"></label>
|
||||||
<label><span><span data-i18n="message_width">Message pane width</span> <output
|
<label><span><span data-i18n="message_width">Message pane width</span> <output
|
||||||
id="pref-message-width-value">420 px</output></span><input id="pref-message-width" type="range"
|
id="pref-message-width-value">420 px</output></span><input id="pref-message-width" type="range"
|
||||||
min="300" max="720" step="10" value="420"></label>
|
min="300" max="760" step="10" value="420"></label>
|
||||||
<label><span><span data-i18n="corner_radius">Corner radius</span> <output id="pref-radius-value">4
|
<label><span><span data-i18n="corner_radius">Corner radius</span> <output id="pref-radius-value">4
|
||||||
px</output></span><input id="pref-radius" type="range" min="0" max="12" step="1" value="4"></label>
|
px</output></span><input id="pref-radius" type="range" min="0" max="12" step="1" value="4"></label>
|
||||||
<label><span data-i18n="contrast">Contrast</span><select id="pref-contrast">
|
<label><span data-i18n="contrast">Contrast</span><select id="pref-contrast">
|
||||||
@@ -782,6 +782,8 @@
|
|||||||
<div class="appearance-toggles">
|
<div class="appearance-toggles">
|
||||||
<label class="checkbox-row"><input id="pref-compact-headers" type="checkbox"><span>Compact message
|
<label class="checkbox-row"><input id="pref-compact-headers" type="checkbox"><span>Compact message
|
||||||
headers</span></label>
|
headers</span></label>
|
||||||
|
<label class="checkbox-row"><input id="pref-compact-wide-rows" type="checkbox"><span>Use single-line
|
||||||
|
rows when the message list is wide</span></label>
|
||||||
<label class="checkbox-row"><input id="pref-show-borders" type="checkbox"><span>Show panel
|
<label class="checkbox-row"><input id="pref-show-borders" type="checkbox"><span>Show panel
|
||||||
borders</span></label>
|
borders</span></label>
|
||||||
<label class="checkbox-row"><input id="pref-monochrome" type="checkbox"><span>Monochrome
|
<label class="checkbox-row"><input id="pref-monochrome" type="checkbox"><span>Monochrome
|
||||||
@@ -1273,6 +1275,10 @@
|
|||||||
<option value="above">Above quoted text</option>
|
<option value="above">Above quoted text</option>
|
||||||
<option value="below">Below quoted text</option>
|
<option value="below">Below quoted text</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
|
<label><span>Signature insertion</span><select id="identity-signature-mode">
|
||||||
|
<option value="silent">Quietly when sending</option>
|
||||||
|
<option value="visible">Show in composer</option>
|
||||||
|
</select></label>
|
||||||
<label id="identity-pgp-key-row"><span>OpenPGP key</span><select id="identity-pgp-key">
|
<label id="identity-pgp-key-row"><span>OpenPGP key</span><select id="identity-pgp-key">
|
||||||
<option value="">Automatic / none</option>
|
<option value="">Automatic / none</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
savedSearches: [],
|
savedSearches: [],
|
||||||
responseTemplates: [],
|
responseTemplates: [],
|
||||||
notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" },
|
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,
|
currentFolderFlaggedOnTop: false,
|
||||||
markReadTimer: null,
|
markReadTimer: null,
|
||||||
ws: null,
|
ws: null,
|
||||||
@@ -82,9 +82,10 @@
|
|||||||
composeUploading: false,
|
composeUploading: false,
|
||||||
composeUploadController: null,
|
composeUploadController: null,
|
||||||
composeReplyMeta: null,
|
composeReplyMeta: null,
|
||||||
|
composeAction: "new",
|
||||||
|
composeSignatureGeneration: 0,
|
||||||
composeInsertRange: null,
|
composeInsertRange: null,
|
||||||
composeSelectedImage: null,
|
composeSelectedImage: null,
|
||||||
composeSignatureGeneration: 0,
|
|
||||||
identitySignatureRange: null,
|
identitySignatureRange: null,
|
||||||
inlineAttachmentIds: new Set(),
|
inlineAttachmentIds: new Set(),
|
||||||
inlineImageCleanupTimer: null,
|
inlineImageCleanupTimer: null,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ let presetPromotionTimer = null;
|
|||||||
let presetPromotionInFlight = false;
|
let presetPromotionInFlight = false;
|
||||||
let presetPromotionNeedsAnotherSave = false;
|
let presetPromotionNeedsAnotherSave = false;
|
||||||
let presetPromotionActive = false;
|
let presetPromotionActive = false;
|
||||||
|
const temporarilyDisabledPanePreferences = new Set();
|
||||||
|
|
||||||
const interfaceStyleProfiles = {
|
const interfaceStyleProfiles = {
|
||||||
"roundcube-classic": {
|
"roundcube-classic": {
|
||||||
@@ -348,9 +349,29 @@ async function loadMailPreferences() {
|
|||||||
if (flaggedTop) flaggedTop.checked = Boolean(state.mailPrefs.flagged_on_top);
|
if (flaggedTop) flaggedTop.checked = Boolean(state.mailPrefs.flagged_on_top);
|
||||||
const syncKeywords = $("#mail-pref-sync-keywords");
|
const syncKeywords = $("#mail-pref-sync-keywords");
|
||||||
if (syncKeywords) syncKeywords.checked = Boolean(state.mailPrefs.sync_imap_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); }
|
} 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() {
|
async function saveMailPreferences() {
|
||||||
const payload = {
|
const payload = {
|
||||||
messages_per_page: Number($("#mail-pref-per-page").value),
|
messages_per_page: Number($("#mail-pref-per-page").value),
|
||||||
@@ -367,6 +388,7 @@ async function saveMailPreferences() {
|
|||||||
try {
|
try {
|
||||||
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify(payload) });
|
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify(payload) });
|
||||||
state.perPage = Number(state.mailPrefs.messages_per_page || 300);
|
state.perPage = Number(state.mailPrefs.messages_per_page || 300);
|
||||||
|
root.dataset.compactWideRows = state.mailPrefs.compact_wide_rows === false ? "false" : "true";
|
||||||
toast("Mailbox settings saved");
|
toast("Mailbox settings saved");
|
||||||
if (state.currentDetail) renderMessageDetail(state.currentDetail);
|
if (state.currentDetail) renderMessageDetail(state.currentDetail);
|
||||||
await loadMessages(true);
|
await loadMessages(true);
|
||||||
@@ -410,6 +432,39 @@ const presetEditablePreferenceKeys = new Set([
|
|||||||
"folderBackground", "messageBackground", "readerBackground",
|
"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) {
|
function presetCssHex(styles, name, fallback) {
|
||||||
const value = styles.getPropertyValue(name).trim();
|
const value = styles.getPropertyValue(name).trim();
|
||||||
if (/^#[0-9a-f]{6}$/i.test(value)) return value;
|
if (/^#[0-9a-f]{6}$/i.test(value)) return value;
|
||||||
@@ -441,8 +496,10 @@ function schedulePromotedAppearanceSave() {
|
|||||||
presetPromotionNeedsAnotherSave = false;
|
presetPromotionNeedsAnotherSave = false;
|
||||||
try {
|
try {
|
||||||
await request("/settings/display", { method: "PUT", body: JSON.stringify(customAppearancePayload(state.prefs)) });
|
await request("/settings/display", { method: "PUT", body: JSON.stringify(customAppearancePayload(state.prefs)) });
|
||||||
|
restorePanePreference("folderPaneWidth");
|
||||||
|
restorePanePreference("messagePaneWidth");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error.message, "error");
|
if (!suppressPaneWidthValidationError(error)) toast(error.message, "error");
|
||||||
} finally {
|
} finally {
|
||||||
presetPromotionInFlight = false;
|
presetPromotionInFlight = false;
|
||||||
if (presetPromotionNeedsAnotherSave) schedulePromotedAppearanceSave();
|
if (presetPromotionNeedsAnotherSave) schedulePromotedAppearanceSave();
|
||||||
@@ -498,16 +555,25 @@ function savePreference(key, value) {
|
|||||||
if (!field) return;
|
if (!field) return;
|
||||||
const payload = { [field]: state.prefs[key] };
|
const payload = { [field]: state.prefs[key] };
|
||||||
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) payload.color_preset = "custom";
|
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) payload.color_preset = "custom";
|
||||||
try { await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }); }
|
try {
|
||||||
catch (error) { toast(error.message, "error"); }
|
await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) });
|
||||||
|
restorePanePreference(key);
|
||||||
|
} catch (error) { if (!suppressPaneWidthValidationError(error)) toast(error.message, "error"); }
|
||||||
}, 180);
|
}, 180);
|
||||||
preferenceTimers.set(key, timer);
|
preferenceTimers.set(key, timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetAppearance() {
|
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: "" };
|
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"); }
|
try {
|
||||||
catch (error) { toast(error.message, "error"); }
|
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"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,19 @@ function estimateComposeMessageBytes(extraFiles = []) {
|
|||||||
const mode = $("#compose-mode")?.value || "html";
|
const mode = $("#compose-mode")?.value || "html";
|
||||||
if (mode === "html") bytes += utf8Bytes(editor.innerHTML);
|
if (mode === "html") bytes += utf8Bytes(editor.innerHTML);
|
||||||
else if (mode === "markdown") bytes += Math.ceil(plainBytes * 1.25);
|
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 attachment of state.composeAttachments) bytes += estimatedMimeAttachmentBytes(attachment.size);
|
||||||
for (const file of extraFiles || []) bytes += estimatedMimeAttachmentBytes(file.size);
|
for (const file of extraFiles || []) bytes += estimatedMimeAttachmentBytes(file.size);
|
||||||
@@ -551,6 +564,7 @@ function composeDraftPayload() {
|
|||||||
references: state.composeReplyMeta?.references || null,
|
references: state.composeReplyMeta?.references || null,
|
||||||
request_mdn: Boolean($("#compose-mdn")?.checked),
|
request_mdn: Boolean($("#compose-mdn")?.checked),
|
||||||
priority: $("#compose-priority")?.value || "normal",
|
priority: $("#compose-priority")?.value || "normal",
|
||||||
|
compose_action: state.composeAction || "new",
|
||||||
compose_format: $("#compose-mode")?.value || "html",
|
compose_format: $("#compose-mode")?.value || "html",
|
||||||
pgp_mode: $("#compose-pgp-mode")?.value || "none",
|
pgp_mode: $("#compose-pgp-mode")?.value || "none",
|
||||||
pgp_inline: Boolean($("#compose-pgp-inline")?.checked),
|
pgp_inline: Boolean($("#compose-pgp-inline")?.checked),
|
||||||
@@ -1414,8 +1428,7 @@ async function uploadInlineImage(files, insertionRange = null) {
|
|||||||
|
|
||||||
async function openImapDraftComposer(folder, uid) {
|
async function openImapDraftComposer(folder, uid) {
|
||||||
const draft = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" });
|
const draft = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" });
|
||||||
openCompose("new");
|
openCompose("new", { skipSignature: true });
|
||||||
state.composeSignatureGeneration += 1;
|
|
||||||
state.composeDraftId = ensureDraftId();
|
state.composeDraftId = ensureDraftId();
|
||||||
state.composeRemoteDraft = draft.imap_source || { folder, uid, uid_validity: 0 };
|
state.composeRemoteDraft = draft.imap_source || { folder, uid, uid_validity: 0 };
|
||||||
state.composeAttachments = mergeComposeAttachments(draft.attachments);
|
state.composeAttachments = mergeComposeAttachments(draft.attachments);
|
||||||
@@ -1425,6 +1438,7 @@ async function openImapDraftComposer(folder, uid) {
|
|||||||
inReplyTo: draft.in_reply_to || null,
|
inReplyTo: draft.in_reply_to || null,
|
||||||
references: draft.references || 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-to").value = draft.to || "";
|
||||||
$("#compose-cc").value = draft.cc || "";
|
$("#compose-cc").value = draft.cc || "";
|
||||||
$("#compose-bcc").value = draft.bcc || "";
|
$("#compose-bcc").value = draft.bcc || "";
|
||||||
@@ -1446,6 +1460,7 @@ async function openImapDraftComposer(folder, uid) {
|
|||||||
const editor = $("#compose-editor");
|
const editor = $("#compose-editor");
|
||||||
if (mode === "html") editor.innerHTML = hydrateComposeHtml(draft.html_body || "", state.composeAttachments, state.composeDraftId);
|
if (mode === "html") editor.innerHTML = hydrateComposeHtml(draft.html_body || "", state.composeAttachments, state.composeDraftId);
|
||||||
else editor.textContent = draft.text_body || "";
|
else editor.textContent = draft.text_body || "";
|
||||||
|
insertIdentitySignature(editor, state.composeAction, { preserveExisting: true });
|
||||||
renderComposeAttachments();
|
renderComposeAttachments();
|
||||||
state.composeDirty = false;
|
state.composeDirty = false;
|
||||||
state.composeNeedsRemoteSync = false;
|
state.composeNeedsRemoteSync = false;
|
||||||
@@ -1458,7 +1473,6 @@ async function openImapDraftComposer(folder, uid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
|
function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
|
||||||
state.composeSignatureGeneration += 1;
|
|
||||||
state.composeDraftId = draft.id || null;
|
state.composeDraftId = draft.id || null;
|
||||||
state.composeRemoteDraft = draft.imap_source || null;
|
state.composeRemoteDraft = draft.imap_source || null;
|
||||||
state.composeAttachments = mergeComposeAttachments(draft.attachments);
|
state.composeAttachments = mergeComposeAttachments(draft.attachments);
|
||||||
@@ -1468,6 +1482,7 @@ function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
|
|||||||
inReplyTo: draft.in_reply_to || null,
|
inReplyTo: draft.in_reply_to || null,
|
||||||
references: draft.references || 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-to").value = draft.to || "";
|
||||||
$("#compose-cc").value = draft.cc || "";
|
$("#compose-cc").value = draft.cc || "";
|
||||||
$("#compose-bcc").value = draft.bcc || "";
|
$("#compose-bcc").value = draft.bcc || "";
|
||||||
@@ -1485,6 +1500,7 @@ function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
|
|||||||
const editor = $("#compose-editor");
|
const editor = $("#compose-editor");
|
||||||
if (mode === "html" && draft.html_body) editor.innerHTML = hydrateComposeHtml(draft.html_body, state.composeAttachments, state.composeDraftId);
|
if (mode === "html" && draft.html_body) editor.innerHTML = hydrateComposeHtml(draft.html_body, state.composeAttachments, state.composeDraftId);
|
||||||
else editor.textContent = draft.text_body || "";
|
else editor.textContent = draft.text_body || "";
|
||||||
|
insertIdentitySignature(editor, state.composeAction, { preserveExisting: true });
|
||||||
renderComposeAttachments();
|
renderComposeAttachments();
|
||||||
state.composeDirty = sourceLabel === "Browser recovery";
|
state.composeDirty = sourceLabel === "Browser recovery";
|
||||||
state.composeNeedsRemoteSync = 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 (!saved) toast("Draft kept in this browser for recovery", "warning", 5200);
|
||||||
}
|
}
|
||||||
if ($("#compose-dialog").open) $("#compose-dialog").close();
|
if ($("#compose-dialog").open) $("#compose-dialog").close();
|
||||||
state.composeSignatureGeneration += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachmentReminderNeeded() {
|
function attachmentReminderNeeded() {
|
||||||
@@ -1649,7 +1664,7 @@ function toggleComposeFields() {
|
|||||||
if (collapse) $("#compose-editor")?.focus();
|
if (collapse) $("#compose-editor")?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCompose(mode = "new") {
|
function openCompose(mode = "new", options = {}) {
|
||||||
if (mode === "forward" && state.prefs.forwardMode === "attachment") {
|
if (mode === "forward" && state.prefs.forwardMode === "attachment") {
|
||||||
void forwardAsAttachment();
|
void forwardAsAttachment();
|
||||||
return;
|
return;
|
||||||
@@ -1662,13 +1677,13 @@ function openCompose(mode = "new") {
|
|||||||
state.composeDraftId = null;
|
state.composeDraftId = null;
|
||||||
state.composeRemoteDraft = null;
|
state.composeRemoteDraft = null;
|
||||||
state.composeAttachments = [];
|
state.composeAttachments = [];
|
||||||
state.composeSignatureGeneration += 1;
|
|
||||||
state.inlineAttachmentIds = new Set();
|
state.inlineAttachmentIds = new Set();
|
||||||
setSelectedInlineImage(null);
|
setSelectedInlineImage(null);
|
||||||
setComposeFieldsCollapsed(false);
|
setComposeFieldsCollapsed(false);
|
||||||
state.composeDirty = false;
|
state.composeDirty = false;
|
||||||
state.composeNeedsRemoteSync = false;
|
state.composeNeedsRemoteSync = false;
|
||||||
state.composeReplyMeta = null;
|
state.composeReplyMeta = null;
|
||||||
|
state.composeAction = mode === "reply" || mode === "reply-all" ? "reply" : mode === "forward" ? "forward" : "new";
|
||||||
setComposeUploadProgress(false);
|
setComposeUploadProgress(false);
|
||||||
renderComposeAttachments();
|
renderComposeAttachments();
|
||||||
updateComposeDraftDeleteAction();
|
updateComposeDraftDeleteAction();
|
||||||
@@ -1728,7 +1743,7 @@ function openCompose(mode = "new") {
|
|||||||
}
|
}
|
||||||
editor.append(quote);
|
editor.append(quote);
|
||||||
}
|
}
|
||||||
insertIdentitySignature(editor, mode);
|
if (!options.skipSignature) insertIdentitySignature(editor, state.composeAction);
|
||||||
let fullScreen = $("#compose-dialog").classList.contains("compose-fullscreen");
|
let fullScreen = $("#compose-dialog").classList.contains("compose-fullscreen");
|
||||||
try { fullScreen = localStorage.getItem("rustbox.composeFullscreen") === "1"; } catch (_) { }
|
try { fullScreen = localStorage.getItem("rustbox.composeFullscreen") === "1"; } catch (_) { }
|
||||||
setComposeFullscreen(fullScreen, false);
|
setComposeFullscreen(fullScreen, false);
|
||||||
@@ -1791,6 +1806,7 @@ async function sendMessage(event) {
|
|||||||
from_identity_id: $("#compose-from").value ? Number($("#compose-from").value) : null,
|
from_identity_id: $("#compose-from").value ? Number($("#compose-from").value) : null,
|
||||||
request_mdn: Boolean($("#compose-mdn").checked),
|
request_mdn: Boolean($("#compose-mdn").checked),
|
||||||
priority: $("#compose-priority").value || "normal",
|
priority: $("#compose-priority").value || "normal",
|
||||||
|
compose_action: state.composeAction || "new",
|
||||||
pgp_mode: $("#compose-pgp-mode").value || "none",
|
pgp_mode: $("#compose-pgp-mode").value || "none",
|
||||||
pgp_inline: Boolean($("#compose-pgp-inline").checked),
|
pgp_inline: Boolean($("#compose-pgp-inline").checked),
|
||||||
pgp_passphrase: $("#compose-pgp-passphrase").value || "",
|
pgp_passphrase: $("#compose-pgp-passphrase").value || "",
|
||||||
|
|||||||
@@ -904,6 +904,7 @@ async function forwardAsAttachment() {
|
|||||||
try {
|
try {
|
||||||
const blob = await fetchEmlBlob(detail);
|
const blob = await fetchEmlBlob(detail);
|
||||||
openCompose("new");
|
openCompose("new");
|
||||||
|
state.composeAction = "forward";
|
||||||
$("#compose-title").textContent = trText("Forward as attachment");
|
$("#compose-title").textContent = trText("Forward as attachment");
|
||||||
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`;
|
$("#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}`;
|
const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`;
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ function editIdentity(identity = null) {
|
|||||||
$("#identity-signature-text").value = identity?.signature_text || "";
|
$("#identity-signature-text").value = identity?.signature_text || "";
|
||||||
$("#identity-signature").innerHTML = sanitizeMessageHtml(identity?.signature_html || "");
|
$("#identity-signature").innerHTML = sanitizeMessageHtml(identity?.signature_html || "");
|
||||||
$("#identity-signature-position").value = identity?.signature_position || "above";
|
$("#identity-signature-position").value = identity?.signature_position || "above";
|
||||||
|
$("#identity-signature-mode").value = identity?.signature_insert_mode || "silent";
|
||||||
renderIdentityPgpOptions(identity?.pgp_fingerprint || "");
|
renderIdentityPgpOptions(identity?.pgp_fingerprint || "");
|
||||||
$("#identity-pgp-sign").checked = Boolean(identity?.pgp_sign_default);
|
$("#identity-pgp-sign").checked = Boolean(identity?.pgp_sign_default);
|
||||||
$("#identity-pgp-encrypt").checked = Boolean(identity?.pgp_encrypt_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_text: $("#identity-signature-text").value,
|
||||||
signature_html: signatureHtml,
|
signature_html: signatureHtml,
|
||||||
signature_position: $("#identity-signature-position").value,
|
signature_position: $("#identity-signature-position").value,
|
||||||
|
signature_insert_mode: $("#identity-signature-mode").value,
|
||||||
pgp_fingerprint: $("#identity-pgp-key").value,
|
pgp_fingerprint: $("#identity-pgp-key").value,
|
||||||
pgp_sign_default: $("#identity-pgp-sign").checked,
|
pgp_sign_default: $("#identity-pgp-sign").checked,
|
||||||
pgp_encrypt_default: $("#identity-pgp-encrypt").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;
|
return state.identities.find((identity) => identity.id === id) || state.identities.find((identity) => identity.is_default) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function composeActionFromTitle() {
|
function signatureEnabledForCompose(identity, action = state.composeAction || "new") {
|
||||||
const title = $("#compose-title").textContent;
|
if (!identity) return false;
|
||||||
if (title === "Reply" || title === "Reply all") return "reply";
|
if (action === "reply" || action === "reply-all") return identity.signature_reply !== false;
|
||||||
if (title === "Forward") return "forward";
|
if (action === "forward") return identity.signature_forward !== false;
|
||||||
return "new";
|
return identity.signature_new !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityUsesVisibleSignature(identity = selectedIdentity()) {
|
||||||
|
return Boolean(identity && identity.signature_insert_mode === "visible");
|
||||||
}
|
}
|
||||||
|
|
||||||
function dataUrlToSignatureFile(src, index = 0) {
|
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/"]')];
|
const images = [...signature.querySelectorAll('img[src^="data:image/"]')];
|
||||||
if (!images.length) return;
|
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;
|
if (!pairs.length) return;
|
||||||
const uploaded = await uploadComposeFiles(pairs.map((item) => item.file), { announce: false });
|
const uploaded = await uploadComposeFiles(pairs.map((item) => item.file), { announce: false });
|
||||||
if (!uploaded.length) return;
|
if (!uploaded.length) return;
|
||||||
@@ -488,29 +496,76 @@ async function materializeSignatureImages(signature, generation) {
|
|||||||
scheduleDraftSave();
|
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;
|
state.composeSignatureGeneration += 1;
|
||||||
const generation = state.composeSignatureGeneration;
|
editor.querySelectorAll('.compose-signature, [data-rustbox-signature="true"]').forEach((node) => node.remove());
|
||||||
editor.querySelectorAll(".compose-signature").forEach((node) => node.remove());
|
if (options.stripPlainLegacy !== false) stripLegacyPlainSignature(editor);
|
||||||
scheduleUnusedInlineAttachmentCleanup();
|
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();
|
const identity = selectedIdentity();
|
||||||
if (!identity) return;
|
const visible = identityUsesVisibleSignature(identity) && signatureEnabledForCompose(identity, action);
|
||||||
const enabled = action === "reply" ? identity.signature_reply !== false : action === "forward" ? identity.signature_forward !== false : identity.signature_new !== false;
|
if (options.preserveExisting && visible && composeHasVisibleSignature(editor, identity)) return;
|
||||||
if (!enabled) return;
|
|
||||||
const format = $("#compose-mode").value;
|
removeVisibleComposeSignature(editor);
|
||||||
const signature = el("div", "compose-signature");
|
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 (format === "html") {
|
||||||
if (!identity.signature_html) return;
|
const html = String(identity.signature_html || "").trim();
|
||||||
signature.innerHTML = sanitizeMessageHtml(identity.signature_html);
|
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 {
|
} else {
|
||||||
const text = identity.signature_text || "";
|
const text = String(identity.signature_text || "").replace(/[\r\n]+$/g, "");
|
||||||
if (!text) return;
|
if (!text.trim()) return;
|
||||||
signature.textContent = `\n-- \n${text}\n`;
|
signature.textContent = `\n-- \n${text}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quote = editor.querySelector(".compose-quote");
|
const quote = editor.querySelector(".compose-quote");
|
||||||
if (quote && identity.signature_position !== "below") editor.insertBefore(signature, quote);
|
if (quote && identity.signature_position !== "below") editor.insertBefore(signature, quote);
|
||||||
else editor.append(signature);
|
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) {
|
function readFileAsDataUrl(file) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function bindPaneResizer(selector, preferenceKey, minPx, maxPx) {
|
|||||||
const handle = $(selector);
|
const handle = $(selector);
|
||||||
if (!handle) return;
|
if (!handle) return;
|
||||||
handle.addEventListener("pointerdown", (event) => {
|
handle.addEventListener("pointerdown", (event) => {
|
||||||
|
if (handle.dataset.temporarilyDisabled === "true" || temporarilyDisabledPanePreferences.has(preferenceKey)) return;
|
||||||
if (innerWidth <= 820 || event.button !== 0) return;
|
if (innerWidth <= 820 || event.button !== 0) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const scaleFactor = Number(getComputedStyle(root).getPropertyValue("--ui-scale-factor")) || 1;
|
const scaleFactor = Number(getComputedStyle(root).getPropertyValue("--ui-scale-factor")) || 1;
|
||||||
@@ -123,7 +124,7 @@ function bindToolbarMenus() {
|
|||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
bindToolbarMenus();
|
bindToolbarMenus();
|
||||||
bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420);
|
bindPaneResizer("#folder-resizer", "folderPaneWidth", 180, 420);
|
||||||
bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760);
|
bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760);
|
||||||
bindRecipientAutocomplete("#compose-to");
|
bindRecipientAutocomplete("#compose-to");
|
||||||
bindRecipientAutocomplete("#compose-cc");
|
bindRecipientAutocomplete("#compose-cc");
|
||||||
@@ -192,11 +193,11 @@ function bindEvents() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setComposeMode(next, true);
|
setComposeMode(next, true);
|
||||||
insertIdentitySignature(editor);
|
insertIdentitySignature(editor, state.composeAction);
|
||||||
scheduleDraftSave();
|
scheduleDraftSave();
|
||||||
updateComposeMessageSize();
|
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", () => {
|
["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => {
|
||||||
state.pgpRecipientStatus = [];
|
state.pgpRecipientStatus = [];
|
||||||
updateComposePgpUi();
|
updateComposePgpUi();
|
||||||
@@ -429,6 +430,7 @@ function bindEvents() {
|
|||||||
$("#pref-unread-badge").addEventListener("change", (event) => savePreference("unreadBadgeStyle", event.target.value));
|
$("#pref-unread-badge").addEventListener("change", (event) => savePreference("unreadBadgeStyle", event.target.value));
|
||||||
$("#pref-scrollbar-density").addEventListener("change", (event) => savePreference("scrollbarDensity", 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-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-show-borders").addEventListener("change", (event) => savePreference("showBorders", event.target.checked));
|
||||||
$("#pref-monochrome").addEventListener("change", (event) => savePreference("monochrome", 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]) => {
|
[["folder", "folderBackground", "#101310"], ["message", "messageBackground", "#181c19"], ["reader", "readerBackground", "#181c19"]].forEach(([name, key, fallback]) => {
|
||||||
|
|||||||
Reference in new issue
Block a user