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

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-03 12:11:13 +01:00
1 parent 1bb398acd1
commit 1309358b68
17 files changed
+239 -50

No files matched your search

+1 -1
View File
@@ -595,7 +595,7 @@
width: 100%;
min-height: 68vh;
border: 0;
background: #fff;
background: var(--reader-bg);
}
.message-body pre {
+12 -1
View File
@@ -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();
+77 -16
View File
@@ -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);
}
+4
View File
@@ -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));
+44 -10
View File
@@ -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" });
+30 -5
View File
@@ -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") {
+28
View File
@@ -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();
}
+14
View File
@@ -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);