1 parent
1bb398acd1
commit
1309358b68
17 files changed
+239
-50
No files matched your search
+8
-2
@@ -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 `<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 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
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -3506,7 +3506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustbox"
|
||||
version = "0.2.17"
|
||||
version = "0.2.18"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-imap",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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('<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('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")
|
||||
|
||||
@@ -595,7 +595,7 @@
|
||||
width: 100%;
|
||||
min-height: 68vh;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
background: var(--reader-bg);
|
||||
}
|
||||
|
||||
.message-body pre {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(`<body>${html}</body>`, "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 = `<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);
|
||||
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 = `<!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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user