1 parent
1bb398acd1
commit
1309358b68
17 files changed
+232
-43
No files matched your search
+8
-2
@@ -1,7 +1,13 @@
|
|||||||
# RustBox changelog
|
# 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 `<head>`, 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 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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
### 0.2.16
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -3506,7 +3506,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.17"
|
version = "0.2.18"
|
||||||
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.17"
|
version = "0.2.18"
|
||||||
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.17 is derived from the Oxi webmail project:
|
RustBox 0.2.18 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.17
|
# RustBox 0.2.18
|
||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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
|
- adds Shift-click contiguous message selection and direct first/last pagination controls
|
||||||
@@ -70,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.17 configuration
|
- README.md documents 0.2.18 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.17
|
- Cargo.toml version = 0.2.18
|
||||||
- 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,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
|
- 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
|
- 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
|
- 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
|
- 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
|
||||||
- V047-V050 are consolidated into compatibility baseline V050; V051 migrates existing tenants with adaptive HTML-mail colors and mailbox loading mode; V052 is next
|
- 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:
|
Completed in this environment:
|
||||||
- offline release validator: scripts/validate-release-static.py (413/413 checks passed)
|
- 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
|
- 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.17
|
- release metadata consistently identifies 0.2.18
|
||||||
- V050 baseline consolidation plus the V051 adaptive-colors/mailbox-loading migration are validated offline
|
- V050 baseline consolidation plus the V051 adaptive-colors/mailbox-loading migration are validated offline
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- Mail loading activity is represented by the app-bar spinner for pagination, progressive/full-folder retrieval and individual message/draft loading.
|
||||||
|
|||||||
@@ -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('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('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('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('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('<meta charset="utf-8">${csp}' in reader_js, "message preview srcdoc explicitly declares UTF-8")
|
require('<meta charset="utf-8">${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('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('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('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")
|
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('.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("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("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('.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-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")
|
||||||
|
|||||||
@@ -595,7 +595,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 68vh;
|
min-height: 68vh;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: #fff;
|
background: var(--reader-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-body pre {
|
.message-body pre {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
realtimeSeenEvents: new Map(),
|
realtimeSeenEvents: new Map(),
|
||||||
realtimeNotifiedMessages: new Map(),
|
realtimeNotifiedMessages: new Map(),
|
||||||
realtimeNotificationSuppressions: new Map(),
|
realtimeNotificationSuppressions: new Map(),
|
||||||
|
realtimeNotificationSuppressedMessages: new Map(),
|
||||||
realtimeNotificationsReady: false,
|
realtimeNotificationsReady: false,
|
||||||
realtimeNotificationsArmRequested: false,
|
realtimeNotificationsArmRequested: false,
|
||||||
realtimeNotificationsReadyTimer: null,
|
realtimeNotificationsReadyTimer: null,
|
||||||
@@ -166,7 +167,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeMessagePreviewHtml(html) {
|
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) => {
|
doc.querySelectorAll("a[href]").forEach((node) => {
|
||||||
if (!node.hasAttribute("target")) node.setAttribute("target", "_blank");
|
if (!node.hasAttribute("target")) node.setAttribute("target", "_blank");
|
||||||
const target = String(node.getAttribute("target") || "").toLowerCase();
|
const target = String(node.getAttribute("target") || "").toLowerCase();
|
||||||
|
|||||||
@@ -862,7 +862,10 @@ function parseAdaptiveEmailColor(value) {
|
|||||||
silver: [192, 192, 192], navy: [0, 0, 128], red: [255, 0, 0], green: [0, 128, 0],
|
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],
|
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],
|
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 };
|
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);
|
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);
|
match = raw.match(/^rgba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*[, ]\s*([0-9.]+)(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i);
|
||||||
if (!match) return null;
|
if (match) {
|
||||||
const alpha = match[4]?.endsWith("%") ? Number(match[4].slice(0, -1)) / 100 : Number(match[4] ?? 1);
|
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))));
|
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)) };
|
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 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) {
|
function adaptiveEmailRgbCss(color) {
|
||||||
@@ -928,7 +949,7 @@ function adaptiveEmailContrast(a, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adaptiveEmailReadableColor(color, background, preferred) {
|
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) {
|
for (let step = 0.18; step <= 1.001; step += 0.14) {
|
||||||
const candidate = adaptiveEmailMix(color, preferred, step);
|
const candidate = adaptiveEmailMix(color, preferred, step);
|
||||||
if (adaptiveEmailContrast(candidate, background) >= 4.5) return candidate;
|
if (adaptiveEmailContrast(candidate, background) >= 4.5) return candidate;
|
||||||
@@ -969,38 +990,68 @@ function adaptiveEmailThemePalette(body, targetTheme) {
|
|||||||
|
|
||||||
function adaptiveEmailMapCssDeclarations(css, mapBackground, mapText, mapBorder) {
|
function adaptiveEmailMapCssDeclarations(css, mapBackground, mapText, mapBorder) {
|
||||||
if (!css) return css;
|
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) => {
|
return css.replace(/(^|[;{])\s*([a-z-]+)\s*:\s*([^;{}]+)/gi, (whole, prefix, property, value) => {
|
||||||
const prop = String(property || "").toLowerCase();
|
const prop = String(property || "").toLowerCase();
|
||||||
let mapper = null;
|
let mapper = null;
|
||||||
if (prop === "color") mapper = mapText;
|
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;
|
else if (/^border(?:-(?:top|right|bottom|left))?(?:-color)?$/.test(prop)) mapper = mapBorder;
|
||||||
if (!mapper) return whole;
|
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 parsed = parseAdaptiveEmailColor(token);
|
||||||
const mapped = parsed ? mapper(parsed) : null;
|
const mapped = parsed ? mapper(parsed) : null;
|
||||||
return mapped ? adaptiveEmailRgbCss(mapped) : token;
|
return mapped ? adaptiveEmailRgbCss(mapped) : token;
|
||||||
});
|
});
|
||||||
|
protectedParts.forEach((part, index) => { next = next.replace(`__RUSTBOX_CSS_PROTECTED_${index}__`, part); });
|
||||||
return `${prefix}${property}:${next}`;
|
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) {
|
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 targetDark = targetTheme === "dark";
|
||||||
const palette = adaptiveEmailThemePalette(body, targetTheme);
|
const palette = adaptiveEmailThemePalette(body, targetTheme);
|
||||||
const surface = palette.surface;
|
const surface = palette.surface;
|
||||||
const text = palette.text;
|
const text = palette.text;
|
||||||
const mailTheme = String(detail?.email_theme || "").toLowerCase();
|
|
||||||
const themeAlreadyMatches = mailTheme === targetTheme;
|
|
||||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
||||||
|
|
||||||
const remapBackground = (color) => {
|
const remapBackground = (color) => {
|
||||||
if (!color || color.a < 0.45) return null;
|
if (!color || color.a < 0.45) return null;
|
||||||
const brightness = adaptiveEmailBrightness(color);
|
const brightness = adaptiveEmailBrightness(color);
|
||||||
const saturation = adaptiveEmailSaturation(color);
|
const saturation = adaptiveEmailSaturation(color);
|
||||||
if (saturation > 0.38) return null;
|
if (saturation > 0.46) return null;
|
||||||
if (targetDark && brightness >= 0.54) {
|
if (targetDark && brightness >= 0.54) {
|
||||||
const depth = Math.max(0, Math.min(0.14, (1 - brightness) * 0.30));
|
const depth = Math.max(0, Math.min(0.14, (1 - brightness) * 0.30));
|
||||||
return adaptiveEmailMix(surface, text, depth);
|
return adaptiveEmailMix(surface, text, depth);
|
||||||
@@ -1016,14 +1067,14 @@ function adaptMessagePreviewColors(html, detail, body) {
|
|||||||
if (!color || color.a < 0.45) return null;
|
if (!color || color.a < 0.45) return null;
|
||||||
const brightness = adaptiveEmailBrightness(color);
|
const brightness = adaptiveEmailBrightness(color);
|
||||||
const saturation = adaptiveEmailSaturation(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.52) return adaptiveEmailMix(text, surface, brightness * 0.24);
|
||||||
if (!targetDark && brightness >= 0.56) return adaptiveEmailMix(text, surface, (1 - brightness) * 0.24);
|
if (!targetDark && brightness >= 0.56) return adaptiveEmailMix(text, surface, (1 - brightness) * 0.24);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const remapBorder = (color) => {
|
const remapBorder = (color) => {
|
||||||
if (!color || color.a < 0.35 || themeAlreadyMatches) return null;
|
if (!color || color.a < 0.35) return null;
|
||||||
const saturation = adaptiveEmailSaturation(color);
|
const saturation = adaptiveEmailSaturation(color);
|
||||||
if (saturation > 0.28) return null;
|
if (saturation > 0.28) return null;
|
||||||
const brightness = adaptiveEmailBrightness(color);
|
const brightness = adaptiveEmailBrightness(color);
|
||||||
@@ -1032,10 +1083,10 @@ function adaptMessagePreviewColors(html, detail, body) {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapBackground = (color) => themeAlreadyMatches ? null : remapBackground(color);
|
const mapBackground = (color) => remapBackground(color);
|
||||||
const mapText = (color, background = surface) => {
|
const mapText = (color, background = surface) => {
|
||||||
if (!color) return null;
|
if (!color) return null;
|
||||||
const neutralMapped = themeAlreadyMatches ? null : remapNeutralText(color);
|
const neutralMapped = remapNeutralText(color);
|
||||||
const candidate = neutralMapped || color;
|
const candidate = neutralMapped || color;
|
||||||
const readable = adaptiveEmailReadableColor(candidate, background, text);
|
const readable = adaptiveEmailReadableColor(candidate, background, text);
|
||||||
return neutralMapped || readable !== candidate ? readable : null;
|
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 imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:";
|
||||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${imageSources}; style-src 'unsafe-inline'; font-src data:">`;
|
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${imageSources}; style-src 'unsafe-inline'; font-src data:">`;
|
||||||
let safeHtml = sanitizeMessagePreviewHtml(detail.html);
|
let safeHtml = sanitizeMessagePreviewHtml(detail.html);
|
||||||
|
const bodyPresentation = messagePreviewBodyPresentation(detail.html);
|
||||||
let previewTheme = { targetTheme: "light", surface: "#fff", text: "#202622" };
|
let previewTheme = { targetTheme: "light", surface: "#fff", text: "#202622" };
|
||||||
if (state.prefs.adaptiveEmailColors) {
|
if (state.prefs.adaptiveEmailColors) {
|
||||||
previewTheme = adaptMessagePreviewColors(safeHtml, detail, body);
|
previewTheme = adaptMessagePreviewColors(safeHtml, detail, body);
|
||||||
safeHtml = previewTheme.html;
|
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 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 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
|
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:${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}`;
|
: `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 = `<!doctype html><html><head><meta charset="utf-8">${csp}<meta name="color-scheme" content="${previewTheme.targetTheme}"><style>${previewStyle}</style></head><body>${safeHtml}</body></html>`;
|
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 = `<!doctype html><html><head><meta charset="utf-8">${csp}<meta name="color-scheme" content="${previewTheme.targetTheme}"><style>${previewStyle}</style></head><body${bodyAttributes ? ` ${bodyAttributes}` : ""}>${safeHtml}</body></html>`;
|
||||||
body.append(iframe);
|
body.append(iframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1404,6 +1404,10 @@ function scheduleUnusedInlineAttachmentCleanup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupUnusedInlineAttachments() {
|
async function cleanupUnusedInlineAttachments() {
|
||||||
|
if (state.composeUploading) {
|
||||||
|
scheduleUnusedInlineAttachmentCleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
syncComposeInlineAttachments();
|
syncComposeInlineAttachments();
|
||||||
const referenced = composeInlineAttachmentIds();
|
const referenced = composeInlineAttachmentIds();
|
||||||
const staleSignatureIds = [...state.signatureAttachmentIds].filter((id) => !referenced.has(id));
|
const staleSignatureIds = [...state.signatureAttachmentIds].filter((id) => !referenced.has(id));
|
||||||
|
|||||||
@@ -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"); }
|
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") {
|
function moveUndoAction(result, label = "Undo") {
|
||||||
const undo = result?.undo_move;
|
const undo = result?.undo_move;
|
||||||
if (!undo?.from_folder || !undo?.to_folder || !Number.isFinite(Number(undo.uid))) return null;
|
if (!undo?.from_folder || !undo?.to_folder || !Number.isFinite(Number(undo.uid))) return null;
|
||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
await request("/messages/move", {
|
const releaseRealtimeNotificationSuppression = suppressRealtimeNotificationsForFolder(undo.to_folder);
|
||||||
|
try {
|
||||||
|
const result = await request("/messages/move", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }),
|
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.selected = null;
|
||||||
state.currentDetail = null;
|
state.currentDetail = null;
|
||||||
state.selectedMessages.clear();
|
state.selectedMessages.clear();
|
||||||
@@ -68,10 +82,16 @@ function bulkMoveUndoAction(undoMoves) {
|
|||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
let restored = 0;
|
let restored = 0;
|
||||||
for (const undo of [...undoMoves].reverse()) {
|
for (const undo of [...undoMoves].reverse()) {
|
||||||
await request("/messages/move", {
|
const releaseRealtimeNotificationSuppression = suppressRealtimeNotificationsForFolder(undo.to_folder);
|
||||||
|
try {
|
||||||
|
const result = await request("/messages/move", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }),
|
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;
|
restored += 1;
|
||||||
}
|
}
|
||||||
state.selected = null;
|
state.selected = null;
|
||||||
@@ -87,7 +107,14 @@ function bulkMoveUndoAction(undoMoves) {
|
|||||||
|
|
||||||
async function moveMessageByDrag(fromFolder, uid, targetFolder) {
|
async function moveMessageByDrag(fromFolder, uid, targetFolder) {
|
||||||
if (!targetFolder || targetFolder === fromFolder) return;
|
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;
|
const wasCurrentFolder = state.folder === fromFolder;
|
||||||
if (wasCurrentFolder) {
|
if (wasCurrentFolder) {
|
||||||
state.messages = state.messages.filter((message) => !(message.uid === uid && (message.folder || state.folder) === fromFolder));
|
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;
|
if (!state.currentDetail || !targetFolder || targetFolder === state.currentDetail.folder) return;
|
||||||
const detail = state.currentDetail;
|
const detail = state.currentDetail;
|
||||||
const nextTarget = nextMessageTargetAfterRemoval(detail.folder, detail.uid);
|
const nextTarget = nextMessageTargetAfterRemoval(detail.folder, detail.uid);
|
||||||
|
const releaseRealtimeNotificationSuppression = suppressRealtimeNotificationsForFolder(targetFolder);
|
||||||
try {
|
try {
|
||||||
const result = await request("/messages/move", {
|
const result = await request("/messages/move", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ from_folder: detail.folder, to_folder: targetFolder, uid: detail.uid }),
|
body: JSON.stringify({ from_folder: detail.folder, to_folder: targetFolder, uid: detail.uid }),
|
||||||
});
|
});
|
||||||
|
suppressIntentionalMoveNotification(targetFolder, result);
|
||||||
await removeCurrentMessageFromView(detail.folder, detail.uid, nextTarget);
|
await removeCurrentMessageFromView(detail.folder, detail.uid, nextTarget);
|
||||||
await refreshFolderListOnly();
|
await refreshFolderListOnly();
|
||||||
toast(successMessage || `Moved to ${targetFolder}`, "success", 9000, moveUndoAction(result));
|
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) {
|
async function copyCurrentTo(targetFolder) {
|
||||||
@@ -277,6 +310,7 @@ async function performBulk(action, targetFolder = null) {
|
|||||||
if (destination && destination !== folder) {
|
if (destination && destination !== folder) {
|
||||||
movedOperations += 1;
|
movedOperations += 1;
|
||||||
const result = await request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: folder, to_folder: destination, uid: message.uid }) });
|
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);
|
if (result?.undo_move) undoMoves.push(result.undo_move);
|
||||||
} else if (action === "delete") {
|
} else if (action === "delete") {
|
||||||
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
|
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
|
||||||
|
|||||||
@@ -335,16 +335,41 @@ async function loadIdentities() {
|
|||||||
} catch (error) { toast(error.message, "error"); }
|
} 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() {
|
function renderComposeIdentities() {
|
||||||
const select = $("#compose-from");
|
const select = $("#compose-from");
|
||||||
if (!select) return;
|
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) {
|
for (const identity of state.identities) {
|
||||||
const label = identity.display_name ? `${identity.display_name} <${identity.email}>` : identity.email;
|
const label = identity.display_name ? `${identity.display_name} <${identity.email}>` : identity.email;
|
||||||
const option = new Option(label, String(identity.id));
|
select.add(new Option(label, String(identity.id)));
|
||||||
if (identity.is_default) option.selected = true;
|
|
||||||
select.add(option);
|
|
||||||
}
|
}
|
||||||
|
const preferred = preferredComposeIdentity();
|
||||||
|
if (preferred) select.value = String(preferred.id);
|
||||||
|
else if (!hasAccountIdentity) select.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderIdentities() {
|
function renderIdentities() {
|
||||||
@@ -440,7 +465,7 @@ async function deleteIdentity() {
|
|||||||
|
|
||||||
function selectedIdentity() {
|
function selectedIdentity() {
|
||||||
const id = Number($("#compose-from")?.value || 0);
|
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") {
|
function signatureEnabledForCompose(identity, action = state.composeAction || "new") {
|
||||||
|
|||||||
@@ -263,6 +263,32 @@ function realtimeNotificationSuppressionKey(folder, accountId = state.accountId)
|
|||||||
return `${accountId || ""}|${folder || "INBOX"}`;
|
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) {
|
function suppressRealtimeNotificationsForFolder(folder, accountId = state.accountId) {
|
||||||
if (!folder) return () => { };
|
if (!folder) return () => { };
|
||||||
const key = realtimeNotificationSuppressionKey(folder, accountId);
|
const key = realtimeNotificationSuppressionKey(folder, accountId);
|
||||||
@@ -294,6 +320,7 @@ function realtimeFolderIsDrafts(folderName) {
|
|||||||
function realtimeNewMailNotificationsSuppressed(data, accountId) {
|
function realtimeNewMailNotificationsSuppressed(data, accountId) {
|
||||||
if (!state.realtimeNotificationsReady) return true;
|
if (!state.realtimeNotificationsReady) return true;
|
||||||
if (realtimeFolderIsDrafts(data?.folder)) return true;
|
if (realtimeFolderIsDrafts(data?.folder)) return true;
|
||||||
|
if (consumeRealtimeMessageNotificationSuppression(data, accountId)) return true;
|
||||||
return (state.realtimeNotificationSuppressions.get(realtimeNotificationSuppressionKey(data?.folder, accountId)) || 0) > 0;
|
return (state.realtimeNotificationSuppressions.get(realtimeNotificationSuppressionKey(data?.folder, accountId)) || 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +344,7 @@ function resetRealtimeNotificationGate() {
|
|||||||
state.realtimeNotificationsReady = false;
|
state.realtimeNotificationsReady = false;
|
||||||
state.realtimeNotificationsArmRequested = false;
|
state.realtimeNotificationsArmRequested = false;
|
||||||
state.realtimeNotificationSuppressions.clear();
|
state.realtimeNotificationSuppressions.clear();
|
||||||
|
state.realtimeNotificationSuppressedMessages.clear();
|
||||||
state.realtimeNotifiedMessages.clear();
|
state.realtimeNotifiedMessages.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
|
|
||||||
function bindEditor() {
|
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", () => {
|
$$(".editor-toolbar [data-command]").forEach((button) => button.addEventListener("click", () => {
|
||||||
$("#compose-editor").focus();
|
$("#compose-editor").focus();
|
||||||
document.execCommand(button.dataset.command, false, button.dataset.commandValue || null);
|
document.execCommand(button.dataset.command, false, button.dataset.commandValue || null);
|
||||||
|
|||||||
Reference in new issue
Block a user