From 1309358b686706f5ae0e730a4ab64d6ed7b88ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 3 Nov 2026 12:11:13 +0100 Subject: [PATCH] v0.2.18 --- CHANGELOG.md | 10 ++- Cargo.lock | 2 +- Cargo.toml | 2 +- NOTICE.md | 2 +- README.md | 2 +- RELEASE-MANIFEST.txt | 8 +-- RELEASE-VALIDATION.txt | 8 +-- docs/IMPLEMENTATION_STATUS.md | 6 +- scripts/validate-release-static.py | 6 ++ src/web/css/30-mail.css | 2 +- src/web/js/00-bootstrap.js | 13 +++- src/web/js/40-messages-reader.js | 93 +++++++++++++++++++++++----- src/web/js/50-compose.js | 4 ++ src/web/js/60-mail-actions-search.js | 54 +++++++++++++--- src/web/js/72-openpgp-identities.js | 35 +++++++++-- src/web/js/80-settings-realtime.js | 28 +++++++++ src/web/js/95-bindings-boot.js | 14 +++++ 17 files changed, 239 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb9e19..c2d466f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,13 @@ # RustBox changelog -### 0.2.17 +### 0.2.18 +- Fixed new-message sender selection so the composer prefers the configured default identity, then the identity matching the active account address, then the first available identity; the visible From field now shows the active account address instead of an ambiguous `Account default` label when no identity is selected. +- Restored visible signatures/footers for new messages when no identity was explicitly marked default by using the same preferred-identity fallback for signature insertion and sending. +- Added explicit composer undo/redo keyboard handling: Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z and Ctrl+Y. +- Improved adaptive HTML-mail rendering for legacy/marketing templates: preserve safe CSS from ``, retain body presentation metadata, adapt HSL/HSLA and background shorthands with URLs, enforce readable text contrast and remove the hard-coded white iframe canvas. +- Suppress realtime new-mail notifications caused by intentional message moves (including Spam/Junk) by tracking the destination UID while retaining normal notifications for unrelated incoming mail. +- Fixed visible-signature image materialization so cleanup waits for the in-flight CID upload instead of deleting the signature image while the composer is opening. - Added a top-bar quick account switcher that appears when more than one account is signed in to the current browser session. - Added browser-level combined notification selection in Account settings; selected accounts keep their own notification folder, sound and DND preferences. - Added administrator feature gate `multi_account_enabled`; disabling it hides multi-account controls, rejects adding a second account and limits realtime WebSocket monitoring to the active account. @@ -13,7 +19,7 @@ - Hide HTML inline/CID resources, including images inserted through signature management, from the reader attachment list while continuing to resolve them inside the message body. - Treat MIME parts explicitly marked `Content-Disposition: inline` as inline content rather than message attachments, preventing signature-only messages from gaining an attachment indicator after the next IMAP metadata sync. - Retain signature images as `multipart/related` CID parts for outgoing mail; regular file attachments remain unchanged. -- Kept the application/source release at 0.2.17. +- Kept the application/source release at 0.2.18. ### 0.2.16 diff --git a/Cargo.lock b/Cargo.lock index 7cb5d3c..5d09f0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3506,7 +3506,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.17" +version = "0.2.18" dependencies = [ "aes-gcm", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index 170f6d3..de3bb0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.2.17" +version = "0.2.18" edition = "2024" rust-version = "1.99.1" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index bf522d8..bec2f27 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # RustBox attribution / notice -RustBox 0.2.17 is derived from the Oxi webmail project: +RustBox 0.2.18 is derived from the Oxi webmail project: - Upstream: `c0h1b4/oxi` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index bdbd280..d11596d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.2.17 +# RustBox 0.2.18 RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`. diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 7414a89..dc2d869 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,6 +1,6 @@ -RustBox 0.2.17 release manifest +RustBox 0.2.18 release manifest -0.2.17 release changes and retained feature set: +0.2.18 release changes and retained feature set: - adds equal-height text/HTML signature fields and a dedicated raw-HTML + visual signature editor with image insertion - keeps synchronization activity visible during page/progressive/all mailbox loads and individual message retrieval - adds Shift-click contiguous message selection and direct first/last pagination controls @@ -70,14 +70,14 @@ Docker examples: Operations/docs: - standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets -- README.md documents 0.2.17 configuration +- README.md documents 0.2.18 configuration - docker/README.md documents Compose variants - docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior - TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending Build/release metadata: - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts -- Cargo.toml version = 0.2.17 +- Cargo.toml version = 0.2.18 - rust-toolchain.toml pins Rust 1.99.1 - Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing - CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 2f9a035..3f5e5bc 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,6 +1,6 @@ -RustBox 0.2.17 - final source validation +RustBox 0.2.18 - final source validation -0.2.17 scope: +0.2.18 scope: - multi-account sessions expose a top-bar quick switcher, per-browser combined notification selection, per-account notification preferences and the administrator `multi_account_enabled` feature gate - plain-text and HTML signature controls have aligned heights; raw HTML can be pasted into a modal, previewed/edited visually and extended with inline images - synchronization spinner covers mailbox pagination, progressive/full-folder loading and message/draft body retrieval @@ -32,7 +32,7 @@ RustBox 0.2.17 - final source validation - 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 - V047-V050 are consolidated into compatibility baseline V050; V051 migrates existing tenants with adaptive HTML-mail colors and mailbox loading mode; V052 is next -- version metadata remains at 0.2.17 +- version metadata remains at 0.2.18 Completed in this environment: - offline release validator: scripts/validate-release-static.py (413/413 checks passed) @@ -40,7 +40,7 @@ Completed in this environment: - all JSON files parse successfully - Python helper scripts compile successfully - shell scripts pass `bash -n` -- release metadata consistently identifies 0.2.17 +- release metadata consistently identifies 0.2.18 - V050 baseline consolidation plus the V051 adaptive-colors/mailbox-loading migration are validated offline diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index afa0bec..a99c7e9 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,9 +1,9 @@ -# RustBox 0.2.17 - implementation status +# RustBox 0.2.18 - implementation status - Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification. -This file tracks the `0.2.17` 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.18` 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.17 browser mail UX +## 0.2.18 browser mail UX - Signature management uses equal-height text and HTML fields and includes a raw-HTML modal with a sanitized visual editor and inline image import. - Mail loading activity is represented by the app-bar spinner for pagination, progressive/full-folder retrieval and individual message/draft loading. diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index 01dbb54..facd8a7 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -149,9 +149,11 @@ require('id="account-notification-list"' in read("src/web/index.html") and 'noti require('realtimeFolderIsDrafts' in realtime_js and 'if (realtimeFolderIsDrafts(data?.folder)) return true;' in realtime_js, "realtime new-mail notifications are suppressed for Drafts folders") require('const draftsFolder = syncImap' in compose_js and 'suppressRealtimeNotificationsForFolder(draftsFolder)' in compose_js and 'releaseRealtimeSuppression();' in compose_js, "manual IMAP draft save suppresses its own realtime new-mail notification") require('suppressRealtimeNotificationsForFolder(notificationDestination)' in mail_actions_js, "bulk destination notifications are suppressed") +require('realtimeNotificationSuppressedMessages: new Map()' in bootstrap_js and 'suppressRealtimeNotificationForMessage' in realtime_js and 'consumeRealtimeMessageNotificationSuppression' in realtime_js and 'suppressIntentionalMoveNotification' in mail_actions_js, "intentional message moves suppress the matching realtime new-mail event by destination UID") require('finishAuthenticatedStartup({ showLoginToast = false } = {})' in auth_js and 'finishAuthenticatedStartup({ showLoginToast: true })' in auth_js and bindings_js.count('finishAuthenticatedStartup();') >= 1, "login success toast is limited to real login, not refresh/session restoration") require('${csp}' in reader_js, "message preview srcdoc explicitly declares UTF-8") require('function sanitizeMessagePreviewHtml(html)' in bootstrap_js and 'if (!node.hasAttribute("target")) node.setAttribute("target", "_blank");' in bootstrap_js and '!["_self", "_parent", "_top"].includes(target)' in bootstrap_js and 'rel.add("noopener");' in bootstrap_js and 'rel.add("noreferrer");' in bootstrap_js and 'sanitizeMessagePreviewHtml(detail.html)' in reader_js, "message preview links default to safe new-window navigation without overriding explicit targets") +require('sourceDoc.head.querySelectorAll("style")' in bootstrap_js and 'messagePreviewBodyPresentation' in reader_js and 'iframe.style.background = previewTheme.surface;' in reader_js and 'background: var(--reader-bg);' in mail_css, "adaptive HTML-mail preview preserves head CSS/body presentation and never exposes a hard-coded white iframe canvas") require('attachmentContentUrl(folder, uid, attachment)' in reader_js and 'frame.src = attachmentContentUrl(folder, uid, attachment);' in reader_js and 'URL.createObjectURL(blob)' in reader_js, "PDF preview uses authenticated inline URL while other attachment previews retain blob handling") require('function composeAttachmentInsertionRange' in compose_js and 'target.collapse(false);' in compose_js and 'range.insertNode(inserted);' in compose_js and 'document.execCommand("insertHTML"' not in compose_js, "compose attachment embedding uses non-destructive DOM Range insertion") require('position: fixed;' in compose_css and '.compose-image-actions' in compose_css and 'grid-template-rows: auto auto auto minmax(240px, 1fr) auto auto auto auto;' in compose_css, "inline image actions float without consuming a composer grid row") @@ -161,6 +163,10 @@ require('clipboardData' in bindings_js and 'uploadInlineImage(images, range)' in require('.compose-attachment-kind' in compose_css and 'Image attachment' in compose_js and 'Inline image' in compose_js, "composer distinguishes embedded images from regular attachments") require("function mergeComposeAttachments" in compose_js and "await refreshComposeAttachmentsFromServer(id);" in compose_js and "attachments: mergeComposeAttachments(local.attachments, server.attachments)" in compose_js, "composer preserves multiple attachments and draft recovery merges authoritative server attachment metadata") require("const attachments = mergeComposeAttachments(state.composeAttachments);" in compose_js and "Inline link" in compose_js and "detachComposeAttachmentFromBody" in compose_js, "composer attachment strip keeps inline and regular attachments visible together") +require('if (state.composeUploading) {' in compose_js and 'scheduleUnusedInlineAttachmentCleanup();' in compose_js and 'staleSignatureIds' in compose_js, "signature CID cleanup waits for in-flight uploads before deleting apparently unreferenced signature images") +require('function preferredComposeIdentity()' in read("src/web/js/72-openpgp-identities.js") and 'activeAccountEmail().toLowerCase()' in read("src/web/js/72-openpgp-identities.js") and 'if (preferred) select.value = String(preferred.id);' in read("src/web/js/72-openpgp-identities.js") and 'return state.identities.find((identity) => identity.id === id) || preferredComposeIdentity();' in read("src/web/js/72-openpgp-identities.js"), "composer resolves a concrete active/default identity before inserting signatures") +require('const hasAccountIdentity = Boolean(normalizedAccountEmail)' in read("src/web/js/72-openpgp-identities.js") and 'if (!hasAccountIdentity) {' in read("src/web/js/72-openpgp-identities.js"), "composer does not duplicate the active account address when a matching identity exists") +require('const command = key === "z" ? (event.shiftKey ? "redo" : "undo")' in bindings_js and 'document.execCommand(command, false, null);' in bindings_js, "composer supports Ctrl/Cmd+Z undo and redo shortcuts") require('.message-pagination {' in mail_css and 'grid-template-columns: 30px 30px auto 30px 30px;' in mail_css and '.pagination-arrow {' in mail_css and '.load-more {' in mail_css and 'width: 100%;' in mail_css.split('.load-more {', 1)[1].split('}', 1)[0], "message pagination uses compact first/previous/next/last controls while progressive-load controls span the message list width") require('id="compose-fields-toggle"' in read("src/web/index.html") and 'function setComposeFieldsCollapsed' in compose_js and 'function toggleComposeFields' in compose_js and '.compose-dialog.compose-fields-collapsed .compose-addresses' in compose_css and 'setComposeFieldsCollapsed(false)' in bindings_js, "composer addressing fields can collapse to maximize editor space and reopen on invalid recipient input") require('id="compose-save-draft"' in read("src/web/index.html") and '$("#compose-save-draft").addEventListener("click", saveComposeDraftToImap);' in bindings_js and 'saveCurrentDraft(false, true, true)' in compose_js, "composer exposes an explicit manual IMAP draft save before cancel") diff --git a/src/web/css/30-mail.css b/src/web/css/30-mail.css index e628846..3ad627b 100644 --- a/src/web/css/30-mail.css +++ b/src/web/css/30-mail.css @@ -595,7 +595,7 @@ width: 100%; min-height: 68vh; border: 0; - background: #fff; + background: var(--reader-bg); } .message-body pre { diff --git a/src/web/js/00-bootstrap.js b/src/web/js/00-bootstrap.js index 75090f9..31abb83 100644 --- a/src/web/js/00-bootstrap.js +++ b/src/web/js/00-bootstrap.js @@ -68,6 +68,7 @@ realtimeSeenEvents: new Map(), realtimeNotifiedMessages: new Map(), realtimeNotificationSuppressions: new Map(), + realtimeNotificationSuppressedMessages: new Map(), realtimeNotificationsReady: false, realtimeNotificationsArmRequested: false, realtimeNotificationsReadyTimer: null, @@ -166,7 +167,17 @@ } function sanitizeMessagePreviewHtml(html) { - const doc = new DOMParser().parseFromString(sanitizeMessageHtml(html), "text/html"); + const source = stripRustBoxUiLeak(html); + const sourceDoc = new DOMParser().parseFromString(source, "text/html"); + const headStyles = [...sourceDoc.head.querySelectorAll("style")] + .map((node) => String(node.textContent || "").replace(/@import\s+(?:url\()?[^;]+;?/gi, "")) + .filter((css) => css.trim()); + const doc = new DOMParser().parseFromString(sanitizeMessageHtml(source), "text/html"); + for (const css of headStyles.reverse()) { + const style = doc.createElement("style"); + style.textContent = css; + doc.body.prepend(style); + } doc.querySelectorAll("a[href]").forEach((node) => { if (!node.hasAttribute("target")) node.setAttribute("target", "_blank"); const target = String(node.getAttribute("target") || "").toLowerCase(); diff --git a/src/web/js/40-messages-reader.js b/src/web/js/40-messages-reader.js index 354bbd9..ac9d2fc 100644 --- a/src/web/js/40-messages-reader.js +++ b/src/web/js/40-messages-reader.js @@ -862,7 +862,10 @@ function parseAdaptiveEmailColor(value) { silver: [192, 192, 192], navy: [0, 0, 128], red: [255, 0, 0], green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0], maroon: [128, 0, 0], purple: [128, 0, 128], teal: [0, 128, 128], olive: [128, 128, 0], aqua: [0, 255, 255], fuchsia: [255, 0, 255], - lime: [0, 255, 0], whitesmoke: [245, 245, 245], gainsboro: [220, 220, 220], + lime: [0, 255, 0], darkgray: [169, 169, 169], darkgrey: [169, 169, 169], + lightgray: [211, 211, 211], lightgrey: [211, 211, 211], whitesmoke: [245, 245, 245], + ghostwhite: [248, 248, 255], floralwhite: [255, 250, 240], linen: [250, 240, 230], + cornsilk: [255, 248, 220], gainsboro: [220, 220, 220], }; if (named[raw]) return { r: named[raw][0], g: named[raw][1], b: named[raw][2], a: 1 }; let match = raw.match(/^#([0-9a-f]{3,8})$/i); @@ -877,10 +880,28 @@ function parseAdaptiveEmailColor(value) { } } match = raw.match(/^rgba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*[, ]\s*([0-9.]+)(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i); + if (match) { + const alpha = match[4]?.endsWith("%") ? Number(match[4].slice(0, -1)) / 100 : Number(match[4] ?? 1); + const clamp = (value) => Math.max(0, Math.min(255, Math.round(Number(value)))); + return { r: clamp(match[1]), g: clamp(match[2]), b: clamp(match[3]), a: Math.max(0, Math.min(1, alpha)) }; + } + match = raw.match(/^hsla?\(\s*(-?[0-9.]+)(?:deg)?\s*[, ]\s*([0-9.]+)%\s*[, ]\s*([0-9.]+)%(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i); if (!match) return null; + const hue = ((Number(match[1]) % 360) + 360) % 360 / 360; + const saturation = Math.max(0, Math.min(1, Number(match[2]) / 100)); + const lightness = Math.max(0, Math.min(1, Number(match[3]) / 100)); const alpha = match[4]?.endsWith("%") ? Number(match[4].slice(0, -1)) / 100 : Number(match[4] ?? 1); - const clamp = (value) => Math.max(0, Math.min(255, Math.round(Number(value)))); - return { r: clamp(match[1]), g: clamp(match[2]), b: clamp(match[3]), a: Math.max(0, Math.min(1, alpha)) }; + const channel = (offset) => { + const k = (offset + hue * 12) % 12; + const amount = saturation * Math.min(lightness, 1 - lightness); + return lightness - amount * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + }; + return { + r: Math.round(channel(0) * 255), + g: Math.round(channel(8) * 255), + b: Math.round(channel(4) * 255), + a: Math.max(0, Math.min(1, alpha)), + }; } function adaptiveEmailRgbCss(color) { @@ -928,7 +949,7 @@ function adaptiveEmailContrast(a, b) { } function adaptiveEmailReadableColor(color, background, preferred) { - if (!color || !background || adaptiveEmailContrast(color, background) >= 3.5) return color; + if (!color || !background || adaptiveEmailContrast(color, background) >= 4.5) return color; for (let step = 0.18; step <= 1.001; step += 0.14) { const candidate = adaptiveEmailMix(color, preferred, step); if (adaptiveEmailContrast(candidate, background) >= 4.5) return candidate; @@ -969,38 +990,68 @@ function adaptiveEmailThemePalette(body, targetTheme) { function adaptiveEmailMapCssDeclarations(css, mapBackground, mapText, mapBorder) { if (!css) return css; - const colorToken = /#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|\b(?:black|white|gray|grey|silver|navy|red|green|blue|yellow|maroon|purple|teal|olive|aqua|fuchsia|lime|whitesmoke|gainsboro)\b/gi; + const colorToken = /#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|\b(?:black|white|gray|grey|silver|navy|red|green|blue|yellow|maroon|purple|teal|olive|aqua|fuchsia|lime|darkgray|darkgrey|lightgray|lightgrey|whitesmoke|ghostwhite|floralwhite|linen|cornsilk|gainsboro)\b/gi; return css.replace(/(^|[;{])\s*([a-z-]+)\s*:\s*([^;{}]+)/gi, (whole, prefix, property, value) => { const prop = String(property || "").toLowerCase(); let mapper = null; if (prop === "color") mapper = mapText; - else if (prop === "background-color" || (prop === "background" && !/url\s*\(/i.test(value))) mapper = mapBackground; + else if (prop === "background-color" || prop === "background") mapper = mapBackground; else if (/^border(?:-(?:top|right|bottom|left))?(?:-color)?$/.test(prop)) mapper = mapBorder; if (!mapper) return whole; - const next = String(value).replace(colorToken, (token) => { + const protectedParts = []; + const masked = String(value).replace(/(?:url|var)\([^)]*\)/gi, (part) => { + const token = `__RUSTBOX_CSS_PROTECTED_${protectedParts.length}__`; + protectedParts.push(part); + return token; + }); + let next = masked.replace(colorToken, (token) => { const parsed = parseAdaptiveEmailColor(token); const mapped = parsed ? mapper(parsed) : null; return mapped ? adaptiveEmailRgbCss(mapped) : token; }); + protectedParts.forEach((part, index) => { next = next.replace(`__RUSTBOX_CSS_PROTECTED_${index}__`, part); }); return `${prefix}${property}:${next}`; }); } +function messagePreviewBodyPresentation(html) { + const doc = new DOMParser().parseFromString(String(html || ""), "text/html"); + const sourceBody = doc.body; + if (!sourceBody) return { className: "", id: "", dir: "", lang: "", style: "" }; + const css = []; + const style = sourceBody.style; + for (const [property, value] of [ + ["font-family", style.fontFamily], + ["font-size", style.fontSize], + ["font-weight", style.fontWeight], + ["line-height", style.lineHeight], + ["text-align", style.textAlign], + ["direction", style.direction], + ]) { + if (value) css.push(`${property}:${value}`); + } + return { + className: String(sourceBody.getAttribute("class") || "").trim(), + id: String(sourceBody.getAttribute("id") || "").trim(), + dir: String(sourceBody.getAttribute("dir") || "").trim(), + lang: String(sourceBody.getAttribute("lang") || "").trim(), + style: css.join(";"), + }; +} + function adaptMessagePreviewColors(html, detail, body) { - const targetTheme = resolveTheme(state.prefs.theme); + const targetTheme = ["dark", "light"].includes(root.dataset.theme) ? root.dataset.theme : resolveTheme(state.prefs.theme); const targetDark = targetTheme === "dark"; const palette = adaptiveEmailThemePalette(body, targetTheme); const surface = palette.surface; const text = palette.text; - const mailTheme = String(detail?.email_theme || "").toLowerCase(); - const themeAlreadyMatches = mailTheme === targetTheme; const doc = new DOMParser().parseFromString(`${html}`, "text/html"); const remapBackground = (color) => { if (!color || color.a < 0.45) return null; const brightness = adaptiveEmailBrightness(color); const saturation = adaptiveEmailSaturation(color); - if (saturation > 0.38) return null; + if (saturation > 0.46) return null; if (targetDark && brightness >= 0.54) { const depth = Math.max(0, Math.min(0.14, (1 - brightness) * 0.30)); return adaptiveEmailMix(surface, text, depth); @@ -1016,14 +1067,14 @@ function adaptMessagePreviewColors(html, detail, body) { if (!color || color.a < 0.45) return null; const brightness = adaptiveEmailBrightness(color); const saturation = adaptiveEmailSaturation(color); - if (saturation > 0.32) return null; + if (saturation > 0.40) return null; if (targetDark && brightness <= 0.52) return adaptiveEmailMix(text, surface, brightness * 0.24); if (!targetDark && brightness >= 0.56) return adaptiveEmailMix(text, surface, (1 - brightness) * 0.24); return null; }; const remapBorder = (color) => { - if (!color || color.a < 0.35 || themeAlreadyMatches) return null; + if (!color || color.a < 0.35) return null; const saturation = adaptiveEmailSaturation(color); if (saturation > 0.28) return null; const brightness = adaptiveEmailBrightness(color); @@ -1032,10 +1083,10 @@ function adaptMessagePreviewColors(html, detail, body) { return null; }; - const mapBackground = (color) => themeAlreadyMatches ? null : remapBackground(color); + const mapBackground = (color) => remapBackground(color); const mapText = (color, background = surface) => { if (!color) return null; - const neutralMapped = themeAlreadyMatches ? null : remapNeutralText(color); + const neutralMapped = remapNeutralText(color); const candidate = neutralMapped || color; const readable = adaptiveEmailReadableColor(candidate, background, text); return neutralMapped || readable !== candidate ? readable : null; @@ -1135,17 +1186,27 @@ function renderMessageBody(detail, forceRemote = false) { const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:"; const csp = ``; let safeHtml = sanitizeMessagePreviewHtml(detail.html); + const bodyPresentation = messagePreviewBodyPresentation(detail.html); let previewTheme = { targetTheme: "light", surface: "#fff", text: "#202622" }; if (state.prefs.adaptiveEmailColors) { previewTheme = adaptMessagePreviewColors(safeHtml, detail, body); safeHtml = previewTheme.html; } + iframe.style.background = previewTheme.surface; + iframe.style.colorScheme = previewTheme.targetTheme; const quoteColor = state.prefs.adaptiveEmailColors ? `color-mix(in srgb, ${previewTheme.text} 25%, ${previewTheme.surface})` : "#d9ddda"; const quoteStyle = state.mailPrefs.collapse_quotes !== false ? `blockquote{max-height:16em;overflow:auto;border-left:2px solid ${quoteColor};padding-left:12px;margin-left:0}` : `blockquote{border-left:2px solid ${quoteColor};padding-left:12px;margin-left:0}`; const previewStyle = state.prefs.adaptiveEmailColors ? `body{margin:0;padding:22px;color:${previewTheme.text};background:${previewTheme.surface};font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:inherit;text-decoration:underline}pre{white-space:pre-wrap}${quoteStyle}` : `body{margin:0;padding:22px;color:#202622;background:#fff;font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:#356a56}pre{white-space:pre-wrap}${quoteStyle}`; - iframe.srcdoc = `${csp}${safeHtml}`; + const bodyAttributes = [ + bodyPresentation.className ? `class="${escapeHtml(bodyPresentation.className)}"` : "", + bodyPresentation.id ? `id="${escapeHtml(bodyPresentation.id)}"` : "", + bodyPresentation.dir ? `dir="${escapeHtml(bodyPresentation.dir)}"` : "", + bodyPresentation.lang ? `lang="${escapeHtml(bodyPresentation.lang)}"` : "", + bodyPresentation.style ? `style="${escapeHtml(bodyPresentation.style)}"` : "", + ].filter(Boolean).join(" "); + iframe.srcdoc = `${csp}${safeHtml}`; body.append(iframe); } diff --git a/src/web/js/50-compose.js b/src/web/js/50-compose.js index b564b4f..b758788 100644 --- a/src/web/js/50-compose.js +++ b/src/web/js/50-compose.js @@ -1404,6 +1404,10 @@ function scheduleUnusedInlineAttachmentCleanup() { } async function cleanupUnusedInlineAttachments() { + if (state.composeUploading) { + scheduleUnusedInlineAttachmentCleanup(); + return; + } syncComposeInlineAttachments(); const referenced = composeInlineAttachmentIds(); const staleSignatureIds = [...state.signatureAttachmentIds].filter((id) => !referenced.has(id)); diff --git a/src/web/js/60-mail-actions-search.js b/src/web/js/60-mail-actions-search.js index 9e1753f..07d0efa 100644 --- a/src/web/js/60-mail-actions-search.js +++ b/src/web/js/60-mail-actions-search.js @@ -40,16 +40,30 @@ async function toggleFlagged() { try { await updateFlag("\\Flagged", !flagged); toast(flagged ? "Flag removed" : "Message flagged", "success"); } catch (error) { toast(error.message, "error"); } } +function suppressIntentionalMoveNotification(destination, result) { + const uid = Number(result?.undo_move?.uid); + if (!destination || !Number.isFinite(uid) || uid <= 0) return; + if (typeof suppressRealtimeNotificationForMessage === "function") { + suppressRealtimeNotificationForMessage(destination, uid); + } +} + function moveUndoAction(result, label = "Undo") { const undo = result?.undo_move; if (!undo?.from_folder || !undo?.to_folder || !Number.isFinite(Number(undo.uid))) return null; return { label, onClick: async () => { - await request("/messages/move", { - method: "POST", - body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }), - }); + const releaseRealtimeNotificationSuppression = suppressRealtimeNotificationsForFolder(undo.to_folder); + try { + const result = await request("/messages/move", { + method: "POST", + body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }), + }); + suppressIntentionalMoveNotification(undo.to_folder, result); + } finally { + releaseRealtimeNotificationSuppression(); + } state.selected = null; state.currentDetail = null; state.selectedMessages.clear(); @@ -68,10 +82,16 @@ function bulkMoveUndoAction(undoMoves) { onClick: async () => { let restored = 0; for (const undo of [...undoMoves].reverse()) { - await request("/messages/move", { - method: "POST", - body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }), - }); + const releaseRealtimeNotificationSuppression = suppressRealtimeNotificationsForFolder(undo.to_folder); + try { + const result = await request("/messages/move", { + method: "POST", + body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }), + }); + suppressIntentionalMoveNotification(undo.to_folder, result); + } finally { + releaseRealtimeNotificationSuppression(); + } restored += 1; } state.selected = null; @@ -87,7 +107,14 @@ function bulkMoveUndoAction(undoMoves) { async function moveMessageByDrag(fromFolder, uid, targetFolder) { if (!targetFolder || targetFolder === fromFolder) return; - const result = await request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: fromFolder, to_folder: targetFolder, uid }) }); + const releaseRealtimeNotificationSuppression = suppressRealtimeNotificationsForFolder(targetFolder); + let result; + try { + result = await request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: fromFolder, to_folder: targetFolder, uid }) }); + suppressIntentionalMoveNotification(targetFolder, result); + } finally { + releaseRealtimeNotificationSuppression(); + } const wasCurrentFolder = state.folder === fromFolder; if (wasCurrentFolder) { state.messages = state.messages.filter((message) => !(message.uid === uid && (message.folder || state.folder) === fromFolder)); @@ -127,15 +154,21 @@ async function moveCurrentTo(targetFolder, successMessage = null) { if (!state.currentDetail || !targetFolder || targetFolder === state.currentDetail.folder) return; const detail = state.currentDetail; const nextTarget = nextMessageTargetAfterRemoval(detail.folder, detail.uid); + const releaseRealtimeNotificationSuppression = suppressRealtimeNotificationsForFolder(targetFolder); try { const result = await request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: detail.folder, to_folder: targetFolder, uid: detail.uid }), }); + suppressIntentionalMoveNotification(targetFolder, result); await removeCurrentMessageFromView(detail.folder, detail.uid, nextTarget); await refreshFolderListOnly(); toast(successMessage || `Moved to ${targetFolder}`, "success", 9000, moveUndoAction(result)); - } catch (error) { toast(error.message, "error"); } + } catch (error) { + toast(error.message, "error"); + } finally { + releaseRealtimeNotificationSuppression(); + } } async function copyCurrentTo(targetFolder) { @@ -277,6 +310,7 @@ async function performBulk(action, targetFolder = null) { if (destination && destination !== folder) { movedOperations += 1; const result = await request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: folder, to_folder: destination, uid: message.uid }) }); + suppressIntentionalMoveNotification(destination, result); if (result?.undo_move) undoMoves.push(result.undo_move); } else if (action === "delete") { await request(`/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" }); diff --git a/src/web/js/72-openpgp-identities.js b/src/web/js/72-openpgp-identities.js index 03377e0..ba2cdbb 100644 --- a/src/web/js/72-openpgp-identities.js +++ b/src/web/js/72-openpgp-identities.js @@ -335,16 +335,41 @@ async function loadIdentities() { } catch (error) { toast(error.message, "error"); } } +function activeAccountEmail() { + return String(state.accounts.find((account) => account.id === state.accountId)?.email || "").trim(); +} + +function preferredComposeIdentity() { + const defaultIdentity = state.identities.find((identity) => identity.is_default); + if (defaultIdentity) return defaultIdentity; + const accountEmail = activeAccountEmail().toLowerCase(); + if (accountEmail) { + const matchingIdentity = state.identities.find((identity) => String(identity.email || "").trim().toLowerCase() === accountEmail); + if (matchingIdentity) return matchingIdentity; + } + return state.identities[0] || null; +} + function renderComposeIdentities() { const select = $("#compose-from"); if (!select) return; - select.replaceChildren(new Option("Account default", "")); + const accountEmail = activeAccountEmail(); + const normalizedAccountEmail = accountEmail.toLowerCase(); + const hasAccountIdentity = Boolean(normalizedAccountEmail) && state.identities.some((identity) => + String(identity.email || "").trim().toLowerCase() === normalizedAccountEmail + ); + + select.replaceChildren(); + if (!hasAccountIdentity) { + select.add(new Option(accountEmail ? `${accountEmail} (account)` : "Account default", "")); + } for (const identity of state.identities) { const label = identity.display_name ? `${identity.display_name} <${identity.email}>` : identity.email; - const option = new Option(label, String(identity.id)); - if (identity.is_default) option.selected = true; - select.add(option); + select.add(new Option(label, String(identity.id))); } + const preferred = preferredComposeIdentity(); + if (preferred) select.value = String(preferred.id); + else if (!hasAccountIdentity) select.value = ""; } function renderIdentities() { @@ -440,7 +465,7 @@ async function deleteIdentity() { function selectedIdentity() { const id = Number($("#compose-from")?.value || 0); - return state.identities.find((identity) => identity.id === id) || state.identities.find((identity) => identity.is_default) || null; + return state.identities.find((identity) => identity.id === id) || preferredComposeIdentity(); } function signatureEnabledForCompose(identity, action = state.composeAction || "new") { diff --git a/src/web/js/80-settings-realtime.js b/src/web/js/80-settings-realtime.js index 81809cb..241aa08 100644 --- a/src/web/js/80-settings-realtime.js +++ b/src/web/js/80-settings-realtime.js @@ -263,6 +263,32 @@ function realtimeNotificationSuppressionKey(folder, accountId = state.accountId) return `${accountId || ""}|${folder || "INBOX"}`; } +function realtimeSuppressedMessageKey(folder, uid, accountId = state.accountId) { + return `${accountId || ""}|${folder || "INBOX"}|${Number(uid) || 0}`; +} + +function suppressRealtimeNotificationForMessage(folder, uid, accountId = state.accountId, ttlMs = 15000) { + const numericUid = Number(uid); + if (!folder || !Number.isFinite(numericUid) || numericUid <= 0) return; + state.realtimeNotificationSuppressedMessages.set( + realtimeSuppressedMessageKey(folder, numericUid, accountId), + Date.now() + Math.max(3000, Number(ttlMs) || 15000), + ); +} + +function consumeRealtimeMessageNotificationSuppression(data, accountId) { + const now = Date.now(); + for (const [key, expiresAt] of state.realtimeNotificationSuppressedMessages) { + if (Number(expiresAt) <= now) state.realtimeNotificationSuppressedMessages.delete(key); + } + const uid = Number(data?.latest_uid); + if (!Number.isFinite(uid) || uid <= 0) return false; + const key = realtimeSuppressedMessageKey(data?.folder, uid, accountId); + if (!state.realtimeNotificationSuppressedMessages.has(key)) return false; + state.realtimeNotificationSuppressedMessages.delete(key); + return true; +} + function suppressRealtimeNotificationsForFolder(folder, accountId = state.accountId) { if (!folder) return () => { }; const key = realtimeNotificationSuppressionKey(folder, accountId); @@ -294,6 +320,7 @@ function realtimeFolderIsDrafts(folderName) { function realtimeNewMailNotificationsSuppressed(data, accountId) { if (!state.realtimeNotificationsReady) return true; if (realtimeFolderIsDrafts(data?.folder)) return true; + if (consumeRealtimeMessageNotificationSuppression(data, accountId)) return true; return (state.realtimeNotificationSuppressions.get(realtimeNotificationSuppressionKey(data?.folder, accountId)) || 0) > 0; } @@ -317,6 +344,7 @@ function resetRealtimeNotificationGate() { state.realtimeNotificationsReady = false; state.realtimeNotificationsArmRequested = false; state.realtimeNotificationSuppressions.clear(); + state.realtimeNotificationSuppressedMessages.clear(); state.realtimeNotifiedMessages.clear(); } diff --git a/src/web/js/95-bindings-boot.js b/src/web/js/95-bindings-boot.js index 62e186b..8fd440f 100644 --- a/src/web/js/95-bindings-boot.js +++ b/src/web/js/95-bindings-boot.js @@ -1,5 +1,19 @@ function bindEditor() { + const composeEditor = $("#compose-editor"); + composeEditor?.addEventListener("keydown", (event) => { + if (!(event.ctrlKey || event.metaKey) || event.altKey) return; + const key = event.key.toLowerCase(); + const command = key === "z" ? (event.shiftKey ? "redo" : "undo") : (key === "y" && !event.shiftKey ? "redo" : null); + if (!command) return; + if (typeof document.execCommand !== "function") return; + event.preventDefault(); + composeEditor.focus(); + document.execCommand(command, false, null); + scheduleDraftSave(); + scheduleUnusedInlineAttachmentCleanup(); + updateComposeMessageSize(); + }); $$(".editor-toolbar [data-command]").forEach((button) => button.addEventListener("click", () => { $("#compose-editor").focus(); document.execCommand(button.dataset.command, false, button.dataset.commandValue || null);