1 parent
edda5fc9e4
commit
fb9eab87eb
21 files changed
+179
-62
No files matched your search
@@ -1,5 +1,15 @@
|
|||||||
# RustBox changelog
|
# RustBox changelog
|
||||||
|
|
||||||
|
### 0.2.15
|
||||||
|
- Fixed HTML signature editor container semantics so clicks on whitespace/outside the editable area no longer implicitly activate the Bold toolbar button.
|
||||||
|
|
||||||
|
- Fixed the HTML signature editor so toolbar actions preserve the active text selection and only run on an actual selection; collapsed-caret clicks no longer enable sticky bold/italic/underline typing modes.
|
||||||
|
- Fixed visible signatures in new messages by creating an editable body entry before the signature instead of placing the signature at the top of an otherwise empty editor.
|
||||||
|
- Refined the visible-signature separator with more breathing room and a subtler solid rule so it reads as a boundary without dominating the composer.
|
||||||
|
- Strips the editor-only body-entry marker from outgoing HTML while retaining the visible-signature marker used for de-duplication.
|
||||||
|
- No schema migration is required; V051 remains the next migration number.
|
||||||
|
- Bumped the application/source release to 0.2.15.
|
||||||
|
|
||||||
### 0.2.14
|
### 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.
|
- 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.
|
||||||
|
|||||||
Generated
+1
-1
@@ -3506,7 +3506,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.14"
|
version = "0.2.15"
|
||||||
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.14"
|
version = "0.2.15"
|
||||||
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.14 is derived from the Oxi webmail project:
|
RustBox 0.2.15 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.14
|
# RustBox 0.2.15
|
||||||
|
|
||||||
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.14
|
## Storage and search in 0.2.15
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -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.14 release"
|
git commit -m "RustBox 0.2.15 release"
|
||||||
./scripts/setup-upstream.sh
|
./scripts/setup-upstream.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
RustBox 0.2.14 release manifest
|
RustBox 0.2.15 release manifest
|
||||||
|
|
||||||
0.2.14 feature/configuration changes:
|
0.2.15 release changes and retained feature set:
|
||||||
|
- fixes HTML signature formatting so toolbar actions preserve the selected text and reliably toggle formatting
|
||||||
|
- gives new messages with visible signatures a real editable body entry above the signature and softens/spaces the composer-only separator
|
||||||
|
- strips the editor-only body-entry marker from serialized outgoing HTML while retaining signature de-duplication metadata
|
||||||
- defaults wide message panes to compact single-line rows, with an Appearance setting to disable the behavior
|
- 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 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
|
||||||
@@ -67,14 +70,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.14 configuration
|
- README.md documents 0.2.15 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.14
|
- Cargo.toml version = 0.2.15
|
||||||
- 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,9 @@
|
|||||||
RustBox 0.2.14 - final source validation
|
RustBox 0.2.15 - final source validation
|
||||||
|
|
||||||
0.2.14 scope:
|
0.2.15 scope:
|
||||||
|
- signature HTML formatting preserves selected text across toolbar clicks, including formatting removal, while collapsed-caret clicks cannot enable sticky bold/italic/underline typing modes
|
||||||
|
- the HTML signature editor is no longer wrapped in a label, preventing whitespace/outside clicks from implicitly activating the Bold toolbar button
|
||||||
|
- new messages with visible signatures provide an editable body area above a more subtle, better-spaced separator
|
||||||
- wide message panes use compact one-line rows by default, with a persisted Mailbox preference to disable them
|
- 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
|
- 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
|
||||||
@@ -25,15 +28,15 @@ RustBox 0.2.14 - final source validation
|
|||||||
- 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 V050 adds compact-wide-row and signature insertion preferences; V051 remains next
|
- runtime schema migration V050 adds compact-wide-row and signature insertion preferences; V051 remains next
|
||||||
- version metadata updated to 0.2.14
|
- version metadata updated to 0.2.15
|
||||||
|
|
||||||
Completed in this environment:
|
Completed in this environment:
|
||||||
- offline release validator: scripts/validate-release-static.py (409/409 checks passed)
|
- offline release validator: scripts/validate-release-static.py (416/416 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.14
|
- release metadata consistently identifies 0.2.15
|
||||||
- V048/V049/V050 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.2.14
|
# RustBox 0.2.15
|
||||||
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.14 - implementation status
|
# RustBox 0.2.15 - 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.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.
|
This file tracks the `0.2.15` 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.14 browser mail UX
|
## 0.2.15 browser mail UX
|
||||||
|
|
||||||
|
- Signature HTML formatting now preserves the selected text when toolbar controls are used, so bold/italic/underline and remove-format can be toggled reliably.
|
||||||
|
- New messages with a visible signature always get an editable body line before the signature; the composer separator is spaced farther from the body and rendered as a subtle solid rule.
|
||||||
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
+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.14
|
## Updating an existing checkout to 0.2.15
|
||||||
|
|
||||||
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.14 storage/search operations
|
# RustBox 0.2.15 storage/search operations
|
||||||
|
|
||||||
RustBox 0.2.14 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed.
|
RustBox 0.2.15 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.14 keeps SQL, blob storage and search as independent concerns:
|
RustBox 0.2.15 keeps SQL, blob storage and search as independent concerns:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
database_backend = sqlite | mysql | pgsql
|
database_backend = sqlite | mysql | pgsql
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.2.14 - packaged production configuration (DEB/RPM)
|
# RustBox 0.2.15 - 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.14.
|
"""Static/offline release checks for RustBox 0.2.15.
|
||||||
|
|
||||||
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.14", "Cargo package version is 0.2.14")
|
require(cargo["package"]["version"] == "0.2.15", "Cargo package version is 0.2.15")
|
||||||
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.14", "Cargo.lock RustBox package version is 0.2.14")
|
require(lock_rustbox.get("version") == "0.2.15", "Cargo.lock RustBox package version is 0.2.15")
|
||||||
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.14"), "README identifies RustBox 0.2.14")
|
require(read("README.md").startswith("# RustBox 0.2.15"), "README identifies RustBox 0.2.15")
|
||||||
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.14"), "release manifest identifies RustBox 0.2.14")
|
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.15"), "release manifest identifies RustBox 0.2.15")
|
||||||
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.14"), "release validation identifies RustBox 0.2.14")
|
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.15"), "release validation identifies RustBox 0.2.15")
|
||||||
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.14"), "implementation status identifies RustBox 0.2.14")
|
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.15"), "implementation status identifies RustBox 0.2.15")
|
||||||
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,12 @@ 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('rememberSignatureRange' in bindings_js and 'selectedSignatureRange' in bindings_js and 'if (!range || range.collapsed) return null;' in bindings_js and 'state.identitySignatureRange = null;' in bindings_js and '.signature-editor-toolbar button' in bindings_js and 'event.preventDefault();' in bindings_js and 'restoreEditorRange(range, signatureEditor);' in bindings_js, "signature formatting preserves real selections and refuses collapsed-caret commands that would enable sticky typing styles")
|
||||||
|
index_html = read("src/web/index.html")
|
||||||
|
require('<div class="identity-signature-field"><span id="identity-signature-label">HTML signature / footer</span>' in index_html and 'aria-labelledby="identity-signature-label"' in index_html and '<label class="identity-signature-field">' not in index_html, "HTML signature editor is not wrapped in a label that can implicitly activate the Bold toolbar button")
|
||||||
|
require('function ensureComposeEntryBeforeSignature' in read("src/web/js/72-openpgp-identities.js") and 'data-rustbox-signature-entry' in read("src/web/js/72-openpgp-identities.js") and 'compose-body-entry' in compose_css, "new visible signatures keep an editable compose body entry above the signature")
|
||||||
|
require('.compose-signature.compose-signature-new' in compose_css and 'margin-top: 2.4em;' in compose_css and 'border-top: 1px solid color-mix(in srgb, var(--line) 58%, transparent);' in compose_css and 'removeAttribute("data-rustbox-signature-entry")' in compose_js, "new-message signature spacing is scoped, the separator is subdued, and editor-only scaffolding is stripped from outgoing HTML")
|
||||||
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")
|
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")
|
display_prefs_rs = read("src/db/display_preferences.rs")
|
||||||
index_html = read("src/web/index.html")
|
index_html = read("src/web/index.html")
|
||||||
|
|||||||
+9
-14
@@ -96,7 +96,9 @@ fn body_already_has_signature(
|
|||||||
}) {
|
}) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let signature = identity.signature_text.trim_matches(|ch| matches!(ch, '\r' | '\n'));
|
let signature = identity
|
||||||
|
.signature_text
|
||||||
|
.trim_matches(|ch| matches!(ch, '\r' | '\n'));
|
||||||
if signature.trim().is_empty() {
|
if signature.trim().is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -138,9 +140,8 @@ fn signature_html_to_text(html: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn materialize_signature_images(html: &str, attachments: &mut Vec<AttachmentData>) -> String {
|
fn materialize_signature_images(html: &str, attachments: &mut Vec<AttachmentData>) -> String {
|
||||||
let data_image = Regex::new(
|
let data_image =
|
||||||
r"(?is)data:(image/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\r\n]+)",
|
Regex::new(r"(?is)data:(image/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\r\n]+)")
|
||||||
)
|
|
||||||
.expect("valid signature data-image regex");
|
.expect("valid signature data-image regex");
|
||||||
let mut image_index = 0usize;
|
let mut image_index = 0usize;
|
||||||
data_image
|
data_image
|
||||||
@@ -416,7 +417,8 @@ pub async fn send_message_handler(
|
|||||||
identity,
|
identity,
|
||||||
req.compose_action.as_deref(),
|
req.compose_action.as_deref(),
|
||||||
req.in_reply_to.as_deref(),
|
req.in_reply_to.as_deref(),
|
||||||
) && !body_already_has_signature(
|
)
|
||||||
|
&& !body_already_has_signature(
|
||||||
identity,
|
identity,
|
||||||
req.text_body.as_deref(),
|
req.text_body.as_deref(),
|
||||||
req.html_body.as_deref(),
|
req.html_body.as_deref(),
|
||||||
@@ -498,15 +500,8 @@ pub async fn send_message_handler(
|
|||||||
|
|
||||||
let mut text_body = req.text_body.unwrap_or_default();
|
let mut text_body = req.text_body.unwrap_or_default();
|
||||||
let mut html_body = req.html_body;
|
let mut html_body = req.html_body;
|
||||||
if append_signature
|
if append_signature && let Some(identity) = selected_identity.as_ref() {
|
||||||
&& let Some(identity) = selected_identity.as_ref()
|
append_identity_signature(identity, &mut text_body, &mut html_body, &mut attachments);
|
||||||
{
|
|
||||||
append_identity_signature(
|
|
||||||
identity,
|
|
||||||
&mut text_body,
|
|
||||||
&mut html_body,
|
|
||||||
&mut attachments,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if pgp_inline && pgp_mode != PgpMode::None && !attachments.is_empty() {
|
if pgp_inline && pgp_mode != PgpMode::None && !attachments.is_empty() {
|
||||||
|
|||||||
@@ -906,10 +906,18 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compose-body-entry {
|
||||||
|
min-height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
.compose-signature {
|
.compose-signature {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding-top: 8px;
|
padding-top: 9px;
|
||||||
border-top: 1px dashed var(--line-strong);
|
border-top: 1px solid color-mix(in srgb, var(--line) 58%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-signature.compose-signature-new {
|
||||||
|
margin-top: 2.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-signature img {
|
.compose-signature img {
|
||||||
|
|||||||
@@ -555,7 +555,8 @@
|
|||||||
min-width: 0
|
min-width: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
.identity-signature-grid>label {
|
.identity-signature-grid>label,
|
||||||
|
.identity-signature-grid>.identity-signature-field {
|
||||||
min-width: 0
|
min-width: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1246,7 +1246,7 @@
|
|||||||
<div class="identity-signature-grid">
|
<div class="identity-signature-grid">
|
||||||
<label><span>Plain-text signature / footer</span><textarea id="identity-signature-text" rows="8"
|
<label><span>Plain-text signature / footer</span><textarea id="identity-signature-text" rows="8"
|
||||||
placeholder="Text-only signature used in plain-text messages"></textarea></label>
|
placeholder="Text-only signature used in plain-text messages"></textarea></label>
|
||||||
<label class="identity-signature-field"><span>HTML signature / footer</span>
|
<div class="identity-signature-field"><span id="identity-signature-label">HTML signature / footer</span>
|
||||||
<div class="signature-editor-shell">
|
<div class="signature-editor-shell">
|
||||||
<div class="signature-editor-toolbar" aria-label="Signature formatting"><button type="button"
|
<div class="signature-editor-toolbar" aria-label="Signature formatting"><button type="button"
|
||||||
data-signature-command="bold" title="Bold"><svg>
|
data-signature-command="bold" title="Bold"><svg>
|
||||||
@@ -1264,11 +1264,11 @@
|
|||||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-remove-format"></use>
|
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-remove-format"></use>
|
||||||
</svg></button></div>
|
</svg></button></div>
|
||||||
<div id="identity-signature" class="signature-editor" contenteditable="true" role="textbox"
|
<div id="identity-signature" class="signature-editor" contenteditable="true" role="textbox"
|
||||||
aria-multiline="true"></div>
|
aria-multiline="true" aria-labelledby="identity-signature-label"></div>
|
||||||
</div><input id="identity-signature-image" type="file" accept="image/png,image/jpeg,image/gif,image/webp"
|
</div><input id="identity-signature-image" type="file" accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
hidden><small class="muted signature-help">You can also paste an image directly into this
|
hidden><small class="muted signature-help">You can also paste an image directly into this
|
||||||
editor.</small>
|
editor.</small>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="identity-options-grid">
|
<div class="identity-options-grid">
|
||||||
<label><span>Signature position</span><select id="identity-signature-position">
|
<label><span>Signature position</span><select id="identity-signature-position">
|
||||||
|
|||||||
@@ -484,6 +484,11 @@ function serializeComposeHtml(editor = $("#compose-editor")) {
|
|||||||
if (mode !== "html") return null;
|
if (mode !== "html") return null;
|
||||||
const clone = editor.cloneNode(true);
|
const clone = editor.cloneNode(true);
|
||||||
clone.querySelectorAll(".rustbox-compose-font").forEach((wrapper) => wrapper.replaceWith(...wrapper.childNodes));
|
clone.querySelectorAll(".rustbox-compose-font").forEach((wrapper) => wrapper.replaceWith(...wrapper.childNodes));
|
||||||
|
clone.querySelectorAll('[data-rustbox-signature-entry="true"]').forEach((entry) => {
|
||||||
|
entry.classList.remove("compose-body-entry");
|
||||||
|
entry.removeAttribute("data-rustbox-signature-entry");
|
||||||
|
if (!entry.className) entry.removeAttribute("class");
|
||||||
|
});
|
||||||
clone.querySelectorAll("img[data-rustbox-cid]").forEach((image) => {
|
clone.querySelectorAll("img[data-rustbox-cid]").forEach((image) => {
|
||||||
const cid = image.dataset.rustboxCid || "";
|
const cid = image.dataset.rustboxCid || "";
|
||||||
if (cid) image.setAttribute("src", `cid:${cid}`);
|
if (cid) image.setAttribute("src", `cid:${cid}`);
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ function renderIdentities() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editIdentity(identity = null) {
|
function editIdentity(identity = null) {
|
||||||
|
state.identitySignatureRange = null;
|
||||||
$("#identity-id").value = identity?.id || "";
|
$("#identity-id").value = identity?.id || "";
|
||||||
$("#identity-name").value = identity?.display_name || "";
|
$("#identity-name").value = identity?.display_name || "";
|
||||||
$("#identity-email").value = identity?.email || "";
|
$("#identity-email").value = identity?.email || "";
|
||||||
@@ -534,17 +535,60 @@ function composeHasVisibleSignature(editor, identity) {
|
|||||||
return Boolean(suffix && (editor.innerText || "").replace(/[\r\n]+$/g, "").endsWith(suffix));
|
return Boolean(suffix && (editor.innerText || "").replace(/[\r\n]+$/g, "").endsWith(suffix));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function composeEntryHasMeaningfulContent(entry) {
|
||||||
|
if (!entry) return false;
|
||||||
|
if ((entry.textContent || "").trim()) return true;
|
||||||
|
return Boolean(entry.querySelector("img,video,a[href],table,ul,ol,blockquote"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureComposeEntryBeforeSignature(editor, signature, action) {
|
||||||
|
if (!editor || !signature || action !== "new") return null;
|
||||||
|
let entry = editor.querySelector('[data-rustbox-signature-entry="true"]');
|
||||||
|
if (entry) return entry;
|
||||||
|
|
||||||
|
let node = signature.previousSibling;
|
||||||
|
while (node && node.nodeType === Node.TEXT_NODE && !(node.textContent || "").trim()) node = node.previousSibling;
|
||||||
|
if (node) return null;
|
||||||
|
|
||||||
|
entry = el("div", "compose-body-entry");
|
||||||
|
entry.dataset.rustboxSignatureEntry = "true";
|
||||||
|
entry.innerHTML = "<br>";
|
||||||
|
editor.insertBefore(entry, signature);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupComposeSignatureEntry(editor) {
|
||||||
|
const entry = editor?.querySelector('[data-rustbox-signature-entry="true"]');
|
||||||
|
if (!entry) return;
|
||||||
|
if (!composeEntryHasMeaningfulContent(entry)) {
|
||||||
|
entry.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.classList.remove("compose-body-entry");
|
||||||
|
entry.removeAttribute("data-rustbox-signature-entry");
|
||||||
|
if (!entry.className) entry.removeAttribute("class");
|
||||||
|
}
|
||||||
|
|
||||||
function insertIdentitySignature(editor = $("#compose-editor"), action = state.composeAction || "new", options = {}) {
|
function insertIdentitySignature(editor = $("#compose-editor"), action = state.composeAction || "new", options = {}) {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const identity = selectedIdentity();
|
const identity = selectedIdentity();
|
||||||
const visible = identityUsesVisibleSignature(identity) && signatureEnabledForCompose(identity, action);
|
const visible = identityUsesVisibleSignature(identity) && signatureEnabledForCompose(identity, action);
|
||||||
if (options.preserveExisting && visible && composeHasVisibleSignature(editor, identity)) return;
|
if (options.preserveExisting && visible && composeHasVisibleSignature(editor, identity)) {
|
||||||
|
const existing = editor.querySelector('.compose-signature, [data-rustbox-signature="true"]');
|
||||||
|
existing?.classList.toggle("compose-signature-new", action === "new");
|
||||||
|
ensureComposeEntryBeforeSignature(editor, existing, action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
removeVisibleComposeSignature(editor);
|
removeVisibleComposeSignature(editor);
|
||||||
if (!visible) return;
|
if (!visible) {
|
||||||
|
cleanupComposeSignatureEntry(editor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const format = $("#compose-mode")?.value || "html";
|
const format = $("#compose-mode")?.value || "html";
|
||||||
const signature = el("div", "compose-signature rustbox-signature");
|
const signature = el("div", "compose-signature rustbox-signature");
|
||||||
|
signature.classList.toggle("compose-signature-new", action === "new");
|
||||||
signature.dataset.rustboxSignature = "true";
|
signature.dataset.rustboxSignature = "true";
|
||||||
if (format === "html") {
|
if (format === "html") {
|
||||||
const html = String(identity.signature_html || "").trim();
|
const html = String(identity.signature_html || "").trim();
|
||||||
@@ -553,16 +597,23 @@ function insertIdentitySignature(editor = $("#compose-editor"), action = state.c
|
|||||||
else if (text) {
|
else if (text) {
|
||||||
signature.textContent = text;
|
signature.textContent = text;
|
||||||
signature.style.whiteSpace = "pre-wrap";
|
signature.style.whiteSpace = "pre-wrap";
|
||||||
} else return;
|
} else {
|
||||||
|
cleanupComposeSignatureEntry(editor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const text = String(identity.signature_text || "").replace(/[\r\n]+$/g, "");
|
const text = String(identity.signature_text || "").replace(/[\r\n]+$/g, "");
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) {
|
||||||
|
cleanupComposeSignatureEntry(editor);
|
||||||
|
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);
|
||||||
|
ensureComposeEntryBeforeSignature(editor, signature, action);
|
||||||
|
|
||||||
const generation = state.composeSignatureGeneration;
|
const generation = state.composeSignatureGeneration;
|
||||||
if (format === "html") void materializeVisibleSignatureImages(signature, generation);
|
if (format === "html") void materializeVisibleSignatureImages(signature, generation);
|
||||||
|
|||||||
@@ -458,21 +458,54 @@ function bindEvents() {
|
|||||||
$("#identity-form").addEventListener("submit", saveIdentity);
|
$("#identity-form").addEventListener("submit", saveIdentity);
|
||||||
$("#identity-new").addEventListener("click", () => editIdentity(null));
|
$("#identity-new").addEventListener("click", () => editIdentity(null));
|
||||||
$("#identity-delete").addEventListener("click", deleteIdentity);
|
$("#identity-delete").addEventListener("click", deleteIdentity);
|
||||||
|
const signatureEditor = $("#identity-signature");
|
||||||
|
const rememberSignatureRange = () => {
|
||||||
|
const range = captureEditorRange(signatureEditor);
|
||||||
|
if (range) state.identitySignatureRange = range;
|
||||||
|
return range;
|
||||||
|
};
|
||||||
|
const selectedSignatureRange = () => {
|
||||||
|
const range = state.identitySignatureRange || captureEditorRange(signatureEditor);
|
||||||
|
if (!range || range.collapsed) return null;
|
||||||
|
return range;
|
||||||
|
};
|
||||||
|
["keyup", "mouseup", "input"].forEach((eventName) => signatureEditor.addEventListener(eventName, rememberSignatureRange));
|
||||||
|
signatureEditor.addEventListener("pointerdown", () => {
|
||||||
|
// A plain click starts a new caret/selection. Never let a stale formatted
|
||||||
|
// selection become the target of the next toolbar action.
|
||||||
|
state.identitySignatureRange = null;
|
||||||
|
});
|
||||||
|
$$(".signature-editor-toolbar button").forEach((button) => button.addEventListener("mousedown", (event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
rememberSignatureRange();
|
||||||
|
event.preventDefault();
|
||||||
|
}));
|
||||||
$$("[data-signature-command]").forEach((button) => button.addEventListener("click", () => {
|
$$("[data-signature-command]").forEach((button) => button.addEventListener("click", () => {
|
||||||
const editor = $("#identity-signature");
|
const range = selectedSignatureRange();
|
||||||
editor.focus();
|
if (!range) {
|
||||||
|
// execCommand on a collapsed caret enables a sticky typing style (for
|
||||||
|
// example every subsequently typed character becomes bold). Signature
|
||||||
|
// formatting is intentionally selection-only.
|
||||||
|
signatureEditor.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreEditorRange(range, signatureEditor);
|
||||||
document.execCommand(button.dataset.signatureCommand, false, null);
|
document.execCommand(button.dataset.signatureCommand, false, null);
|
||||||
|
rememberSignatureRange();
|
||||||
}));
|
}));
|
||||||
$("#identity-signature-link").addEventListener("click", async () => {
|
$("#identity-signature-link").addEventListener("click", async () => {
|
||||||
const editor = $("#identity-signature");
|
const range = selectedSignatureRange();
|
||||||
const range = captureEditorRange(editor);
|
if (!range) {
|
||||||
|
signatureEditor.focus();
|
||||||
|
return toast("Select signature text before inserting a link", "warning");
|
||||||
|
}
|
||||||
const url = await appPrompt("Insert signature link", { placeholder: "https://example.com", type: "url", confirmLabel: "Insert" });
|
const url = await appPrompt("Insert signature link", { placeholder: "https://example.com", type: "url", confirmLabel: "Insert" });
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
editor.focus();
|
restoreEditorRange(range, signatureEditor);
|
||||||
restoreEditorRange(range, editor);
|
|
||||||
document.execCommand("createLink", false, url);
|
document.execCommand("createLink", false, url);
|
||||||
|
rememberSignatureRange();
|
||||||
});
|
});
|
||||||
$("#identity-signature-image-btn").addEventListener("click", () => { state.identitySignatureRange = captureEditorRange($("#identity-signature")); $("#identity-signature-image").click(); });
|
$("#identity-signature-image-btn").addEventListener("click", () => { rememberSignatureRange(); $("#identity-signature-image").click(); });
|
||||||
$("#identity-signature-image").addEventListener("change", async (event) => { const file = event.target.files?.[0]; await insertIdentitySignatureImage(file); event.target.value = ""; });
|
$("#identity-signature-image").addEventListener("change", async (event) => { const file = event.target.files?.[0]; await insertIdentitySignatureImage(file); event.target.value = ""; });
|
||||||
$("#identity-signature").addEventListener("paste", async (event) => {
|
$("#identity-signature").addEventListener("paste", async (event) => {
|
||||||
const file = [...(event.clipboardData?.items || [])].find((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type))?.getAsFile();
|
const file = [...(event.clipboardData?.items || [])].find((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type))?.getAsFile();
|
||||||
|
|||||||
Reference in new issue
Block a user