v0.2.15
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-02 16:22:13 +01:00
1 parent edda5fc9e4
commit fb9eab87eb
21 files changed
+179 -62

No files matched your search

+10
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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`
+3 -3
View File
@@ -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
``` ```
+7 -4
View File
@@ -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
+8 -5
View File
@@ -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
View File
@@ -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"
+5 -3
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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.
+13 -7
View File
@@ -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
View File
@@ -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() {
+10 -2
View File
@@ -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 {
+2 -1
View File
@@ -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
View File
@@ -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">
+5
View File
@@ -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}`);
+55 -4
View File
@@ -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);
+40 -7
View File
@@ -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();