Files
RustBox/ui/app.js
T
Mateusz Gruszczyński bc5fbd6ba9
RustBox CI / rustbox (push) Waiting to run
first commit
2026-10-29 15:24:59 +01:00

7049 lines
369 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"use strict";
const root = document.documentElement;
const configuredBase = root.dataset.basePath || "/";
const BASE = configuredBase === "/" ? "" : configuredBase.replace(/\/$/, "");
const API = `${BASE}/api`;
const ICONS = `${BASE}/assets/icons.svg`;
const state = {
accounts: [],
accountId: localStorage.getItem("rustbox.activeAccount") || null,
folders: [],
folder: "INBOX",
collapsedFolders: new Set(),
page: 0,
perPage: 50,
total: 0,
messages: [],
selected: null,
selectedMessages: new Set(),
selectionMode: false,
searchContext: null,
searchAllResults: null,
currentDetail: null,
specialFolders: { sent: null, drafts: null, trash: null, junk: null, archive: null },
tags: [],
sieveScripts: [],
sieveInfo: null,
sieveEditingRuleId: null,
identities: [],
pgpKeys: [],
pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false },
pgpRecipientStatus: [],
authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24 },
contacts: [],
contactGroups: [],
contactPhotoUrl: null,
calendarCursor: new Date(),
calendarView: localStorage.getItem("rustbox.calendarView") || "month",
threadExpanded: localStorage.getItem("rustbox.threadExpanded") !== "false",
calendarEvents: [],
savedSearches: [],
notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" },
mailPrefs: { messages_per_page: 50, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false },
currentFolderFlaggedOnTop: false,
markReadTimer: null,
ws: null,
wsRetryTimer: null,
realtimeRefreshTimer: null,
composeDraftId: null,
composeAttachments: [],
composeDirty: false,
composeNeedsRemoteSync: false,
composeUploading: false,
composeReplyMeta: null,
composeInsertRange: null,
composeSelectedImage: null,
composeSignatureGeneration: 0,
identitySignatureRange: null,
inlineAttachmentIds: new Set(),
inlineImageCleanupTimer: null,
imageEditorBitmap: null,
imageEditorBlob: null,
draftSaveTimer: null,
prefs: {
theme: "system", density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html",
composeAutosaveMs: 3000, defaultPriority: "normal", requestMdnDefault: false, attachmentReminder: true, composeFontFamily: "system", replyPosition: "above", quoteStyle: "standard", forwardMode: "inline", spellcheckEnabled: true,
colorPreset: "sage", accentColor: "#6f9c86", backgroundColor: "#121513", panelColor: "#181c19", textColor: "#edf0ec", fontFamily: "system", fontSize: 13,
rowHeight: 58, folderPaneWidth: 228, messagePaneWidth: 420, borderRadius: 4,
contrast: "normal", showAvatars: true, showAttachmentIcons: true, showFlags: true, reduceMotion: false, dateFormat: "locale", timeFormat: "locale",
iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 29, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#28352e", unreadFontWeight: 730, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false, folderBackground: "", messageBackground: "", readerBackground: "",
},
translations: {},
quotaWarningLevel: null,
networkOnline: navigator.onLine,
listStateTimer: null,
};
const $ = (selector, parent = document) => parent.querySelector(selector);
const $$ = (selector, parent = document) => [...parent.querySelectorAll(selector)];
const el = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
function svgIcon(name) {
return `<svg aria-hidden="true"><use href="${ICONS}#icon-${name}"></use></svg>`;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function stripRustBoxUiLeak(value) {
let text = String(value ?? "");
// Strip only RustBox's own embedded SVG sprite fragments if malformed MIME
// content captured adjacent UI markup. Legitimate SVG/code in mail remains.
text = text.replace(/<svg\b[^>]*>\s*<use\b[^>]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, "");
text = text.replace(/&lt;svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?&lt;\/svg&gt;/gi, "");
return text;
}
function cleanPlainMessageText(value) {
return stripRustBoxUiLeak(value);
}
function sanitizeMessageHtml(html) {
const doc = new DOMParser().parseFromString(stripRustBoxUiLeak(html), "text/html");
doc.querySelectorAll("script,iframe,frame,frameset,object,embed,applet,form,input,button,select,textarea,base,meta,link,svg,math").forEach((node) => node.remove());
doc.querySelectorAll("*").forEach((node) => {
for (const attr of [...node.attributes]) {
const name = attr.name.toLowerCase();
const value = attr.value.trim();
if (name.startsWith("on") || ["srcdoc", "formaction"].includes(name)) node.removeAttribute(attr.name);
if (["href", "src", "background", "action", "poster"].includes(name) && /^\s*javascript:/i.test(value)) node.removeAttribute(attr.name);
}
});
return doc.body.innerHTML;
}
function apiPath(path) {
return `${API}${path.startsWith("/") ? path : `/${path}`}`;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function setConnectivityState(online, detail = "") {
state.networkOnline = Boolean(online);
document.body.classList.toggle("network-offline", !state.networkOnline);
const banner = $("#offline-banner");
if (banner) {
banner.classList.toggle("hidden", state.networkOnline);
banner.textContent = detail || (navigator.onLine ? "RustBox cannot reach the server. Read-only cached content may still be visible." : "You are offline. RustBox will reconnect when the network returns.");
}
const label = $("#connection-label");
if (!label) return;
if (!state.networkOnline) label.textContent = navigator.onLine ? "Reconnecting" : "Offline";
else if (["Offline", "Reconnecting", "Connection error"].includes(label.textContent)) label.textContent = "Connected";
}
function listStateStorageKey(folder = state.folder) {
return `rustbox.listState.${state.accountId || "anonymous"}.${encodeURIComponent(folder || "INBOX")}`;
}
function lastFolderStorageKey() {
return `rustbox.lastFolder.${state.accountId || "anonymous"}`;
}
function activeSearchStorageKey() {
return `rustbox.activeSearch.${state.accountId || "anonymous"}`;
}
function saveActiveSearchContext() {
if (!state.searchContext) return sessionStorage.removeItem(activeSearchStorageKey());
sessionStorage.setItem(activeSearchStorageKey(), JSON.stringify(state.searchContext));
}
function readActiveSearchContext() {
try {
const parsed = JSON.parse(sessionStorage.getItem(activeSearchStorageKey()) || "null");
return parsed && typeof parsed.params === "string" ? parsed : null;
} catch (_) {
return null;
}
}
function clearActiveSearchContext() {
state.searchContext = null;
state.searchAllResults = null;
sessionStorage.removeItem(activeSearchStorageKey());
}
function readMessageListState(folder = state.folder) {
try {
const parsed = JSON.parse(localStorage.getItem(listStateStorageKey(folder)) || "null");
if (!parsed || typeof parsed !== "object") return null;
return {
scrollTop: Math.max(0, Number(parsed.scrollTop) || 0),
page: Math.max(0, Number(parsed.page) || 0),
};
} catch (_) {
return null;
}
}
function saveMessageListState() {
if (!state.accountId) return;
const list = $("#message-list");
if (!list) return;
if (state.searchContext) {
state.searchContext.scrollTop = list.scrollTop;
saveActiveSearchContext();
return;
}
localStorage.setItem(listStateStorageKey(), JSON.stringify({ scrollTop:list.scrollTop, page:state.page }));
localStorage.setItem(lastFolderStorageKey(), state.folder);
}
function scheduleMessageListStateSave() {
clearTimeout(state.listStateTimer);
state.listStateTimer = setTimeout(saveMessageListState, 120);
}
async function request(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.accountId) headers.set("X-Active-Account", state.accountId);
if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json");
if (options.method && options.method !== "GET") headers.set("X-Requested-With", "XMLHttpRequest");
const method = String(options.method || "GET").toUpperCase();
const maxAttempts = method === "GET" || method === "HEAD" ? 3 : 1;
let lastNetworkError = null;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const response = await fetch(apiPath(path), {
credentials: "include",
...options,
headers,
});
if ([502, 503, 504].includes(response.status) && attempt + 1 < maxAttempts) {
await sleep(250 * (attempt + 1));
continue;
}
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json")
? await response.json().catch(() => ({}))
: await response.text().catch(() => "");
if (!response.ok) {
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Request failed (${response.status})`;
const error = new Error(message);
error.status = response.status;
throw error;
}
setConnectivityState(true);
return payload;
} catch (error) {
if (error?.status) throw error;
lastNetworkError = error;
setConnectivityState(false);
if (attempt + 1 < maxAttempts) {
await sleep(350 * (attempt + 1));
continue;
}
}
}
const error = new Error(navigator.onLine ? "RustBox server is temporarily unreachable" : "You are offline");
error.cause = lastNetworkError;
error.network = true;
throw error;
}
function uploadRequest(path, formData, onProgress = null) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", apiPath(path), true);
xhr.withCredentials = true;
if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId);
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
if (typeof onProgress === "function") {
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100));
});
}
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
xhr.addEventListener("load", () => {
const contentType = xhr.getResponseHeader("content-type") || "";
let payload = xhr.responseText || "";
if (contentType.includes("application/json")) {
try { payload = payload ? JSON.parse(payload) : {}; } catch (_) { payload = {}; }
}
if (xhr.status < 200 || xhr.status >= 300) {
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`;
const error = new Error(message);
error.status = xhr.status;
reject(error);
return;
}
resolve(payload);
});
xhr.send(formData);
});
}
function toast(message, kind = "info", duration = null, action = null) {
const normalized = ["success", "error", "warning", "info"].includes(kind) ? kind : "info";
const lifetime = Number(duration) > 0 ? Number(duration) : normalized === "error" ? 6500 : normalized === "warning" ? 5200 : 3800;
const node = el("div", `toast toast-${normalized}`);
node.setAttribute("role", normalized === "error" ? "alert" : "status");
const iconName = normalized === "success" ? "check-circle" : normalized === "error" || normalized === "warning" ? "circle-alert" : "info";
const icon = el("span", "toast-icon");
icon.innerHTML = svgIcon(iconName);
const text = el("span", "toast-message", message);
let actionButton = null;
if (action?.label && typeof action.onClick === "function") {
actionButton = el("button", "toast-action", action.label);
actionButton.type = "button";
}
const countdown = el("span", "toast-countdown");
countdown.setAttribute("aria-hidden", "true");
const close = el("button", "toast-close");
close.type = "button";
close.setAttribute("aria-label", "Dismiss notification");
close.innerHTML = svgIcon("x");
const controls = el("span", "toast-controls");
if (actionButton) controls.append(actionButton);
controls.append(close);
node.append(icon, text, countdown, controls);
const openDialogs = $$("dialog[open]");
let stack = $("#toast-stack");
if (openDialogs.length) {
const dialog = openDialogs[openDialogs.length - 1];
stack = dialog.querySelector(":scope > .modal-toast-stack");
if (!stack) {
stack = el("div", "toast-stack modal-toast-stack");
stack.setAttribute("aria-live", "polite");
dialog.append(stack);
}
}
stack.append(node);
requestAnimationFrame(() => node.classList.add("visible"));
let remaining = lifetime;
let startedAt = 0;
let timeout = null;
let ticker = null;
let dismissed = false;
const renderCountdown = () => { countdown.textContent = `${Math.max(1, Math.ceil(remaining / 1000))}s`; };
const stopTimers = () => { clearTimeout(timeout); clearInterval(ticker); timeout = null; ticker = null; };
const dismiss = (immediate = false) => {
if (dismissed) return;
dismissed = true;
stopTimers();
if (immediate) return node.remove();
node.classList.remove("visible");
setTimeout(() => node.remove(), 160);
};
const pause = () => {
if (dismissed || !startedAt) return;
remaining = Math.max(0, remaining - (performance.now() - startedAt));
startedAt = 0;
stopTimers();
renderCountdown();
};
const resume = () => {
if (dismissed || remaining <= 0) return dismiss();
stopTimers();
startedAt = performance.now();
renderCountdown();
timeout = setTimeout(() => dismiss(), remaining);
ticker = setInterval(() => {
if (!startedAt) return;
const elapsed = performance.now() - startedAt;
const visibleRemaining = Math.max(0, remaining - elapsed);
countdown.textContent = `${Math.max(1, Math.ceil(visibleRemaining / 1000))}s`;
}, 200);
};
close.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
dismiss(true);
});
if (actionButton) {
actionButton.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
dismiss(true);
try { await action.onClick(); }
catch (error) { toast(error?.message || "Action failed", "error"); }
});
}
node.addEventListener("mouseenter", pause);
node.addEventListener("mouseleave", resume);
node.addEventListener("focusin", pause);
node.addEventListener("focusout", (event) => { if (!node.contains(event.relatedTarget)) resume(); });
resume();
}
function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) {
return new Promise((resolve) => {
const dialog = el("dialog", "dialog app-prompt-dialog");
const form = el("form", "dialog-card app-prompt-card");
form.method = "dialog";
const header = el("header", "dialog-header");
header.append(el("strong", "", title));
const close = el("button", "icon-button");
close.type = "button";
close.setAttribute("aria-label", cancelLabel);
close.innerHTML = svgIcon("x");
header.append(close);
const body = el("div", "app-prompt-body");
if (message) body.append(el("p", "app-prompt-message", message));
let control = null;
if (Array.isArray(selectOptions)) {
control = document.createElement("select");
control.className = "app-prompt-control";
for (const option of selectOptions) control.add(new Option(option.label ?? option.value, option.value));
if (input?.value !== undefined) control.value = String(input.value);
body.append(control);
} else if (input) {
control = input.multiline ? document.createElement("textarea") : document.createElement("input");
control.className = "app-prompt-control";
if (!input.multiline) control.type = input.type || "text";
control.value = input.value ?? "";
if (input.placeholder) control.placeholder = input.placeholder;
if (input.required) control.required = true;
body.append(control);
}
const actions = el("footer", "dialog-actions app-prompt-actions");
const cancel = el("button", "button quiet", cancelLabel);
cancel.type = "button";
const confirm = el("button", danger ? "button danger" : "button primary", confirmLabel);
confirm.type = "submit";
actions.append(cancel, confirm);
form.append(header, body, actions);
dialog.append(form);
document.body.append(dialog);
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value);
if (dialog.open) dialog.close();
else dialog.remove();
};
close.addEventListener("click", () => finish(null));
cancel.addEventListener("click", () => finish(null));
dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish(null); });
dialog.addEventListener("close", () => { if (!settled) { settled = true; resolve(null); } dialog.remove(); }, { once: true });
form.addEventListener("submit", (event) => {
event.preventDefault();
if (control && !control.reportValidity()) return;
finish(control ? control.value : true);
});
dialog.showModal();
setTimeout(() => (control || confirm).focus(), 20);
});
}
async function appConfirm(message, { title = "Confirm action", confirmLabel = "Continue", danger = false } = {}) {
return (await appDialog({ title, message, confirmLabel, danger })) === true;
}
function appPrompt(title, { message = "", value = "", placeholder = "", type = "text", confirmLabel = "OK", required = false } = {}) {
return appDialog({ title, message, input: { value, placeholder, type, required }, confirmLabel });
}
function appSelect(title, options, { message = "", value = "", confirmLabel = "Choose" } = {}) {
return appDialog({ title, message, input: { value }, selectOptions: options, confirmLabel });
}
function setBusy(button, busy) {
if (!button) return;
button.disabled = busy;
button.setAttribute("aria-busy", String(busy));
}
function resolveTheme(theme) {
if (theme !== "system") return theme;
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
const preferenceMap = {
theme: "theme", density: "density", preview: "preview_pane", scale: "ui_scale", language: "language",
colorPreset: "color_preset", accentColor: "accent_color", backgroundColor: "background_color", panelColor: "panel_color", textColor: "text_color", fontFamily: "font_family", fontSize: "font_size",
rowHeight: "row_height", folderPaneWidth: "folder_pane_width", messagePaneWidth: "message_pane_width",
borderRadius: "border_radius", contrast: "contrast", showAvatars: "show_avatars",
showAttachmentIcons: "show_attachment_icons", showFlags: "show_flags", reduceMotion: "reduce_motion", composeFormat: "compose_format",
composeAutosaveMs: "compose_autosave_ms", defaultPriority: "default_priority", requestMdnDefault: "request_mdn_default", attachmentReminder: "attachment_reminder", composeFontFamily: "compose_font_family", replyPosition: "reply_position", quoteStyle: "quote_style", forwardMode: "forward_mode", spellcheckEnabled: "spellcheck_enabled",
dateFormat: "date_format", timeFormat: "time_format", iconSize: "icon_size", toolbarDensity: "toolbar_density", sidebarItemHeight: "sidebar_item_height",
readerMaxWidth: "reader_max_width", composerWidth: "composer_width", selectionColor: "selection_color", unreadFontWeight: "unread_font_weight", unreadBadgeStyle: "unread_badge_style",
compactHeaders: "compact_headers", showBorders: "show_borders", scrollbarDensity: "scrollbar_density", monochrome: "monochrome", folderBackground: "folder_background", messageBackground: "message_background", readerBackground: "reader_background",
};
const preferenceTimers = new Map();
async function loadTranslations(language) {
const locale = ["en", "pl"].includes(language) ? language : "en";
try {
const response = await fetch(`${BASE}/assets/i18n/${locale}.json`, { credentials: "same-origin" });
state.translations = response.ok ? await response.json() : {};
} catch (_) { state.translations = {}; }
root.lang = locale;
$$('[data-i18n]').forEach((node) => { const key = node.dataset.i18n; if (state.translations[key]) node.textContent = state.translations[key]; });
$$('[data-i18n-placeholder]').forEach((node) => { const key = node.dataset.i18nPlaceholder; if (state.translations[key]) node.placeholder = state.translations[key]; });
$$('[data-i18n-title]').forEach((node) => { const key = node.dataset.i18nTitle; if (state.translations[key]) node.title = state.translations[key]; });
$$('[data-i18n-aria-label]').forEach((node) => { const key = node.dataset.i18nAriaLabel; if (state.translations[key]) node.setAttribute('aria-label', state.translations[key]); });
}
function tr(key, fallback = key) {
return state.translations[key] || fallback;
}
function applyPreferences() {
const p = state.prefs;
root.dataset.theme = resolveTheme(p.theme);
root.dataset.density = p.density;
root.dataset.preview = p.preview;
root.dataset.scale = p.scale;
root.dataset.colorPreset = p.colorPreset;
root.dataset.fontFamily = p.fontFamily;
root.dataset.contrast = p.contrast;
root.dataset.hideAvatars = String(!p.showAvatars);
root.dataset.hideAttachments = String(!p.showAttachmentIcons);
root.dataset.hideFlags = String(!p.showFlags);
root.dataset.reduceMotion = String(p.reduceMotion);
root.dataset.toolbarDensity = p.toolbarDensity;
root.dataset.unreadBadge = p.unreadBadgeStyle;
root.dataset.compactHeaders = String(p.compactHeaders);
root.dataset.showBorders = String(p.showBorders);
root.dataset.scrollbarDensity = p.scrollbarDensity;
root.dataset.monochrome = String(p.monochrome);
root.style.setProperty("--user-accent", p.accentColor);
root.style.setProperty("--user-bg", p.backgroundColor);
root.style.setProperty("--user-panel", p.panelColor);
root.style.setProperty("--user-text", p.textColor);
const scaleFactor = ({ small: 0.90, normal: 1, large: 1.12 })[p.scale] || 1;
const densityFactor = p.density === "comfortable" ? 1.13 : 0.93;
root.style.setProperty("--ui-scale-factor", String(scaleFactor));
root.style.setProperty("--density-factor", String(densityFactor));
root.style.setProperty("--ui-size", `${Math.round(Number(p.fontSize || 13) * scaleFactor * 100) / 100}px`);
root.style.setProperty("--row-height", `${Math.max(42, Math.round(Number(p.rowHeight || 58) * scaleFactor))}px`);
root.style.setProperty("--folder-pane-width", `${Math.max(170, Math.round(Number(p.folderPaneWidth || 228) * scaleFactor))}px`);
root.style.setProperty("--message-pane-width", `${Math.max(300, Math.round(Number(p.messagePaneWidth || 420) * scaleFactor))}px`);
root.style.setProperty("--control-height", `${Math.max(26, Math.round(30 * scaleFactor * densityFactor))}px`);
root.style.setProperty("--appbar-height", `${Math.max(38, Math.round(44 * scaleFactor * densityFactor))}px`);
root.style.setProperty("--dialog-header-height", `${Math.max(38, Math.round(43 * scaleFactor * densityFactor))}px`);
root.style.setProperty("--icon-size", `${Math.max(14, Math.round(Number(p.iconSize || 18) * scaleFactor))}px`);
const toolbarFactor = ({ compact: 0.88, normal: 1, spacious: 1.14 })[p.toolbarDensity] || 1;
root.style.setProperty("--toolbar-density-factor", String(toolbarFactor));
root.style.setProperty("--sidebar-item-height", `${Math.max(24, Math.round(Number(p.sidebarItemHeight || 29) * scaleFactor))}px`);
root.style.setProperty("--reader-max-width", Number(p.readerMaxWidth) > 0 ? `${Number(p.readerMaxWidth)}px` : "none");
root.style.setProperty("--composer-width", `${Math.max(640, Number(p.composerWidth || 900))}px`);
root.style.setProperty("--compose-font-family", composeFontCss(p.composeFontFamily));
root.style.setProperty("--selected", p.selectionColor || "#28352e");
root.style.setProperty("--unread-font-weight", String(Number(p.unreadFontWeight || 730)));
root.style.setProperty("--folder-bg", p.folderBackground || "var(--sidebar)");
root.style.setProperty("--message-bg", p.messageBackground || "var(--panel)");
root.style.setProperty("--reader-bg", p.readerBackground || "var(--panel)");
root.style.setProperty("--button-pad-x", `${Math.max(8, Math.round(10 * scaleFactor))}px`);
root.style.setProperty("--button-pad-y", `${Math.max(3, Math.round(5 * scaleFactor * densityFactor))}px`);
root.style.setProperty("--field-pad-x", `${Math.max(7, Math.round(8 * scaleFactor))}px`);
root.style.setProperty("--field-pad-y", `${Math.max(3, Math.round(5 * scaleFactor * densityFactor))}px`);
root.style.setProperty("--radius-control", `${p.borderRadius}px`);
root.style.setProperty("--radius-panel", `${Math.min(p.borderRadius + 1, 13)}px`);
const values = {
"#pref-theme": p.theme, "#pref-density": p.density, "#pref-preview": p.preview, "#pref-scale": p.scale,
"#pref-language": p.language, "#pref-color-preset": p.colorPreset, "#pref-accent": p.accentColor,
"#pref-background": p.backgroundColor, "#pref-panel": p.panelColor, "#pref-text-color": p.textColor,
"#pref-font-family": p.fontFamily, "#pref-font-size": p.fontSize, "#pref-row-height": p.rowHeight,
"#pref-folder-width": p.folderPaneWidth, "#pref-message-width": p.messagePaneWidth, "#pref-radius": p.borderRadius,
"#pref-contrast": p.contrast, "#pref-date-format": p.dateFormat, "#pref-time-format": p.timeFormat,
"#compose-pref-format": p.composeFormat, "#compose-pref-autosave": String(p.composeAutosaveMs), "#compose-pref-priority": p.defaultPriority, "#compose-pref-font": p.composeFontFamily, "#compose-pref-reply-position": p.replyPosition, "#compose-pref-quote-style": p.quoteStyle, "#compose-pref-forward-mode": p.forwardMode,
"#pref-icon-size": p.iconSize, "#pref-toolbar-density": p.toolbarDensity, "#pref-sidebar-height": p.sidebarItemHeight, "#pref-reader-width": String(p.readerMaxWidth), "#pref-composer-width": p.composerWidth,
"#pref-selection-color": p.selectionColor, "#pref-unread-weight": p.unreadFontWeight, "#pref-unread-badge": p.unreadBadgeStyle, "#pref-scrollbar-density": p.scrollbarDensity,
};
Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; });
const checks = { "#pref-avatars": p.showAvatars, "#pref-attachment-icons": p.showAttachmentIcons, "#pref-flags": p.showFlags, "#pref-reduce-motion": p.reduceMotion, "#compose-pref-mdn": p.requestMdnDefault, "#compose-pref-attachment-reminder": p.attachmentReminder, "#compose-pref-spellcheck": p.spellcheckEnabled, "#pref-compact-headers": p.compactHeaders, "#pref-show-borders": p.showBorders, "#pref-monochrome": p.monochrome, "#pref-folder-bg-enabled": Boolean(p.folderBackground), "#pref-message-bg-enabled": Boolean(p.messageBackground), "#pref-reader-bg-enabled": Boolean(p.readerBackground) };
Object.entries(checks).forEach(([selector, value]) => { const node = $(selector); if (node) node.checked = Boolean(value); });
const outputs = { "#pref-accent-value": p.accentColor, "#pref-background-value": p.backgroundColor, "#pref-panel-value": p.panelColor, "#pref-text-color-value": p.textColor, "#pref-font-size-value": `${p.fontSize} px`, "#pref-row-height-value": `${p.rowHeight} px`, "#pref-folder-width-value": `${p.folderPaneWidth} px`, "#pref-message-width-value": `${p.messagePaneWidth} px`, "#pref-radius-value": `${p.borderRadius} px`, "#pref-icon-size-value": `${p.iconSize} px`, "#pref-sidebar-height-value": `${p.sidebarItemHeight} px`, "#pref-composer-width-value": `${p.composerWidth} px`, "#pref-selection-color-value": p.selectionColor, "#pref-unread-weight-value": String(p.unreadFontWeight) };
Object.entries(outputs).forEach(([selector, value]) => { const node = $(selector); if (node) node.textContent = value; });
[["#pref-folder-bg", p.folderBackground || "#101310"], ["#pref-message-bg", p.messageBackground || "#181c19"], ["#pref-reader-bg", p.readerBackground || "#181c19"]].forEach(([selector,value]) => { const node=$(selector); if (node) node.value=value; });
}
function normalizeServerPreferences(data) {
if (!data) return;
state.prefs = {
...state.prefs,
theme: data.theme || "system", density: data.density || "compact", preview: data.preview_pane || "right", composeFormat: data.compose_format || "html",
composeAutosaveMs: Number(data.compose_autosave_ms || 3000), defaultPriority: data.default_priority || "normal", requestMdnDefault: Boolean(data.request_mdn_default), attachmentReminder: data.attachment_reminder !== false, composeFontFamily: data.compose_font_family || "system", replyPosition: data.reply_position || "above", quoteStyle: data.quote_style || "standard", forwardMode: data.forward_mode || "inline", spellcheckEnabled: data.spellcheck_enabled !== false,
scale: data.ui_scale || "normal", language: data.language || "en", colorPreset: data.color_preset || "sage",
accentColor: data.accent_color || "#6f9c86", backgroundColor: data.background_color || "#121513", panelColor: data.panel_color || "#181c19", textColor: data.text_color || "#edf0ec", fontFamily: data.font_family || "system", fontSize: data.font_size || 13,
rowHeight: data.row_height || 58, folderPaneWidth: data.folder_pane_width || 228, messagePaneWidth: data.message_pane_width || 420,
borderRadius: data.border_radius ?? 4, contrast: data.contrast || "normal", showAvatars: data.show_avatars !== false,
showAttachmentIcons: data.show_attachment_icons !== false, showFlags: data.show_flags !== false, reduceMotion: Boolean(data.reduce_motion), dateFormat: data.date_format || "locale", timeFormat: data.time_format || "locale",
iconSize: Number(data.icon_size || 18), toolbarDensity: data.toolbar_density || "normal", sidebarItemHeight: Number(data.sidebar_item_height || 29), readerMaxWidth: Number(data.reader_max_width || 0), composerWidth: Number(data.composer_width || 900), selectionColor: data.selection_color || "#28352e", unreadFontWeight: Number(data.unread_font_weight || 730), unreadBadgeStyle: data.unread_badge_style || "dot", compactHeaders: Boolean(data.compact_headers), showBorders: data.show_borders !== false, scrollbarDensity: data.scrollbar_density || "thin", monochrome: Boolean(data.monochrome), folderBackground: data.folder_background || "", messageBackground: data.message_background || "", readerBackground: data.reader_background || "",
};
}
async function loadPreferences() {
try { normalizeServerPreferences(await request("/settings/display", { method: "GET" })); }
catch (error) { console.warn("Could not load display preferences", error); }
applyPreferences();
await loadTranslations(state.prefs.language);
}
async function loadMailPreferences() {
try {
const prefs = await request("/settings/mailbox", { method:"GET" });
state.mailPrefs = { ...state.mailPrefs, ...prefs };
state.perPage = Number(state.mailPrefs.messages_per_page || 50);
const values = {
"#mail-pref-per-page": String(state.perPage),
"#mail-pref-read-delay": String(state.mailPrefs.mark_read_delay_ms || 0),
"#mail-pref-remote-images": state.mailPrefs.remote_images || "ask",
"#mail-pref-preferred-body": state.mailPrefs.preferred_body || "auto",
"#mail-pref-thread-mode": state.mailPrefs.thread_mode || "threaded",
"#mail-pref-sort": state.mailPrefs.default_sort || "date_desc",
};
Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; });
const messageSort = $("#message-sort");
if (messageSort) messageSort.value = state.mailPrefs.default_sort || "date_desc";
const collapse = $("#mail-pref-collapse-quotes");
if (collapse) collapse.checked = state.mailPrefs.collapse_quotes !== false;
const snippets = $("#mail-pref-show-snippets");
if (snippets) snippets.checked = state.mailPrefs.show_snippets !== false;
const flaggedTop = $("#mail-pref-flagged-top");
if (flaggedTop) flaggedTop.checked = Boolean(state.mailPrefs.flagged_on_top);
const syncKeywords = $("#mail-pref-sync-keywords");
if (syncKeywords) syncKeywords.checked = Boolean(state.mailPrefs.sync_imap_keywords);
} catch (error) { console.warn("Could not load mailbox preferences", error); }
}
async function saveMailPreferences() {
const payload = {
messages_per_page: Number($("#mail-pref-per-page").value),
mark_read_delay_ms: Number($("#mail-pref-read-delay").value),
remote_images: $("#mail-pref-remote-images").value,
preferred_body: $("#mail-pref-preferred-body").value,
thread_mode: $("#mail-pref-thread-mode").value,
collapse_quotes: $("#mail-pref-collapse-quotes").checked,
show_snippets: $("#mail-pref-show-snippets").checked,
default_sort: $("#mail-pref-sort").value,
flagged_on_top: $("#mail-pref-flagged-top").checked,
sync_imap_keywords: $("#mail-pref-sync-keywords").checked,
};
try {
state.mailPrefs = await request("/settings/mailbox", { method:"PUT", body:JSON.stringify(payload) });
state.perPage = Number(state.mailPrefs.messages_per_page || 50);
toast("Mailbox settings saved");
if (state.currentDetail) renderMessageDetail(state.currentDetail);
await loadMessages(true);
} catch (error) { toast(error.message, "error"); }
}
async function reconcileImapLabels() {
const button = $("#mail-pref-reconcile-labels");
const status = $("#mail-pref-reconcile-status");
if (!$("#mail-pref-sync-keywords")?.checked) {
toast("Enable IMAP keyword synchronization first", "warning");
return;
}
if (button) button.disabled = true;
if (status) status.textContent = "Reconciling…";
try {
const result = await request("/tags/reconcile", { method:"POST", body:"{}" });
const added = Number(result.labels_added || 0);
const removed = Number(result.labels_removed || 0);
const folders = Number(result.folders_checked || 0);
const skipped = Number(result.skipped_folders || 0);
if (status) status.textContent = `${folders} folder(s) checked · +${added} / −${removed}${skipped ? ` · ${skipped} skipped` : ""}`;
toast(`Labels reconciled: +${added}, −${removed}`, "success");
await loadTags();
await loadMessages(true);
} catch (error) {
if (status) status.textContent = error.message;
toast(error.message, "error");
} finally {
if (button) button.disabled = false;
}
}
function savePreference(key, value) {
state.prefs[key] = value;
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
applyPreferences();
if (key === "language") loadTranslations(value);
const previousTimer = preferenceTimers.get(key);
if (previousTimer) clearTimeout(previousTimer);
const timer = setTimeout(async () => {
preferenceTimers.delete(key);
const field = preferenceMap[key];
if (!field) return;
const payload = { [field]: state.prefs[key] };
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) payload.color_preset = "custom";
try { await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }); }
catch (error) { toast(error.message, "error"); }
}, 180);
preferenceTimers.set(key, timer);
}
async function resetAppearance() {
const payload = { theme:"system", density:"compact", preview_pane:"right", ui_scale:"normal", color_preset:"sage", accent_color:"#6f9c86", background_color:"#121513", panel_color:"#181c19", text_color:"#edf0ec", font_family:"system", font_size:13, row_height:58, folder_pane_width:228, message_pane_width:420, border_radius:4, contrast:"normal", show_avatars:true, show_attachment_icons:true, show_flags:true, reduce_motion:false, date_format:"locale", time_format:"locale", icon_size:18, toolbar_density:"normal", sidebar_item_height:29, reader_max_width:0, composer_width:900, selection_color:"#28352e", unread_font_weight:730, unread_badge_style:"dot", compact_headers:false, show_borders:true, scrollbar_density:"thin", monochrome:false, folder_background:"", message_background:"", reader_background:"" };
try { normalizeServerPreferences(await request("/settings/display", { method:"PUT", body:JSON.stringify(payload) })); applyPreferences(); toast("Appearance reset"); }
catch (error) { toast(error.message, "error"); }
}
const appearancePresets = {
compact: { density:"compact", scale:"small", fontSize:12, rowHeight:46, folderPaneWidth:196, messagePaneWidth:350, borderRadius:2, preview:"right" },
balanced: { density:"compact", scale:"normal", fontSize:13, rowHeight:56, folderPaneWidth:228, messagePaneWidth:420, borderRadius:4, preview:"right" },
comfortable: { density:"comfortable", scale:"normal", fontSize:14, rowHeight:66, folderPaneWidth:244, messagePaneWidth:460, borderRadius:6, preview:"right" },
reading: { density:"comfortable", scale:"large", fontSize:15, rowHeight:72, folderPaneWidth:236, messagePaneWidth:520, borderRadius:6, preview:"bottom" },
};
async function applyAppearancePreset(name) {
const preset = appearancePresets[name];
if (!preset) return;
state.prefs = { ...state.prefs, ...preset };
applyPreferences();
const payload = {};
for (const [key, value] of Object.entries(preset)) {
const field = preferenceMap[key];
if (field) payload[field] = value;
}
try {
normalizeServerPreferences(await request("/settings/display", { method:"PUT", body:JSON.stringify(payload) }));
applyPreferences();
const label = $(`[data-appearance-preset="${name}"]`)?.dataset.presetLabel || name;
toast(`Applied interface preset: ${label}`, "success");
} catch (error) { toast(error.message, "error"); }
}
function formatDate(raw, long = false) {
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw || "";
const timeOptions = state.prefs.timeFormat === "24h" ? { hour:"2-digit", minute:"2-digit", hour12:false } : state.prefs.timeFormat === "12h" ? { hour:"numeric", minute:"2-digit", hour12:true } : { hour:"2-digit", minute:"2-digit" };
if (long) {
if (state.prefs.dateFormat === "iso") return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,"0")}-${String(date.getDate()).padStart(2,"0")} ${date.toLocaleTimeString([], timeOptions)}`;
const locale = state.prefs.dateFormat === "eu" ? "en-GB" : state.prefs.dateFormat === "us" ? "en-US" : undefined;
return `${date.toLocaleDateString(locale)} ${date.toLocaleTimeString([], timeOptions)}`;
}
const now = new Date();
if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString([], timeOptions);
if (state.prefs.dateFormat === "iso") return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,"0")}-${String(date.getDate()).padStart(2,"0")}`;
if (state.prefs.dateFormat === "eu") return date.toLocaleDateString("en-GB");
if (state.prefs.dateFormat === "us") return date.toLocaleDateString("en-US");
if (date.getFullYear() === now.getFullYear()) return date.toLocaleDateString([], { month:"short", day:"numeric" });
return date.toLocaleDateString([], { year:"numeric", month:"short", day:"numeric" });
}
function formatBytes(value) {
const bytes = Number(value || 0);
if (!Number.isFinite(bytes) || bytes < 1) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
}
function initial(value) {
return (value || "?").trim().charAt(0).toUpperCase() || "?";
}
function showLogin() {
$("#boot").classList.add("hidden");
$("#app-view").classList.add("hidden");
$("#login-view").classList.remove("hidden");
}
function showApp() {
$("#boot").classList.add("hidden");
$("#login-view").classList.add("hidden");
$("#app-view").classList.remove("hidden");
}
async function loadAuthPolicy() {
try {
state.authPolicy = await request("/auth/policy", { method:"GET" });
} catch (_) {
state.authPolicy = { rememberMeEnabled:true, rememberMeDays:30, sessionTimeoutHours:24 };
}
const enabled = state.authPolicy.rememberMeEnabled !== false;
["#login-remember-row", "#add-account-remember-row"].forEach((selector) => $(selector)?.classList.toggle("hidden", !enabled));
["#login-remember", "#add-account-remember"].forEach((selector) => { const input=$(selector); if (input && !enabled) { input.checked=false; input.disabled=true; } });
const days = Number(state.authPolicy.rememberMeDays || 30);
["#login-remember-label", "#add-account-remember-label"].forEach((selector) => { const label=$(selector); if (label) label.textContent = `Keep me signed in (${days} days)`; });
}
async function loadAccounts() {
try {
const data = await request("/auth/accounts", { method: "GET" });
state.accounts = data.accounts || [];
if (!state.accounts.length) {
state.accountId = null;
localStorage.removeItem("rustbox.activeAccount");
state.folders = [];
updateUnreadAppBadge();
showLogin();
return false;
}
if (!state.accountId || !state.accounts.some((account) => account.id === state.accountId)) {
state.accountId = state.accounts[0].id;
localStorage.setItem("rustbox.activeAccount", state.accountId);
}
const account = state.accounts.find((item) => item.id === state.accountId) || state.accounts[0];
$("#account-email").textContent = account.email;
showApp();
return true;
} catch (error) {
console.error(error);
showLogin();
return false;
}
}
function renderAccountSettings() {
const list = $("#account-settings-list");
if (!list) return;
list.replaceChildren();
if (!state.accounts.length) {
list.append(el("div", "empty-list", "No active accounts."));
return;
}
for (const account of state.accounts) {
const row = el("div", `account-settings-row ${account.id === state.accountId ? "active" : ""}`);
const email = el("span", "account-email", account.email || "Account");
const use = el("button", "button quiet", account.id === state.accountId ? "Current" : "Switch");
use.type = "button";
use.disabled = account.id === state.accountId;
use.addEventListener("click", () => switchAccount(account.id));
const remove = el("button", "button quiet danger", "Remove");
remove.type = "button";
remove.addEventListener("click", () => removeBrowserAccount(account.id));
row.append(email, use, remove);
list.append(row);
}
}
async function loadActiveSessions() {
const list = $("#account-session-list");
if (!list) return;
list.replaceChildren(el("div", "empty-list", "Loading sessions…"));
try {
const data = await request("/auth/sessions", { method:"GET" });
const sessions = data.sessions || [];
list.replaceChildren();
if (!sessions.length) {
list.append(el("div", "empty-list", "No active sessions."));
return;
}
for (const session of sessions) {
const row = el("div", `account-settings-row ${session.current ? "active" : ""}`);
const details = el("div", "account-session-details");
const expiry = Number(session.expiresAt || 0);
const expiryText = expiry ? ` · expires ${new Date(expiry * 1000).toLocaleString()}` : "";
const rememberedText = session.remembered ? " · remembered" : "";
details.append(
el("strong", "account-email", session.current ? `${session.email} · current` : session.email),
el("span", "muted", `Created ${new Date(session.createdAt * 1000).toLocaleString()} · active ${new Date(session.lastActive * 1000).toLocaleString()}${expiryText}${rememberedText}`),
);
const client = el("span", "muted", session.clientName || "Unknown device");
if (session.userAgent) client.title = session.userAgent;
details.append(client);
const action = el("button", "button quiet danger", session.current ? "Current" : "Revoke");
action.type = "button";
action.disabled = Boolean(session.current);
action.addEventListener("click", () => revokeActiveSession(session.id));
row.append(details, action);
list.append(row);
}
} catch (error) {
list.replaceChildren(el("div", "empty-list", error.message));
}
}
async function revokeActiveSession(id) {
if (!await appConfirm("Revoke this RustBox session?", { title:"Revoke session", confirmLabel:"Revoke", danger:true })) return;
try {
await request(`/auth/sessions/${encodeURIComponent(id)}`, { method:"DELETE" });
toast("Session revoked");
await loadActiveSessions();
} catch (error) { toast(error.message, "error"); }
}
async function revokeOtherSessions() {
if (!await appConfirm("Revoke every other active RustBox session for this account?", { title:"Revoke other sessions", confirmLabel:"Revoke sessions", danger:true })) return;
try {
const result = await request("/auth/sessions/revoke-others", { method:"POST", body:"{}" });
toast(`Revoked ${result.count || 0} session(s)`);
await loadActiveSessions();
} catch (error) { toast(error.message, "error"); }
}
async function reloadActiveAccount() {
state.folder = "INBOX";
state.selected = null;
state.currentDetail = null;
state.selectedMessages.clear();
state.selectionMode = false;
closeReader();
await loadPreferences();
await loadMailPreferences();
await loadOpenPgp();
await loadIdentities();
await loadFolders(true);
await loadSpecialFolders();
await loadTags();
await loadNotificationPreferences();
renderAccountSettings();
}
async function switchAccount(accountId) {
if (!state.accounts.some((account) => account.id === accountId) || accountId === state.accountId) return;
state.accountId = accountId;
localStorage.setItem("rustbox.activeAccount", accountId);
const account = state.accounts.find((item) => item.id === accountId);
if (account) $("#account-email").textContent = account.email;
try { await reloadActiveAccount(); } catch (error) { toast(error.message, "error"); }
}
async function removeBrowserAccount(accountId) {
const account = state.accounts.find((item) => item.id === accountId);
if (!account || !await appConfirm(`Remove ${account.email} from this browser session?`, { title:"Remove account", confirmLabel:"Remove", danger:true })) return;
try {
await request(`/auth/accounts/${encodeURIComponent(accountId)}`, { method:"DELETE" });
const removingCurrent = accountId === state.accountId;
await loadAccounts();
if (!state.accounts.length) return;
if (removingCurrent) await reloadActiveAccount();
else renderAccountSettings();
} catch (error) { toast(error.message, "error"); }
}
async function addBrowserAccount(event) {
event.preventDefault();
const errorBox = $("#add-account-error");
const submit = $("#add-account-form button[type=submit]");
errorBox.classList.add("hidden");
setBusy(submit, true);
try {
const data = await request("/auth/login", { method:"POST", body:JSON.stringify({ email:$("#add-account-email").value.trim(), password:$("#add-account-password").value, remember:$("#add-account-remember").checked }) });
state.accountId = data.account.id;
localStorage.setItem("rustbox.activeAccount", state.accountId);
$("#add-account-dialog").close();
$("#add-account-form").reset();
await loadAccounts();
await reloadActiveAccount();
disconnectRealtime();
connectRealtime();
toast("Account added");
} catch (error) {
errorBox.textContent = error.message;
errorBox.classList.remove("hidden");
} finally { setBusy(submit, false); }
}
async function login(event) {
event.preventDefault();
const submit = $("#login-form button[type=submit]");
const errorBox = $("#login-error");
errorBox.classList.add("hidden");
setBusy(submit, true);
try {
const data = await request("/auth/login", {
method: "POST",
body: JSON.stringify({
email: $("#login-email").value.trim(),
password: $("#login-password").value,
remember: $("#login-remember").checked,
}),
});
state.accountId = data.account.id;
localStorage.setItem("rustbox.activeAccount", state.accountId);
await loadAccounts();
await loadPreferences();
await loadMailPreferences();
await loadOpenPgp();
await loadIdentities();
await loadFolders(true);
await loadNotificationPreferences();
connectRealtime();
} catch (error) {
errorBox.textContent = error.message;
errorBox.classList.remove("hidden");
} finally {
setBusy(submit, false);
}
}
async function logout() {
disconnectRealtime();
try { await request("/auth/logout", { method: "POST", body: "{}" }); } catch (_) {}
state.accountId = null;
state.accounts = [];
localStorage.removeItem("rustbox.activeAccount");
showLogin();
}
function folderIconName(folder) {
const attrs = (folder.attributes || []).join(" ").toLowerCase();
const name = String(folder.name || "").toLowerCase();
if (name === "inbox" || attrs.includes("\\inbox")) return "inbox";
if (attrs.includes("\\sent") || name.includes("sent")) return "send";
if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text";
if (attrs.includes("\\archive") || name.includes("archive")) return "archive";
if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash";
if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert";
return "folder";
}
function orderedFolders() {
return [...state.folders].sort((a, b) => {
if (a.name.toUpperCase() === "INBOX") return -1;
if (b.name.toUpperCase() === "INBOX") return 1;
return a.name.localeCompare(b.name);
});
}
function folderDepth(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return 0;
return Math.min(8, folder.name.split(delimiter).length - 1);
}
function folderLeafName(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return folder.name;
return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name;
}
function folderHasChildren(folder, folders = state.folders) {
const delimiter = folder.delimiter || "/";
if (!delimiter) return false;
const prefix = `${folder.name}${delimiter}`;
return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix));
}
function folderAncestors(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return [];
const parts = folder.name.split(delimiter);
const result = [];
for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter));
return result;
}
function collapsedFolderStorageKey() {
return `rustbox.collapsedFolders.${state.accountId || "guest"}`;
}
function loadCollapsedFolders() {
try {
const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]");
state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []);
} catch (_) {
state.collapsedFolders = new Set();
}
}
function saveCollapsedFolders() {
localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders]));
}
function folderIsHiddenByCollapsedAncestor(folder) {
return folderAncestors(folder).some((name) => state.collapsedFolders.has(name));
}
function toggleFolderBranch(folder) {
if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name);
else state.collapsedFolders.add(folder.name);
saveCollapsedFolders();
renderFolders();
}
function renderFolders() {
const list = $("#folder-list");
list.replaceChildren();
const folders = orderedFolders();
for (const folder of folders) {
if (folderIsHiddenByCollapsedAncestor(folder)) continue;
const row = el("div", "folder-row");
row.style.setProperty("--folder-depth", String(folderDepth(folder)));
const hasChildren = folderHasChildren(folder, folders);
const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`);
expander.type = "button";
expander.tabIndex = hasChildren ? 0 : -1;
expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`);
expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name)));
if (hasChildren) {
expander.innerHTML = svgIcon("chevron-right");
expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name));
expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); });
}
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
button.type = "button";
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
button.addEventListener("dragover", (event) => {
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
button.classList.add("drop-target");
});
button.addEventListener("dragleave", () => button.classList.remove("drop-target"));
button.addEventListener("drop", async (event) => {
event.preventDefault();
button.classList.remove("drop-target");
try {
const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message"));
if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return;
await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name);
} catch (error) { toast(error.message || "Could not move message", "error"); }
});
button.addEventListener("click", async () => {
saveMessageListState();
state.folder = folder.name;
state.selected = null;
state.selectedMessages.clear();
clearActiveSearchContext();
state.currentDetail = null;
renderFolders();
closeReader();
$("#folder-pane").classList.remove("open");
await loadMessages(true, { restoreState:true });
});
row.append(expander, button);
list.append(row);
}
renderMoveOptions();
}
function updateUnreadAppBadge() {
const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0);
document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox";
try {
if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread);
else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge();
} catch (_) {}
}
async function loadFolders(restoreView = false) {
$("#connection-label").textContent = "Connecting";
try {
const data = await request("/folders", { method: "GET" });
state.folders = data.folders || [];
updateUnreadAppBadge();
loadCollapsedFolders();
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
if (restoreView) {
const remembered = localStorage.getItem(lastFolderStorageKey());
if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered;
}
if (!state.folders.some((folder) => folder.name === state.folder)) {
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
}
renderFolders();
$("#connection-label").textContent = "Connected";
if (restoreView) {
const restoredSearch = readActiveSearchContext();
if (restoredSearch) {
state.searchContext = restoredSearch;
const restoredParams = new URLSearchParams(restoredSearch.params);
$("#search-input").value = restoredParams.get("q") || "";
await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop:restoredSearch.scrollTop });
return;
}
}
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop:state.searchContext.scrollTop });
else await loadMessages(true, { restoreState:restoreView });
} catch (error) {
$("#connection-label").textContent = "Connection error";
if (error.status === 401) return showLogin();
toast(error.message, "error");
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
}
}
function renderMoveOptions() {
const selects = [
["#move-select", "Move to…"],
["#copy-select", "Copy to…"],
["#bulk-move-select", "Move to…"],
["#bulk-copy-select", "Copy to…"],
];
for (const [selector, placeholder] of selects) {
const select = $(selector);
if (!select) continue;
select.replaceChildren(new Option(placeholder, ""));
for (const folder of orderedFolders()) {
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
select.add(new Option(folder.name, folder.name));
}
}
}
function messageKey(message) {
return `${message.folder || state.folder}\u0000${message.uid}`;
}
function activeSearchHighlightTerms() {
if (!state.searchContext?.params) return [];
const params = new URLSearchParams(state.searchContext.params);
const terms = [];
for (const key of ["from", "to", "subject"]) {
const value = (params.get(key) || "").trim();
if (value) terms.push(value);
}
const query = (params.get("q") || "").trim();
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
for (const token of tokens) {
const cleaned = token.replace(/^"|"$/g, "");
const colon = cleaned.indexOf(":");
if (colon > 0) {
const op = cleaned.slice(0, colon).toLowerCase();
const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim();
if (["from", "to", "subject"].includes(op) && value) terms.push(value);
} else if (cleaned.length >= 2) {
terms.push(cleaned);
}
}
return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))]
.sort((a, b) => b.length - a.length)
.slice(0, 12);
}
function renderHighlightedText(node, value, terms) {
const text = String(value || "");
node.replaceChildren();
if (!terms.length || !text) {
node.textContent = text;
return;
}
const lower = text.toLocaleLowerCase();
let offset = 0;
while (offset < text.length) {
let matchIndex = -1;
let matchTerm = "";
for (const term of terms) {
const index = lower.indexOf(term.toLocaleLowerCase(), offset);
if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) {
matchIndex = index;
matchTerm = term;
}
}
if (matchIndex < 0) {
node.append(document.createTextNode(text.slice(offset)));
break;
}
if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex)));
const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length));
node.append(mark);
offset = matchIndex + matchTerm.length;
}
}
function selectedMessages() {
const source = state.searchAllResults || state.messages;
return source.filter((message) => state.selectedMessages.has(messageKey(message)));
}
function renderBulkToolbar() {
const selected = selectedMessages();
const toolbar = $("#bulk-toolbar");
toolbar.classList.toggle("hidden", !state.selectionMode);
$("#bulk-count").textContent = `${selected.length} selected`;
const selectionUniverse = state.searchAllResults || state.messages;
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
$("#bulk-select-all").checked = allLoaded;
$("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded;
toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; });
const selectSearch = $("#bulk-select-search");
if (selectSearch) {
selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults));
selectSearch.disabled = !state.searchContext;
}
const junkButton = $("#bulk-junk");
if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam");
const toggle = $("#select-mode-btn");
if (toggle) {
toggle.classList.toggle("active", state.selectionMode);
toggle.setAttribute("aria-pressed", String(state.selectionMode));
toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages";
toggle.setAttribute("aria-label", toggle.title);
}
}
function setSelectionMode(enabled) {
state.selectionMode = Boolean(enabled);
if (!state.selectionMode) {
state.selectedMessages.clear();
state.searchAllResults = null;
}
renderMessages();
}
function setMessageSelected(message, selected) {
if (!state.selectionMode) state.selectionMode = true;
const key = messageKey(message);
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
renderMessages();
}
function clearBulkSelection() {
state.selectedMessages.clear();
state.searchAllResults = null;
renderMessages();
}
async function selectAllSearchResults() {
if (!state.searchContext) return;
const button = $("#bulk-select-search");
setBusy(button, true, "Selecting…");
try {
const params = new URLSearchParams(state.searchContext.params);
params.set("all", "true");
params.delete("offset");
const data = await request(`/search?${params.toString()}`, { method:"GET" });
state.searchAllResults = data.results || [];
for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message));
state.total = state.searchAllResults.length;
renderMessages();
toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success");
} catch (error) {
toast(error.message, "error");
} finally {
setBusy(button, false);
}
}
function renderTagOptions() {
const select = $("#bulk-tag-select");
if (!select) return;
select.replaceChildren(new Option("Add label…", ""));
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
}
function renderTags() {
const list = $("#tag-list");
if (!list) return;
list.replaceChildren();
for (const tag of state.tags) {
const button = el("button", "tag-item");
button.type = "button";
const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280");
button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0)));
button.addEventListener("click", () => loadTagMessages(tag));
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); });
list.append(button);
}
renderTagOptions();
}
async function loadTags() {
try {
const data = await request("/tags", { method:"GET" });
state.tags = data.tags || [];
renderTags();
} catch (error) { toast(error.message, "error"); }
}
async function loadTagMessages(tag) {
try {
const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method:"GET" });
state.messages = data.messages || [];
state.total = data.total_count || state.messages.length;
state.selectedMessages.clear();
clearActiveSearchContext();
renderMessages();
$("#folder-title").textContent = `Label: ${tag.name}`;
$("#message-count").textContent = `${state.total} results`;
} catch (error) { toast(error.message, "error"); }
}
function openTagDialog(tag = null) {
$("#tag-id").value = tag?.id || "";
$("#tag-name").value = tag?.name || "";
$("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280";
$("#tag-delete").disabled = !tag;
$("#tag-dialog").showModal();
setTimeout(() => $("#tag-name").focus(), 20);
}
async function saveTag(event) {
event.preventDefault();
const id = $("#tag-id").value;
const payload = { name:$("#tag-name").value.trim(), color:$("#tag-color").value };
if (!payload.name) return;
try {
await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method:id ? "PUT" : "POST", body:JSON.stringify(payload) });
$("#tag-dialog").close();
await loadTags();
toast("Label saved");
} catch (error) { toast(error.message, "error"); }
}
async function deleteTag() {
const id = $("#tag-id").value;
if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title:"Delete label", confirmLabel:"Delete", danger:true })) return;
try { await request(`/tags/${encodeURIComponent(id)}`, { method:"DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); }
catch (error) { toast(error.message, "error"); }
}
async function promptTag(label, tags = state.tags) {
if (!tags.length) { toast("Create a label first", "error"); return null; }
const answer = await appSelect(label, tags.map((tag) => ({ value:tag.id, label:tag.name })), { value:tags[0].id, confirmLabel:"Choose label" });
return tags.find((tag) => String(tag.id) === String(answer)) || null;
}
async function addTagToMessage(message, tag) {
const folder = message.folder || state.folder;
await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method:"POST", body:JSON.stringify({ message_uid:message.uid, message_folder:folder }) });
message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id:tag.id, name:tag.name, color:tag.color }];
renderMessages();
await loadTags();
}
async function removeTagFromMessage(message, tag) {
const folder = message.folder || state.folder;
await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method:"DELETE" });
message.tags = (message.tags || []).filter((item) => item.id !== tag.id);
renderMessages();
await loadTags();
}
async function bulkAddTag(tagId) {
const items = selectedMessages();
const tag = state.tags.find((item) => item.id === tagId);
if (!items.length || !tag) return;
try {
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method:"POST", body:JSON.stringify({ messages:items.map((message) => ({ uid:message.uid, folder:message.folder || state.folder })) }) });
for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id:tag.id, name:tag.name, color:tag.color }];
renderMessages();
await loadTags();
toast(`Label “${tag.name}” added`);
} catch (error) { toast(error.message, "error"); }
}
async function loadMessages(reset = false, options = {}) {
const list = $("#message-list");
const restoreState = Boolean(options.restoreState);
const savedState = restoreState ? readMessageListState(state.folder) : null;
const previousScrollTop = reset && !restoreState && list ? list.scrollTop : null;
if (reset) {
state.page = 0;
state.messages = [];
state.selectedMessages.clear();
clearActiveSearchContext();
list.innerHTML = `<div class="empty-list">Loading messages…</div>`;
}
$("#folder-title").textContent = state.folder;
try {
const sort = encodeURIComponent(state.mailPrefs.default_sort || "date_desc");
const maxRestorePage = Math.max(0, Math.ceil(500 / Math.max(20, state.perPage)) - 1);
const targetPage = reset && savedState ? Math.min(savedState.page, maxRestorePage) : state.page;
const pages = reset && targetPage > 0 ? Array.from({ length:targetPage + 1 }, (_, index) => index) : [state.page];
let loaded = [];
let totalCount = 0;
let flaggedOnTop = state.currentFolderFlaggedOnTop;
let lastLoadedPage = state.page;
for (const page of pages) {
const data = await request(`/folders/${encodeURIComponent(state.folder)}/messages?page=${page}&per_page=${state.perPage}&sort=${sort}`, { method: "GET" });
totalCount = data.total_count || 0;
flaggedOnTop = Boolean(data.flagged_on_top);
loaded.push(...(data.messages || []));
lastLoadedPage = page;
if (loaded.length >= totalCount) break;
}
state.page = reset ? lastLoadedPage : state.page;
state.total = totalCount;
state.currentFolderFlaggedOnTop = flaggedOnTop;
state.messages = reset ? loaded : [...state.messages, ...loaded];
$("#connection-label").textContent = "Connected";
renderMessages();
const desiredScroll = savedState?.scrollTop ?? previousScrollTop;
if (Number.isFinite(desiredScroll) && desiredScroll > 0) {
requestAnimationFrame(() => { list.scrollTop = Math.min(desiredScroll, Math.max(0, list.scrollHeight - list.clientHeight)); });
}
saveMessageListState();
} catch (error) {
$("#connection-label").textContent = error.network ? (navigator.onLine ? "Reconnecting" : "Offline") : "Connection error";
if (!state.messages.length) list.innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
$("#message-count").textContent = state.messages.length ? `${state.total} messages · cached view` : "Unavailable";
}
}
async function quickUpdateMessageFlag(message, flag, add, successMessage) {
const folder = message.folder || state.folder;
try {
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method:"PATCH", body:JSON.stringify({ flags:[flag], add }) });
const flags = new Set(Array.isArray(message.flags) ? message.flags : String(message.flags || "").split(/[,\s]+/).filter(Boolean));
add ? flags.add(flag) : flags.delete(flag);
message.flags = [...flags].join(" ");
if (state.currentDetail?.uid === message.uid && state.currentDetail?.folder === folder) {
const detailFlags = new Set(state.currentDetail.flags || []);
add ? detailFlags.add(flag) : detailFlags.delete(flag);
state.currentDetail.flags = [...detailFlags];
renderMessageDetail(state.currentDetail);
}
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) await loadMessages(true);
else renderMessages();
toast(successMessage, "success");
} catch (error) { toast(error.message, "error"); }
}
function renderMessages() {
$("#folder-title").textContent = state.folder;
$("#message-count").textContent = `${state.total} ${state.total === 1 ? "message" : "messages"}`;
const list = $("#message-list");
const highlightTerms = activeSearchHighlightTerms();
list.classList.toggle("selection-mode", state.selectionMode);
list.replaceChildren();
if (!state.messages.length) list.innerHTML = `<div class="empty-list">${state.searchContext ? "No matching messages." : "No messages in this folder."}</div>`;
for (const message of state.messages) {
const folder = message.folder || state.folder;
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : (message.flags || "");
const unread = !flags.includes("\\Seen");
const flagged = flags.includes("\\Flagged");
const checked = state.selectedMessages.has(messageKey(message));
const active = state.selected?.uid === message.uid && state.selected?.folder === folder;
const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""}`);
row.tabIndex = 0;
row.setAttribute("role", "button");
row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}`);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "message-select";
checkbox.checked = checked;
checkbox.disabled = !state.selectionMode;
checkbox.setAttribute("aria-label", "Select message");
checkbox.addEventListener("click", (event) => { event.stopPropagation(); });
checkbox.addEventListener("change", () => setMessageSelected(message, checkbox.checked));
row.innerHTML = `<span class="message-state" aria-hidden="true"></span><div class="message-main"><span class="message-from"></span><time class="message-date">${escapeHtml(formatDate(message.date))}</time></div><div class="message-subject-line"><span class="message-subject"></span><span class="message-snippet"></span></div><span class="message-icons"></span>`;
renderHighlightedText(row.querySelector(".message-from"), message.from_name || message.from_address || "Unknown", highlightTerms);
renderHighlightedText(row.querySelector(".message-subject"), message.subject || "(no subject)", highlightTerms);
renderHighlightedText(row.querySelector(".message-snippet"), state.mailPrefs.show_snippets !== false ? (message.snippet || "") : "", highlightTerms);
const icons = row.querySelector(".message-icons");
if (message.has_attachments) {
const attachmentIcon = el("span", "message-static-icon attachment-indicator");
attachmentIcon.innerHTML = svgIcon("paperclip");
attachmentIcon.title = "Has attachments";
icons.append(attachmentIcon);
}
const quick = el("span", "message-quick-actions");
const readButton = el("button", "message-quick-action");
readButton.type = "button";
readButton.title = unread ? "Mark read" : "Mark unread";
readButton.setAttribute("aria-label", readButton.title);
readButton.innerHTML = svgIcon("mail");
readButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Seen", unread, unread ? "Marked read" : "Marked unread"); });
const flagButton = el("button", `message-quick-action message-flag ${flagged ? "active" : ""}`);
flagButton.type = "button";
flagButton.title = flagged ? "Unflag" : "Flag";
flagButton.setAttribute("aria-label", flagButton.title);
flagButton.innerHTML = svgIcon("star");
flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); });
quick.append(readButton, flagButton);
icons.append(quick);
if (Array.isArray(message.tags) && message.tags.length) {
const wrap = el("span", "message-tags");
for (const tag of message.tags) {
const chip = el("span", "message-tag", tag.name);
chip.style.setProperty("--tag-color", tag.color || "#6b7280");
wrap.append(chip);
}
row.querySelector(".message-subject").after(wrap);
}
row.prepend(checkbox);
row.draggable = true;
row.addEventListener("dragstart", (event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-rustbox-message", JSON.stringify({ folder, uid: message.uid }));
row.classList.add("dragging");
});
row.addEventListener("dragend", () => row.classList.remove("dragging"));
row.addEventListener("click", (event) => {
if (state.selectionMode || event.ctrlKey || event.metaKey) return setMessageSelected(message, !state.selectedMessages.has(messageKey(message)));
openMessage(folder, message.uid);
});
row.addEventListener("keydown", (event) => { if (event.key === "Enter" && !state.selectionMode) openMessage(folder, message.uid); if (event.key === " ") { event.preventDefault(); setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); } });
row.addEventListener("contextmenu", (event) => { event.preventDefault(); openMessageContextMenu(event.clientX, event.clientY, message); });
list.append(row);
}
$("#load-more").classList.toggle("hidden", Boolean(state.searchContext) || state.messages.length >= state.total);
renderBulkToolbar();
}
function navigateMessage(delta) {
if (!state.selected) return;
const index = state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder);
if (index < 0) return;
const next = state.messages[index + delta];
if (!next) return toast(delta > 0 ? "No next loaded message" : "No previous loaded message");
openMessage(next.folder || state.folder, next.uid);
}
function updateMessageNavigation() {
const index = state.selected ? state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder) : -1;
$("#reader-prev").disabled = index <= 0;
$("#reader-next").disabled = index < 0 || index >= state.messages.length - 1;
}
async function senderIsSavedContact(email) {
if (!email) return false;
try {
const data = await request(`/contacts?q=${encodeURIComponent(email)}&limit=10`, { method:"GET" });
const wanted = String(email).trim().toLowerCase();
return (data.contacts || []).some((contact) => {
const addresses = [contact.email, ...(contact.emails || [])].filter(Boolean).map((value) => String(value).trim().toLowerCase());
return addresses.includes(wanted);
});
} catch (_) {
return false;
}
}
async function openMessage(folder, uid) {
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
state.selected = { folder, uid };
renderMessages();
updateMessageNavigation();
$("#reader-empty").classList.add("hidden");
$("#reader-content").classList.remove("hidden");
$("#reader-subject").textContent = "Loading…";
$("#message-body").replaceChildren();
$("#reader").classList.add("mobile-open");
try {
const detail = await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "GET" });
if (state.mailPrefs.remote_images === "contacts" && detail.from_address) {
detail._senderInContacts = await senderIsSavedContact(detail.from_address);
}
state.currentDetail = detail;
renderMessageDetail(detail);
renderMoveOptions();
scheduleMarkRead(detail);
} catch (error) {
$("#reader-subject").textContent = "Unable to open message";
$("#message-body").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
}
}
function scheduleMarkRead(detail) {
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
if (!detail || (detail.flags || []).includes("\\Seen")) return;
const delay = Math.max(0, Number(state.mailPrefs.mark_read_delay_ms || 0));
const run = async () => {
if (!state.currentDetail || state.currentDetail.uid !== detail.uid || state.currentDetail.folder !== detail.folder) return;
try {
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { method:"PATCH", body:JSON.stringify({ flags:["\\Seen"], add:true }) });
detail.flags = [...new Set([...(detail.flags || []), "\\Seen"])];
const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder);
if (item) {
const flags = Array.isArray(item.flags) ? item.flags : String(item.flags || "").split(/[,\s]+/).filter(Boolean);
item.flags = [...new Set([...flags, "\\Seen"])].join(" ");
}
renderMessages();
} catch (_) {}
};
if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay);
}
function renderThread(detail) {
const thread = detail.thread || [];
const section = $("#thread-section");
const list = $("#thread-list");
list.replaceChildren();
const enabled = state.mailPrefs.thread_mode !== "flat";
section.classList.toggle("hidden", !enabled || thread.length <= 1);
if (!enabled || thread.length <= 1) return;
$("#thread-title").textContent = `Thread (${thread.length})`;
list.classList.toggle("hidden", !state.threadExpanded);
$("#thread-toggle").classList.toggle("expanded", state.threadExpanded);
$("#thread-toggle").setAttribute("aria-expanded", String(state.threadExpanded));
for (const item of thread) {
const button = el("button", `thread-item ${item.uid === detail.uid && item.folder === detail.folder ? "current" : ""}`);
button.type = "button";
button.innerHTML = `<span>${escapeHtml(item.from_name || item.from_address || "Unknown")} — ${escapeHtml(item.subject || "(no subject)")}</span><time>${escapeHtml(formatDate(item.date))}</time>`;
button.addEventListener("click", () => openMessage(item.folder, item.uid));
list.append(button);
}
}
function renderMessageDetail(detail) {
$("#reader-subject").textContent = detail.subject || "(no subject)";
$("#reader-from").textContent = detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address;
$("#reader-from").title = detail.from_address ? "Sender actions" : "";
$("#reader-from").tabIndex = detail.from_address ? 0 : -1;
if (detail.from_address) $("#reader-from").setAttribute("role", "button");
else $("#reader-from").removeAttribute("role");
const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", ");
$("#reader-to").textContent = `to ${to || "me"}`;
$("#reader-date").textContent = formatDate(detail.date, true);
$("#reader-avatar").textContent = initial(detail.from_name || detail.from_address);
$("#flag-btn").classList.toggle("active", (detail.flags || []).includes("\\Flagged"));
const spamButton = $("#spam-btn");
const inJunk = isJunkFolder(detail.folder);
spamButton.classList.toggle("active", inJunk);
spamButton.title = inJunk ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam");
spamButton.setAttribute("aria-label", spamButton.title);
renderThread(detail);
renderPgpReaderState(detail);
const attachments = $("#attachment-list");
attachments.replaceChildren();
attachments.classList.toggle("hidden", !(detail.attachments || []).length);
for (const attachment of detail.attachments || []) {
const item = el("div", "attachment-item");
const button = el("button", "attachment-button");
button.type = "button";
button.innerHTML = `${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
button.addEventListener("click", () => downloadAttachment(detail.folder, detail.uid, attachment));
button.addEventListener("contextmenu", (event) => {
event.preventDefault();
const filename = String(attachment.filename || "").toLowerCase();
const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase();
const actions = [
{ label:"Open", action:() => openAttachment(detail.folder, detail.uid, attachment) },
{ label:"Download", action:() => downloadAttachment(detail.folder, detail.uid, attachment) },
];
if (contentType.includes("text/calendar") || filename.endsWith(".ics")) actions.push("separator", { label:"Add to calendar", action:() => importCalendarAttachment(detail.folder, detail.uid, attachment) });
if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) actions.push("separator", { label:"Import contact", action:() => importVcardAttachment(detail.folder, detail.uid, attachment) });
if ((detail.attachments || []).length > 1) actions.push("separator", { label:"Download all as ZIP", action:() => downloadAllAttachmentsZip(detail) });
showContextMenu(event.clientX, event.clientY, actions);
});
item.append(button);
const filename = String(attachment.filename || "").toLowerCase();
const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase();
if (contentType.includes("text/calendar") || filename.endsWith(".ics")) {
const add = el("button", "icon-button small");
add.innerHTML = svgIcon("calendar-days");
add.type = "button";
add.title = "Add to calendar";
add.setAttribute("aria-label", "Add calendar attachment");
add.addEventListener("click", () => importCalendarAttachment(detail.folder, detail.uid, attachment));
item.append(add);
}
if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) {
const add = el("button", "icon-button small");
add.innerHTML = svgIcon("user-round");
add.type = "button";
add.title = "Import contact";
add.setAttribute("aria-label", "Import vCard attachment");
add.addEventListener("click", () => importVcardAttachment(detail.folder, detail.uid, attachment));
item.append(add);
}
attachments.append(item);
}
if ((detail.attachments || []).length > 1) {
const all = el("button", "attachment-download-all");
all.type = "button";
all.innerHTML = `${svgIcon("package")}<span>Download all as ZIP</span>`;
all.addEventListener("click", () => downloadAllAttachmentsZip(detail));
attachments.prepend(all);
}
renderMessageBody(detail);
}
function pgpMessageKind(detail) {
const headers = String(detail?.raw_headers || "").toLowerCase();
const text = String(detail?.text || "").toLowerCase();
const encrypted = (headers.includes("multipart/encrypted") && headers.includes("application/pgp-encrypted")) || text.includes("-----begin pgp message-----");
const signed = (headers.includes("multipart/signed") && headers.includes("application/pgp-signature")) || text.includes("-----begin pgp signed message-----");
return { pgp: encrypted || signed, encrypted, signed };
}
function renderPgpReaderState(detail) {
const bar = $("#pgp-reader-bar");
const status = $("#pgp-reader-status");
const title = $("#pgp-reader-title");
const find = $("#pgp-reader-discover");
const kind = pgpMessageKind(detail);
const configured = state.pgpKeys.length > 0;
bar.classList.toggle("hidden", !kind.pgp || !configured);
find.classList.add("hidden");
if (!kind.pgp || !configured) return;
const processed = detail?._pgpProcessed;
if (!processed) {
title.textContent = kind.encrypted ? "OpenPGP encrypted message" : "OpenPGP signed message";
status.textContent = kind.encrypted ? "Decrypt to read and verify the protected MIME payload." : "Signature has not been verified yet.";
$("#pgp-reader-process").textContent = kind.encrypted ? "Decrypt / verify" : "Verify signature";
return;
}
const signer = processed.signerFingerprint ? ` · ${pgpFingerprintLabel(processed.signerFingerprint)}` : "";
const signerIdentity = processed.signerEmail ? ` · ${processed.signerEmail}` : "";
const trust = processed.signerTrust ? ` · trust: ${processed.signerTrust}` : "";
if (processed.encrypted && processed.verified) { title.textContent="OpenPGP decrypted + verified"; status.textContent=`Encrypted content was decrypted and its signature is valid${signer}${signerIdentity}${trust}`; }
else if (processed.encrypted) { title.textContent="OpenPGP decrypted"; status.textContent=processed.signed ? `Content decrypted; embedded signature could not be verified${signer}${signerIdentity}${trust}` : "Content decrypted successfully"; }
else if (processed.verified) { title.textContent="OpenPGP signature verified"; status.textContent=`Valid signature${signer}${signerIdentity}${trust}`; }
else {
title.textContent="OpenPGP signature not verified";
status.textContent="No matching public key verified this signature.";
if (detail?.from_address && (state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled)) find.classList.remove("hidden");
}
$("#pgp-reader-process").textContent = "Reprocess";
}
async function processCurrentPgpMessage() {
const detail = state.currentDetail;
if (!detail) return;
const kind = pgpMessageKind(detail);
let passphrase = "";
if (kind.encrypted) {
const value = await appPrompt("Decrypt OpenPGP message", { message:"Enter the private-key passphrase if this key is protected. It is used only for this request and is not stored.", type:"password", confirmLabel:"Decrypt" });
if (value === null) return;
passphrase = value;
}
const button = $("#pgp-reader-process"); setBusy(button,true);
try {
const result = await request(`/pgp/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/process`, { method:"POST", body:JSON.stringify({ passphrase }) });
detail._pgpProcessed = result;
renderPgpReaderState(detail);
if (result.text != null || result.html != null) renderMessageBody({ ...detail, text:result.text, html:result.html });
renderPgpProcessedAttachments(result.attachments || []);
if (result.verified && result.signerTrust === "untrusted") toast("OpenPGP signature is cryptographically valid, but this key is marked untrusted", "warning");
else if (result.verified) toast("OpenPGP signature verified", "success");
else if (result.decrypted) toast("OpenPGP message decrypted", "success");
else if (result.signed) toast("OpenPGP signature could not be verified", "warning");
} catch (error) { toast(error.message,"error"); }
finally { setBusy(button,false); }
}
function renderPgpProcessedAttachments(items) {
if (!items.length) return;
const list = $("#attachment-list");
list.replaceChildren(); list.classList.remove("hidden");
for (const attachment of items) {
const item = el("div", "attachment-item");
const button = el("button", "attachment-button"); button.type="button";
button.innerHTML=`${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1,Math.ceil((attachment.size||0)/1024))} KB</span>`;
button.addEventListener("click", () => {
try {
const binary = atob(attachment.dataBase64 || ""); const bytes = new Uint8Array(binary.length); for (let i=0;i<binary.length;i++) bytes[i]=binary.charCodeAt(i);
const blob = new Blob([bytes], { type:attachment.contentType || "application/octet-stream" }); const url=URL.createObjectURL(blob); const a=document.createElement("a"); a.href=url; a.download=attachment.filename || "attachment"; a.click(); setTimeout(()=>URL.revokeObjectURL(url),30000);
} catch (_) { toast("Unable to decode decrypted attachment", "error"); }
});
item.append(button); list.append(item);
}
}
async function discoverCurrentPgpSigner() {
const email = state.currentDetail?.from_address;
if (!email) return;
const key = await discoverPgpEmail(email, false);
if (key) await processCurrentPgpMessage();
}
function renderMessageBody(detail, forceRemote = false) {
const body = $("#message-body");
const remoteBar = $("#remote-image-bar");
body.replaceChildren();
remoteBar.classList.add("hidden");
const preferredBody = state.mailPrefs.preferred_body || "auto";
if (preferredBody === "text" || !detail?.html) {
body.append(el("pre", "", cleanPlainMessageText(detail?.text || "(empty message)")));
return;
}
const hasRemote = /(?:src|background)\s*=\s*["']https?:\/\//i.test(detail.html) || /url\(\s*["']?https?:\/\//i.test(detail.html);
const policy = state.mailPrefs.remote_images || "ask";
const allowRemote = forceRemote || policy === "always" || (policy === "contacts" && detail?._senderInContacts === true);
if (hasRemote && !allowRemote && (policy === "ask" || policy === "contacts")) remoteBar.classList.remove("hidden");
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "");
iframe.setAttribute("referrerpolicy", "no-referrer");
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 quoteStyle = state.mailPrefs.collapse_quotes !== false ? "blockquote{max-height:16em;overflow:auto;border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}" : "blockquote{border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}";
const safeHtml = sanitizeMessageHtml(detail.html);
iframe.srcdoc = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>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}</style></head><body>${safeHtml}</body></html>`;
body.append(iframe);
}
async function downloadAttachment(folder, uid, attachment) {
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials: "include", headers });
if (!response.ok) throw new Error(`Download failed (${response.status})`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = attachment.filename || "attachment";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 30000);
toast(`Downloading ${attachment.filename || "attachment"}`, "info");
} catch (error) { toast(error.message, "error"); }
}
async function fetchAttachmentBlob(folder, uid, attachment) {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials:"include", headers });
if (!response.ok) throw new Error(`Attachment request failed (${response.status})`);
return response.blob();
}
function attachmentMime(attachment, blob = null) {
return String(blob?.type || attachment?.content_type || attachment?.contentType || "application/octet-stream").toLowerCase();
}
async function openAttachment(folder, uid, attachment) {
try {
const blob = await fetchAttachmentBlob(folder, uid, attachment);
const mime = attachmentMime(attachment, blob);
const safe = mime.startsWith("image/") || mime === "application/pdf" || mime.startsWith("text/");
if (!safe) return toast("Preview is not available for this attachment type", "warning");
if (blob.size > 30 * 1024 * 1024) return toast("Attachment is too large for preview", "warning");
const dialog = el("dialog", "dialog attachment-preview-dialog");
const card = el("div", "dialog-card attachment-preview-card");
const header = el("header", "dialog-header");
header.append(el("strong", "", attachment.filename || "Attachment preview"));
const close = el("button", "icon-button");
close.type = "button";
close.setAttribute("aria-label", "Close preview");
close.innerHTML = svgIcon("x");
header.append(close);
const body = el("div", "attachment-preview-body");
let objectUrl = null;
if (mime.startsWith("image/")) {
objectUrl = URL.createObjectURL(blob);
const image = document.createElement("img");
image.src = objectUrl;
image.alt = attachment.filename || "Attachment image";
body.append(image);
} else if (mime === "application/pdf") {
objectUrl = URL.createObjectURL(blob);
const frame = document.createElement("iframe");
frame.src = objectUrl;
frame.title = attachment.filename || "PDF preview";
frame.referrerPolicy = "no-referrer";
body.append(frame);
} else {
if (blob.size > 5 * 1024 * 1024) return toast("Text attachment is too large for inline preview", "warning");
const pre = document.createElement("pre");
pre.textContent = await blob.text();
body.append(pre);
}
const footer = el("footer", "dialog-actions");
const download = el("button", "button quiet", "Download");
download.type = "button";
download.addEventListener("click", () => downloadAttachment(folder, uid, attachment));
const done = el("button", "button primary", "Close");
done.type = "button";
const dismiss = () => { if (dialog.open) dialog.close(); };
close.addEventListener("click", dismiss);
done.addEventListener("click", dismiss);
footer.append(download, done);
card.append(header, body, footer);
dialog.append(card);
document.body.append(dialog);
dialog.addEventListener("close", () => { if (objectUrl) URL.revokeObjectURL(objectUrl); dialog.remove(); }, { once:true });
dialog.showModal();
} catch (error) { toast(error.message, "error"); }
}
function safeZipFilename(value, index) {
const cleaned = String(value || `attachment-${index + 1}`)
.replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_")
.replace(/^\.+$/, "attachment")
.slice(0, 180);
return cleaned || `attachment-${index + 1}`;
}
let crcTable = null;
function crc32(bytes) {
if (!crcTable) {
crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
crcTable[n] = c >>> 0;
}
}
let crc = 0xffffffff;
for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function zipDosDateTime(date = new Date()) {
const year = Math.max(1980, date.getFullYear());
const dosTime = ((date.getHours() & 31) << 11) | ((date.getMinutes() & 63) << 5) | ((Math.floor(date.getSeconds() / 2)) & 31);
const dosDate = (((year - 1980) & 127) << 9) | (((date.getMonth() + 1) & 15) << 5) | (date.getDate() & 31);
return { dosTime, dosDate };
}
function zipRecord(size, fill) {
const bytes = new Uint8Array(size);
const view = new DataView(bytes.buffer);
fill(view);
return bytes;
}
function concatBytes(parts) {
const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) { out.set(part, offset); offset += part.byteLength; }
return out;
}
async function downloadAllAttachmentsZip(detail = state.currentDetail) {
const attachments = detail?.attachments || [];
if (!attachments.length) return toast("This message has no attachments", "warning");
if (attachments.length > 100) return toast("Too many attachments for a browser ZIP", "warning");
try {
toast(`Preparing ZIP with ${attachments.length} attachment${attachments.length === 1 ? "" : "s"}…`, "info");
const encoder = new TextEncoder();
const localParts = [];
const centralParts = [];
let localOffset = 0;
let totalPayload = 0;
for (let index = 0; index < attachments.length; index += 1) {
const attachment = attachments[index];
const blob = await fetchAttachmentBlob(detail.folder, detail.uid, attachment);
totalPayload += blob.size;
if (totalPayload > 120 * 1024 * 1024) throw new Error("Attachments exceed the 120 MB browser ZIP limit");
const data = new Uint8Array(await blob.arrayBuffer());
const name = encoder.encode(safeZipFilename(attachment.filename, index));
const checksum = crc32(data);
const { dosTime, dosDate } = zipDosDateTime();
const local = zipRecord(30, (view) => {
view.setUint32(0, 0x04034b50, true); view.setUint16(4, 20, true); view.setUint16(6, 0x0800, true); view.setUint16(8, 0, true);
view.setUint16(10, dosTime, true); view.setUint16(12, dosDate, true); view.setUint32(14, checksum, true);
view.setUint32(18, data.length, true); view.setUint32(22, data.length, true); view.setUint16(26, name.length, true); view.setUint16(28, 0, true);
});
const central = zipRecord(46, (view) => {
view.setUint32(0, 0x02014b50, true); view.setUint16(4, 20, true); view.setUint16(6, 20, true); view.setUint16(8, 0x0800, true); view.setUint16(10, 0, true);
view.setUint16(12, dosTime, true); view.setUint16(14, dosDate, true); view.setUint32(16, checksum, true);
view.setUint32(20, data.length, true); view.setUint32(24, data.length, true); view.setUint16(28, name.length, true); view.setUint16(30, 0, true);
view.setUint16(32, 0, true); view.setUint16(34, 0, true); view.setUint16(36, 0, true); view.setUint32(38, 0, true); view.setUint32(42, localOffset, true);
});
localParts.push(local, name, data);
centralParts.push(central, name);
localOffset += local.byteLength + name.byteLength + data.byteLength;
}
const centralDirectory = concatBytes(centralParts);
const eocd = zipRecord(22, (view) => {
view.setUint32(0, 0x06054b50, true); view.setUint16(4, 0, true); view.setUint16(6, 0, true);
view.setUint16(8, attachments.length, true); view.setUint16(10, attachments.length, true);
view.setUint32(12, centralDirectory.byteLength, true); view.setUint32(16, localOffset, true); view.setUint16(20, 0, true);
});
const zip = new Blob([...localParts, centralDirectory, eocd], { type:"application/zip" });
const url = URL.createObjectURL(zip);
const link = document.createElement("a");
const stem = String(detail.subject || "attachments").replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_").slice(0, 80) || "attachments";
link.href = url; link.download = `${stem}-attachments.zip`; document.body.append(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
toast(`Downloaded ${attachments.length} attachment${attachments.length === 1 ? "" : "s"} as ZIP`, "success");
} catch (error) { toast(error.message, "error"); }
}
async function importCalendarAttachment(folder, uid, attachment) {
try {
const blob = await fetchAttachmentBlob(folder, uid, attachment);
if (blob.size > 10 * 1024 * 1024) throw new Error("ICS attachment is too large");
const text = await blob.text();
const headers = new Headers({ "Content-Type":"text/calendar; charset=utf-8", "X-Requested-With":"XMLHttpRequest" });
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath("/calendar/events/import-ics"), { method:"POST", credentials:"include", headers, body:text });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data?.error?.message || `Calendar import failed (${response.status})`);
toast(`Calendar: ${data.created || 0} event(s) imported`);
} catch (error) { toast(error.message, "error"); }
}
async function importVcardAttachment(folder, uid, attachment) {
try {
const blob = await fetchAttachmentBlob(folder, uid, attachment);
if (blob.size > 10 * 1024 * 1024) throw new Error("vCard attachment is too large");
const form = new FormData();
form.append("file", blob, attachment.filename || "contact.vcf");
const result = await request("/contacts/import", { method:"POST", body:form });
toast(`Contacts: ${result.created || 0} created, ${result.updated || 0} updated`);
} catch (error) { toast(error.message, "error"); }
}
function closeReader() {
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
$("#reader").classList.remove("mobile-open");
$("#reader-empty").classList.remove("hidden");
$("#reader-content").classList.add("hidden");
}
function parseRecipients(raw) {
return raw.split(",").map((value) => value.trim()).filter(Boolean);
}
function recipientToken(input) {
const value = input.value || "";
const comma = value.lastIndexOf(",");
return { prefix: comma >= 0 ? value.slice(0, comma + 1) : "", query: value.slice(comma + 1).trim() };
}
function bindRecipientAutocomplete(selector) {
const input = $(selector);
if (!input) return;
const host = input.closest("label") || input.parentElement;
host.classList.add("recipient-field");
const menu = el("div", "recipient-suggestions hidden");
menu.setAttribute("role", "listbox");
host.append(menu);
let timer = null;
let activeIndex = -1;
let suggestions = [];
const hide = () => { menu.classList.add("hidden"); menu.replaceChildren(); activeIndex = -1; suggestions = []; };
const select = (item) => {
const { prefix } = recipientToken(input);
const formatted = item.name?.trim() ? `${item.name.trim()} <${item.email}>` : item.email;
input.value = `${prefix}${prefix ? " " : ""}${formatted}, `;
hide();
input.focus();
scheduleDraftSave();
input.dispatchEvent(new Event("input", { bubbles:true }));
};
const render = () => {
menu.replaceChildren();
if (!suggestions.length) { hide(); return; }
suggestions.forEach((item, index) => {
const button = el("button", `recipient-suggestion ${index === activeIndex ? "active" : ""}`);
button.type = "button";
button.setAttribute("role", "option");
button.setAttribute("aria-selected", String(index === activeIndex));
const primary = el("strong", "", item.name?.trim() || item.email);
const secondary = el("span", "", item.name?.trim() ? item.email : (item.source || ""));
button.append(primary, secondary);
button.addEventListener("mousedown", (event) => event.preventDefault());
button.addEventListener("click", () => select(item));
menu.append(button);
});
menu.classList.remove("hidden");
};
const load = async () => {
const { query } = recipientToken(input);
if (query.length < 2 || query.includes(">")) { hide(); return; }
try {
const data = await request(`/contacts/autocomplete?q=${encodeURIComponent(query)}&limit=8`, { method:"GET" });
suggestions = data.suggestions || [];
activeIndex = -1;
render();
} catch (_) { hide(); }
};
input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 140); });
input.addEventListener("blur", () => setTimeout(hide, 120));
input.addEventListener("keydown", (event) => {
if (menu.classList.contains("hidden") || !suggestions.length) return;
if (event.key === "ArrowDown") { event.preventDefault(); activeIndex = (activeIndex + 1) % suggestions.length; render(); }
else if (event.key === "ArrowUp") { event.preventDefault(); activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length; render(); }
else if (event.key === "Enter" && activeIndex >= 0) { event.preventDefault(); select(suggestions[activeIndex]); }
else if (event.key === "Escape") { event.preventDefault(); hide(); }
});
}
function quoteTextAsHtml(text) {
return escapeHtml(cleanPlainMessageText(text || "")).replaceAll("\n", "<br>");
}
function ownAddressSet() {
return new Set(state.accounts.map((account) => String(account.email || "").trim().toLowerCase()).filter(Boolean));
}
function ensureDraftId() {
if (!state.composeDraftId) state.composeDraftId = crypto.randomUUID ? crypto.randomUUID() : `draft-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return state.composeDraftId;
}
function renderComposeAttachments() {
const box = $("#compose-attachments");
box.replaceChildren();
const visibleAttachments = state.composeAttachments.filter((attachment) => !state.inlineAttachmentIds.has(String(attachment.id)));
box.classList.toggle("hidden", !visibleAttachments.length);
for (const attachment of visibleAttachments) {
const item = el("div", "compose-attachment");
item.innerHTML = `${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename)} · ${Math.max(1, Math.ceil((attachment.size || 0)/1024))} KB</span>`;
const remove = el("button", "");
remove.innerHTML = svgIcon("x");
remove.type = "button";
remove.setAttribute("aria-label", `Remove ${attachment.filename}`);
remove.addEventListener("click", () => deleteComposeAttachment(attachment.id));
item.append(remove);
box.append(item);
}
if ($("#compose-pgp-mode")) updateComposePgpUi();
}
function composeRecoveryKey() {
return `rustbox.composeRecovery.${state.accountId || "default"}`;
}
function draftHasContent() {
return Boolean(
$("#compose-to")?.value.trim() || $("#compose-cc")?.value.trim() || $("#compose-bcc")?.value.trim() ||
$("#compose-subject")?.value.trim() || $("#compose-editor")?.innerText.trim() || state.composeAttachments.length
);
}
function composeAttachmentContentUrl(draftId, attachmentId) {
const query = state.accountId ? `?account_id=${encodeURIComponent(state.accountId)}` : "";
return `${apiPath(`/drafts/${encodeURIComponent(draftId)}/attachments/${encodeURIComponent(attachmentId)}/content`)}${query}`;
}
function markdownInlineToSafeHtml(value) {
const source = String(value ?? "");
const code = [];
let html = escapeHtml(source).replace(/`([^`\n]+)`/g, (_, body) => {
const token = `@@RUSTBOX_CODE_${code.length}@@`;
code.push(`<code>${body}</code>`);
return token;
});
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label, rawUrl) => {
let url = rawUrl.replaceAll("&amp;", "&");
try {
const parsed = new URL(url, window.location.origin);
if (!["http:", "https:", "mailto:"].includes(parsed.protocol)) return label;
url = parsed.protocol === "mailto:" ? url : parsed.href;
} catch (_) { return label; }
return `<a href="${escapeHtml(url)}" rel="noopener noreferrer">${label}</a>`;
});
html = html
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_\n]+)__/g, "<strong>$1</strong>")
.replace(/~~([^~\n]+)~~/g, "<del>$1</del>")
.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/g, "$1<em>$2</em>")
.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?:;])/g, "$1<em>$2</em>");
code.forEach((snippet, index) => { html = html.replace(`@@RUSTBOX_CODE_${index}@@`, snippet); });
return html;
}
function markdownToSafeHtml(markdown) {
const lines = String(markdown ?? "").replace(/\r\n?/g, "\n").split("\n");
const out = [];
let paragraph = [];
let listType = null;
let inCode = false;
let codeLines = [];
const flushParagraph = () => {
if (!paragraph.length) return;
out.push(`<p>${paragraph.map(markdownInlineToSafeHtml).join("<br>")}</p>`);
paragraph = [];
};
const closeList = () => {
if (!listType) return;
out.push(`</${listType}>`);
listType = null;
};
for (const line of lines) {
if (/^\s*```/.test(line)) {
flushParagraph(); closeList();
if (inCode) { out.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`); codeLines = []; inCode = false; }
else { inCode = true; }
continue;
}
if (inCode) { codeLines.push(line); continue; }
if (!line.trim()) { flushParagraph(); closeList(); continue; }
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) { flushParagraph(); closeList(); const level = heading[1].length; out.push(`<h${level}>${markdownInlineToSafeHtml(heading[2])}</h${level}>`); continue; }
if (/^\s*(?:---+|\*\*\*+)\s*$/.test(line)) { flushParagraph(); closeList(); out.push("<hr>"); continue; }
const quote = line.match(/^>\s?(.*)$/);
if (quote) { flushParagraph(); closeList(); out.push(`<blockquote>${markdownInlineToSafeHtml(quote[1])}</blockquote>`); continue; }
const unordered = line.match(/^\s*[-+*]\s+(.+)$/);
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
if (unordered || ordered) {
flushParagraph();
const wanted = unordered ? "ul" : "ol";
if (listType !== wanted) { closeList(); listType = wanted; out.push(`<${wanted}>`); }
out.push(`<li>${markdownInlineToSafeHtml((unordered || ordered)[1])}</li>`);
continue;
}
paragraph.push(line);
}
if (inCode) out.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
flushParagraph(); closeList();
return out.join("\n");
}
function composeFontCss(value = state.prefs.composeFontFamily) {
return ({
system: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
humanist: '"Trebuchet MS","Segoe UI",sans-serif',
serif: 'Georgia,"Times New Roman",serif',
mono: 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace',
})[value] || '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif';
}
function wrapComposeHtmlFont(html) {
if (!html) return null;
const family = composeFontCss().replace(/&/g, "&amp;").replace(/"/g, "&quot;");
return `<div class="rustbox-compose-font" style="font-family:${family}">${html}</div>`;
}
function serializeComposeHtml(editor = $("#compose-editor")) {
if (!editor) return null;
const mode = $("#compose-mode").value;
if (mode === "markdown") {
const markdown = editor.innerText.trim();
return markdown ? wrapComposeHtmlFont(markdownToSafeHtml(markdown)) : null;
}
if (mode !== "html") return null;
const clone = editor.cloneNode(true);
clone.querySelectorAll(".rustbox-compose-font").forEach((wrapper) => wrapper.replaceWith(...wrapper.childNodes));
clone.querySelectorAll("img[data-rustbox-cid]").forEach((image) => {
const cid = image.dataset.rustboxCid || "";
if (cid) image.setAttribute("src", `cid:${cid}`);
image.removeAttribute("data-rustbox-cid");
image.removeAttribute("data-rustbox-align");
image.classList.remove("compose-inline-image-selected");
if (!image.className) image.removeAttribute("class");
});
const html = clone.innerHTML.trim();
return html ? wrapComposeHtmlFont(html) : null;
}
function hydrateComposeHtml(html, attachments = state.composeAttachments, draftId = state.composeDraftId) {
const host = document.createElement("div");
host.innerHTML = sanitizeMessageHtml(html || "");
if (host.children.length === 1 && host.firstElementChild?.classList.contains("rustbox-compose-font")) {
host.innerHTML = host.firstElementChild.innerHTML;
}
if (!draftId) return host.innerHTML;
const ids = new Set((attachments || []).map((attachment) => String(attachment.id || "")).filter(Boolean));
host.querySelectorAll("img[src]").forEach((image) => {
const src = image.getAttribute("src") || "";
if (!src.toLowerCase().startsWith("cid:")) return;
const cid = src.slice(4).replace(/^<|>$/g, "");
if (!ids.has(cid)) return;
state.inlineAttachmentIds.add(cid);
image.dataset.rustboxCid = cid;
image.setAttribute("src", composeAttachmentContentUrl(draftId, cid));
});
return host.innerHTML;
}
function composeDraftPayload() {
const editor = $("#compose-editor");
return {
id: ensureDraftId(),
to: $("#compose-to").value,
cc: $("#compose-cc").value,
bcc: $("#compose-bcc").value,
subject: $("#compose-subject").value,
text_body: editor.innerText,
html_body: serializeComposeHtml(editor),
in_reply_to: state.composeReplyMeta?.inReplyTo || null,
references: state.composeReplyMeta?.references || null,
request_mdn: Boolean($("#compose-mdn")?.checked),
priority: $("#compose-priority")?.value || "normal",
compose_format: $("#compose-mode")?.value || "html",
pgp_mode: $("#compose-pgp-mode")?.value || "none",
pgp_inline: Boolean($("#compose-pgp-inline")?.checked),
pgp_attach_public_key: Boolean($("#compose-pgp-attach-key")?.checked),
pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys")?.checked),
};
}
function readLocalDraftSnapshots() {
try {
const raw = localStorage.getItem(composeRecoveryKey());
if (!raw) return [];
const parsed = JSON.parse(raw);
const drafts = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.drafts) ? parsed.drafts : parsed?.id ? [parsed] : [];
return drafts.filter((draft) => draft && draft.id).sort((a, b) => String(b.saved_at || "").localeCompare(String(a.saved_at || "")));
} catch (_) { return []; }
}
function writeLocalDraftSnapshots(drafts) {
const kept = drafts.filter((draft) => draft?.id).slice(0, 10);
if (!kept.length) localStorage.removeItem(composeRecoveryKey());
else localStorage.setItem(composeRecoveryKey(), JSON.stringify({ drafts: kept }));
}
function storeLocalDraftSnapshot() {
if (!draftHasContent()) return;
try {
const payload = composeDraftPayload();
const snapshot = {
...payload,
compose_format: $("#compose-mode").value,
attachments: state.composeAttachments,
saved_at: new Date().toISOString(),
};
const drafts = readLocalDraftSnapshots().filter((draft) => draft.id !== payload.id);
drafts.unshift(snapshot);
writeLocalDraftSnapshots(drafts);
} catch (_) {}
}
function clearLocalDraftSnapshot(draftId = state.composeDraftId) {
if (!draftId) return;
writeLocalDraftSnapshots(readLocalDraftSnapshots().filter((draft) => draft.id !== draftId));
}
async function saveCurrentDraft(silent = false, force = false, syncImap = false) {
if (!force && !$("#compose-dialog").open) return false;
if (!force && !state.composeDirty) return true;
if (!draftHasContent()) { state.composeDirty = false; clearLocalDraftSnapshot(); return true; }
const payload = composeDraftPayload();
payload.sync_imap = Boolean(syncImap);
try {
await request("/drafts", { method:"POST", body:JSON.stringify(payload) });
state.composeDirty = false;
if (syncImap) state.composeNeedsRemoteSync = false;
clearLocalDraftSnapshot();
$("#compose-status").textContent = `Draft saved ${new Date().toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"})}`;
if (!silent) toast("Draft saved", "success");
return true;
} catch (error) {
storeLocalDraftSnapshot();
if (!silent) toast(error.message, "error");
return false;
}
}
function scheduleDraftSave() {
state.composeDirty = true;
state.composeNeedsRemoteSync = true;
storeLocalDraftSnapshot();
clearTimeout(state.draftSaveTimer);
state.draftSaveTimer = setTimeout(() => saveCurrentDraft(true), Math.max(1000, Number(state.prefs.composeAutosaveMs || 3000)));
}
function setComposeUploadProgress(visible, percent = 0, label = "Uploading…") {
const box = $("#compose-upload-progress");
const bar = $("#compose-upload-bar");
const text = $("#compose-upload-label");
if (!box || !bar || !text) return;
const value = Math.max(0, Math.min(100, Number(percent) || 0));
box.classList.toggle("hidden", !visible);
bar.style.width = `${value}%`;
text.textContent = label;
}
async function uploadComposeFiles(files, options = {}) {
const list = [...(files || [])];
const announce = options.announce !== false;
if (!list.length) return [];
if (state.composeUploading) { if (announce) toast("Another upload is already in progress", "warning"); return []; }
state.composeUploading = true;
setComposeUploadProgress(true, 0, `Uploading ${list.length} file${list.length === 1 ? "" : "s"}…`);
await saveCurrentDraft(true, true);
const id = ensureDraftId();
const form = new FormData();
list.forEach((file) => form.append("file", file, file.name));
try {
const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => {
setComposeUploadProgress(true, percent, `Uploading ${list.length} file${list.length === 1 ? "" : "s"}… ${percent}%`);
});
const added = data.attachments || [];
state.composeAttachments.push(...added);
renderComposeAttachments();
state.composeDirty = true;
state.composeNeedsRemoteSync = true;
storeLocalDraftSnapshot();
setComposeUploadProgress(true, 100, "Upload complete");
if (announce) toast(`${added.length} attachment${added.length === 1 ? "" : "s"} added`, "success");
setTimeout(() => setComposeUploadProgress(false), 450);
return added;
} catch (error) {
setComposeUploadProgress(false);
toast(error.message, "error");
return [];
} finally {
state.composeUploading = false;
}
}
function previewMarkdown() {
const editor = $("#compose-editor");
const preview = $("#markdown-preview-content");
if (!editor || !preview) return;
preview.innerHTML = markdownToSafeHtml(editor.innerText);
$("#markdown-preview-dialog").showModal();
}
function setComposeMode(mode, persist = false) {
const value = ["html", "text", "markdown"].includes(mode) ? mode : "html";
const select = $("#compose-mode");
if (select) select.value = value;
const editor = $("#compose-editor");
editor.dataset.mode = value;
editor.classList.toggle("plain-mode", value !== "html");
$$(".editor-toolbar [data-command], #editor-link, #editor-image, #editor-font, #editor-size, #editor-color").forEach((control) => { control.disabled = value !== "html"; });
const imageEdit = $("#editor-image-edit");
if (imageEdit) imageEdit.disabled = value !== "html" || !state.composeSelectedImage;
$("#compose-markdown-preview")?.classList.toggle("hidden", value !== "markdown");
if (persist) savePreference("composeFormat", value);
}
function captureEditorRange(editor = $("#compose-editor")) {
const selection = window.getSelection();
if (!editor || !selection || !selection.rangeCount) return null;
const range = selection.getRangeAt(0);
const container = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement;
if (container !== editor && !editor.contains(container)) return null;
return range.cloneRange();
}
function restoreEditorRange(range, editor = $("#compose-editor")) {
editor.focus();
if (!range) return;
try {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
} catch (_) {}
}
function setSelectedInlineImage(image) {
if (state.composeSelectedImage && state.composeSelectedImage !== image) {
state.composeSelectedImage.classList?.remove("compose-inline-image-selected");
}
state.composeSelectedImage = image && image.isConnected ? image : null;
state.composeSelectedImage?.classList.add("compose-inline-image-selected");
const edit = $("#editor-image-edit");
if (edit) edit.disabled = $("#compose-mode")?.value !== "html" || !state.composeSelectedImage;
}
function inlineImageAlignment(image) {
if (!image) return "inline";
if (image.dataset.rustboxAlign) return image.dataset.rustboxAlign;
const left = image.style.marginLeft;
const right = image.style.marginRight;
if (left === "auto" && right === "auto") return "center";
if (left === "auto" && right === "0px") return "right";
if (left === "0px" && right === "auto") return "left";
return "inline";
}
function applyInlineImageDisplay(image, widthPercent, alignment) {
if (!image) return;
const width = Math.max(10, Math.min(100, Number(widthPercent) || 100));
image.style.width = `${width}%`;
image.style.maxWidth = "100%";
image.style.height = "auto";
image.style.float = "none";
image.style.marginTop = "0";
image.style.marginBottom = "0";
image.dataset.rustboxAlign = alignment;
if (alignment === "center") {
image.style.display = "block";
image.style.marginLeft = "auto";
image.style.marginRight = "auto";
} else if (alignment === "right") {
image.style.display = "block";
image.style.marginLeft = "auto";
image.style.marginRight = "0px";
} else if (alignment === "left") {
image.style.display = "block";
image.style.marginLeft = "0px";
image.style.marginRight = "auto";
} else {
image.style.display = "inline-block";
image.style.marginLeft = "0";
image.style.marginRight = "0";
}
}
function imageCropValues() {
const value = (id) => Math.max(0, Math.min(45, Number($(id)?.value) || 0));
return { top:value("#image-crop-top"), right:value("#image-crop-right"), bottom:value("#image-crop-bottom"), left:value("#image-crop-left") };
}
function updateImageEditOutputs() {
const width = Number($("#image-edit-width")?.value || 100);
const widthOut = $("#image-edit-width-value");
if (widthOut) widthOut.textContent = `${width}%`;
for (const side of ["top", "right", "bottom", "left"]) {
const input = $(`#image-crop-${side}`);
const output = $(`#image-crop-${side}-value`);
if (input && output) output.textContent = `${input.value}%`;
}
}
function drawInlineImageCropPreview() {
const bitmap = state.imageEditorBitmap;
const canvas = $("#image-edit-preview");
if (!bitmap || !canvas) return;
const crop = imageCropValues();
const sx = Math.round(bitmap.width * crop.left / 100);
const sy = Math.round(bitmap.height * crop.top / 100);
const sw = Math.max(1, Math.round(bitmap.width * (100 - crop.left - crop.right) / 100));
const sh = Math.max(1, Math.round(bitmap.height * (100 - crop.top - crop.bottom) / 100));
const scale = Math.min(1, 760 / sw, 560 / sh);
canvas.width = Math.max(1, Math.round(sw * scale));
canvas.height = Math.max(1, Math.round(sh * scale));
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
updateImageEditOutputs();
}
function resetImageCropControls() {
for (const side of ["top", "right", "bottom", "left"]) {
const input = $(`#image-crop-${side}`);
if (input) input.value = "0";
}
drawInlineImageCropPreview();
}
async function decodeInlineImageBlob(blob) {
if (typeof createImageBitmap === "function") return createImageBitmap(blob);
const url = URL.createObjectURL(blob);
try {
const image = new Image();
image.decoding = "async";
await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error("Could not decode image")); image.src = url; });
return image;
} finally {
URL.revokeObjectURL(url);
}
}
async function openInlineImageEditor(image = state.composeSelectedImage) {
if (!image?.dataset?.rustboxCid) return toast("Select an inline image first", "info");
setSelectedInlineImage(image);
const cid = image.dataset.rustboxCid;
const attachment = state.composeAttachments.find((item) => String(item.id) === String(cid));
if (!attachment) return toast("The inline image attachment is no longer available", "error");
try {
state.imageEditorBitmap?.close?.();
const response = await fetch(composeAttachmentContentUrl(ensureDraftId(), cid), { credentials:"same-origin", cache:"no-store" });
if (!response.ok) throw new Error(`Could not load image (${response.status})`);
state.imageEditorBlob = await response.blob();
state.imageEditorBitmap = await decodeInlineImageBlob(state.imageEditorBlob);
const editorWidth = Math.max(1, $("#compose-editor")?.clientWidth || image.parentElement?.clientWidth || image.clientWidth || 1);
const stylePercent = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : null;
const measuredPercent = Math.round((image.getBoundingClientRect().width / editorWidth) * 100 / 5) * 5;
$("#image-edit-width").value = String(Math.max(10, Math.min(100, stylePercent || measuredPercent || 100)));
$("#image-edit-align").value = inlineImageAlignment(image);
resetImageCropControls();
const cropControls = $("#image-crop-controls");
const isGif = String(attachment.content_type || state.imageEditorBlob.type || "").toLowerCase() === "image/gif";
cropControls?.classList.toggle("image-crop-disabled", isGif);
cropControls?.querySelectorAll("input").forEach((input) => { input.disabled = isGif; });
const help = $("#image-edit-help");
if (help) help.textContent = isGif
? "Size and position are available. Cropping animated GIF files is disabled so the animation is not flattened."
: "Resizing changes only the displayed size. Cropping creates a new CID image and replaces the previous inline attachment.";
drawInlineImageCropPreview();
$("#image-edit-dialog").showModal();
} catch (error) {
toast(error.message || "Could not open the image editor", "error");
}
}
function croppedImageFilename(filename, mime) {
const base = String(filename || "image").replace(/\.[^.]+$/, "") || "image";
const ext = mime === "image/jpeg" ? "jpg" : mime === "image/webp" ? "webp" : "png";
return `${base}-crop.${ext}`;
}
async function cropInlineImageToFile(bitmap, sourceBlob, crop, filename) {
const sx = Math.round(bitmap.width * crop.left / 100);
const sy = Math.round(bitmap.height * crop.top / 100);
const sw = Math.max(1, Math.round(bitmap.width * (100 - crop.left - crop.right) / 100));
const sh = Math.max(1, Math.round(bitmap.height * (100 - crop.top - crop.bottom) / 100));
if (sw * sh > 40_000_000) throw new Error("The crop is too large to process safely in the browser");
const canvas = document.createElement("canvas");
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, sw, sh);
const sourceType = String(sourceBlob?.type || "").toLowerCase();
const outputType = ["image/jpeg", "image/webp", "image/png"].includes(sourceType) ? sourceType : "image/png";
const blob = await new Promise((resolve, reject) => canvas.toBlob((value) => value ? resolve(value) : reject(new Error("Could not create cropped image")), outputType, outputType === "image/png" ? undefined : 0.92));
return new File([blob], croppedImageFilename(filename, outputType), { type:outputType, lastModified:Date.now() });
}
async function applyInlineImageEditor(event) {
event.preventDefault();
const image = state.composeSelectedImage;
const bitmap = state.imageEditorBitmap;
if (!image?.dataset?.rustboxCid || !bitmap) return;
const width = Number($("#image-edit-width").value || 100);
const alignment = $("#image-edit-align").value || "inline";
const crop = imageCropValues();
const hasCrop = Object.values(crop).some((value) => value > 0);
const oldCid = image.dataset.rustboxCid;
const oldAttachment = state.composeAttachments.find((item) => String(item.id) === String(oldCid));
const applyButton = $("#image-edit-apply");
setBusy(applyButton, true);
try {
if (hasCrop) {
const type = String(oldAttachment?.content_type || state.imageEditorBlob?.type || "").toLowerCase();
if (type === "image/gif") throw new Error("Cropping animated GIF files is disabled to preserve animation");
const file = await cropInlineImageToFile(bitmap, state.imageEditorBlob, crop, oldAttachment?.filename || image.alt || "image");
const uploaded = await uploadComposeFiles([file], { announce:false });
const replacement = uploaded[0];
if (!replacement) throw new Error("Could not upload the cropped image");
state.inlineAttachmentIds.add(String(replacement.id));
image.dataset.rustboxCid = replacement.id;
image.src = composeAttachmentContentUrl(ensureDraftId(), replacement.id);
image.alt = replacement.filename || image.alt || "image";
state.inlineAttachmentIds.delete(String(oldCid));
await deleteComposeAttachment(oldCid);
}
applyInlineImageDisplay(image, width, alignment);
setSelectedInlineImage(image);
scheduleDraftSave();
$("#image-edit-dialog").close();
toast(hasCrop ? "Image cropped" : "Image layout updated", "success");
} catch (error) {
toast(error.message || "Could not update image", "error");
} finally {
setBusy(applyButton, false);
}
}
function scheduleUnusedInlineAttachmentCleanup() {
clearTimeout(state.inlineImageCleanupTimer);
state.inlineImageCleanupTimer = setTimeout(cleanupUnusedInlineAttachments, 3500);
}
async function cleanupUnusedInlineAttachments() {
if (!state.composeDraftId || !state.inlineAttachmentIds.size) return;
const used = new Set($$("#compose-editor img[data-rustbox-cid]").map((image) => String(image.dataset.rustboxCid || "")).filter(Boolean));
const unused = [...state.inlineAttachmentIds].filter((id) => !used.has(String(id)));
if (!unused.length) return;
let changed = false;
for (const id of unused) {
try {
await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method:"DELETE" });
state.composeAttachments = state.composeAttachments.filter((attachment) => String(attachment.id) !== String(id));
state.inlineAttachmentIds.delete(String(id));
changed = true;
} catch (error) {
console.warn("Could not remove unused inline attachment", id, error);
}
}
if (changed) renderComposeAttachments();
}
function editorRangeFromPoint(x, y) {
const editor = $("#compose-editor");
if (!editor) return null;
if (document.caretRangeFromPoint) {
const range = document.caretRangeFromPoint(x, y);
if (range && editor.contains(range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement)) return range;
}
if (document.caretPositionFromPoint) {
const position = document.caretPositionFromPoint(x, y);
if (position?.offsetNode) {
const range = document.createRange();
range.setStart(position.offsetNode, position.offset);
range.collapse(true);
if (editor.contains(position.offsetNode.nodeType === Node.ELEMENT_NODE ? position.offsetNode : position.offsetNode.parentElement)) return range;
}
}
return captureEditorRange(editor);
}
async function uploadInlineImage(files, insertionRange = null) {
const images = [...(files || [])].filter((item) => /^image\/(png|jpeg|gif|webp)$/i.test(item.type));
if (!images.length) return toast("Choose a PNG, JPEG, GIF or WebP image", "error");
const uploaded = await uploadComposeFiles(images);
if (!uploaded.length) return;
setComposeMode("html");
const editor = $("#compose-editor");
restoreEditorRange(insertionRange, editor);
const draftId = ensureDraftId();
let lastInsertedId = null;
for (const attachment of uploaded) {
const previewUrl = composeAttachmentContentUrl(draftId, attachment.id);
state.inlineAttachmentIds.add(String(attachment.id));
lastInsertedId = String(attachment.id);
const html = `<img src="${escapeHtml(previewUrl)}" data-rustbox-cid="${escapeHtml(attachment.id)}" data-rustbox-align="inline" alt="${escapeHtml(attachment.filename || "image")}" style="max-width:100%;height:auto">`;
document.execCommand("insertHTML", false, html);
}
if (lastInsertedId) {
const inserted = [...editor.querySelectorAll("img[data-rustbox-cid]")].reverse().find((image) => String(image.dataset.rustboxCid) === lastInsertedId);
if (inserted) setSelectedInlineImage(inserted);
}
scheduleDraftSave();
}
function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
state.composeSignatureGeneration += 1;
state.composeDraftId = draft.id || null;
state.composeAttachments = Array.isArray(draft.attachments) ? draft.attachments : [];
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = {
inReplyTo: draft.in_reply_to || null,
references: draft.references || null,
};
$("#compose-to").value = draft.to || "";
$("#compose-cc").value = draft.cc || "";
$("#compose-bcc").value = draft.bcc || "";
$("#compose-subject").value = draft.subject || "";
$("#compose-priority").value = ["high", "normal", "low"].includes(draft.priority) ? draft.priority : "normal";
$("#compose-mdn").checked = Boolean(draft.request_mdn);
$("#compose-pgp-mode").value = ["none","sign","encrypt","sign-encrypt"].includes(draft.pgp_mode) ? draft.pgp_mode : "none";
$("#compose-pgp-inline").checked = Boolean(draft.pgp_inline);
$("#compose-pgp-attach-key").checked = Boolean(draft.pgp_attach_public_key);
$("#compose-pgp-attach-recipient-keys").checked = Boolean(draft.pgp_attach_recipient_keys);
$("#compose-pgp-passphrase").value = "";
updateComposePgpUi();
const mode = draft.compose_format || (draft.html_body ? "html" : "text");
setComposeMode(mode);
const editor = $("#compose-editor");
if (mode === "html" && draft.html_body) editor.innerHTML = hydrateComposeHtml(draft.html_body, state.composeAttachments, state.composeDraftId);
else editor.textContent = draft.text_body || "";
renderComposeAttachments();
state.composeDirty = sourceLabel === "Browser recovery";
state.composeNeedsRemoteSync = sourceLabel === "Browser recovery";
if (state.composeDirty) storeLocalDraftSnapshot(); else clearLocalDraftSnapshot();
$("#compose-title").textContent = "Recovered draft";
$("#compose-status").textContent = `${sourceLabel}${draft.updated_at ? ` · ${draft.updated_at}` : ""}`;
setTimeout(() => editor.focus(), 20);
}
async function recoverDraft() {
if (state.composeDirty && draftHasContent()) {
const saved = await saveCurrentDraft(true, true);
if (!saved) return toast("Save the current draft before recovering another one", "error");
}
const localDrafts = readLocalDraftSnapshots();
let serverDrafts = [];
try {
const data = await request("/drafts", { method:"GET" });
serverDrafts = data.drafts || [];
} catch (error) {
if (!localDrafts.length) return toast(error.message, "error");
}
const options = [];
for (const local of localDrafts) {
const localSubject = local.subject || "(no subject)";
options.push({ value:`local:${local.id}`, label:`Browser recovery — ${localSubject}` });
}
for (const draft of serverDrafts.slice(0, 50)) {
const subject = draft.subject || "(no subject)";
const recipient = draft.to || "no recipient";
options.push({ value:`server:${draft.id}`, label:`${subject} — ${recipient}` });
}
if (!options.length) return toast("No recoverable drafts found", "info");
const selected = await appSelect("Recover draft", options, { value:options[0].value, confirmLabel:"Recover" });
if (!selected) return;
if (selected.startsWith("local:")) {
const id = selected.slice("local:".length);
const local = localDrafts.find((draft) => draft.id === id);
if (!local) return toast("Browser recovery snapshot is no longer available", "error");
applyRecoveredDraft(local, "Browser recovery");
toast("Recovered unsaved browser draft", "success");
return;
}
const id = selected.slice("server:".length);
try {
const draft = await request(`/drafts/${encodeURIComponent(id)}`, { method:"GET" });
applyRecoveredDraft(draft, "Saved draft");
toast("Draft recovered", "success");
} catch (error) { toast(error.message, "error"); }
}
async function closeComposer() {
clearTimeout(state.draftSaveTimer);
clearTimeout(state.inlineImageCleanupTimer);
await cleanupUnusedInlineAttachments();
if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning");
if (state.composeNeedsRemoteSync && draftHasContent()) {
const saved = await saveCurrentDraft(true, true, true);
if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200);
} else if (state.composeDirty && draftHasContent()) {
const saved = await saveCurrentDraft(true, true);
if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200);
}
if ($("#compose-dialog").open) $("#compose-dialog").close();
state.composeSignatureGeneration += 1;
}
function attachmentReminderNeeded() {
if (state.prefs.attachmentReminder === false) return false;
if (state.composeAttachments.some((attachment) => !state.inlineAttachmentIds.has(String(attachment.id)))) return false;
const text = `${$("#compose-subject").value} ${$("#compose-editor").innerText}`.toLowerCase();
return /\b(attach(ed|ment|ments)?|załącz(am|nik|niki|niku|one|ony)?|zalacz(am|nik|niki|niku|one|ony)?)\b/i.test(text);
}
async function deleteComposeAttachment(id) {
if (!state.composeDraftId) return;
try {
await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method:"DELETE" });
state.composeAttachments = state.composeAttachments.filter((a) => a.id !== id);
state.inlineAttachmentIds.delete(String(id));
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid]").forEach((image) => {
if (image.dataset.rustboxCid === id) image.remove();
});
renderComposeAttachments();
scheduleDraftSave();
} catch (error) { toast(error.message, "error"); }
}
function selectIdentityForMessage(detail) {
const select = $("#compose-from");
if (!select || !detail || !state.identities.length) return;
const addresses = new Set();
for (const entry of [...(detail.to_addresses || []), ...(detail.cc_addresses || [])]) {
if (entry?.address) addresses.add(String(entry.address).trim().toLowerCase());
}
const raw = String(detail.raw_headers || "");
for (const match of raw.matchAll(/^(?:delivered-to|envelope-to):\s*(.+)$/gim)) {
const value = match[1] || "";
const email = value.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)?.[0];
if (email) addresses.add(email.toLowerCase());
}
const identity = state.identities.find((candidate) => addresses.has(String(candidate.email || "").trim().toLowerCase()));
if (identity) select.value = String(identity.id);
}
function setComposeFullscreen(enabled, persist = true) {
const dialog = $("#compose-dialog");
const button = $("#compose-fullscreen");
if (!dialog || !button) return;
dialog.classList.toggle("compose-fullscreen", Boolean(enabled));
button.setAttribute("aria-pressed", enabled ? "true" : "false");
button.title = enabled ? "Exit full screen" : "Full screen";
button.textContent = enabled ? "↙" : "⛶";
if (persist) {
try { localStorage.setItem("rustbox.composeFullscreen", enabled ? "1" : "0"); } catch (_) {}
}
}
function toggleComposeFullscreen() {
setComposeFullscreen(!$("#compose-dialog").classList.contains("compose-fullscreen"));
}
function openCompose(mode = "new") {
if (mode === "forward" && state.prefs.forwardMode === "attachment") {
void forwardAsAttachment();
return;
}
const detail = state.currentDetail;
clearTimeout(state.draftSaveTimer);
$("#compose-form").reset();
renderComposeIdentities();
if (detail && ["reply", "reply-all", "forward"].includes(mode)) selectIdentityForMessage(detail);
state.composeDraftId = null;
state.composeAttachments = [];
state.composeSignatureGeneration += 1;
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeDirty = false;
state.composeNeedsRemoteSync = false;
state.composeReplyMeta = null;
setComposeUploadProgress(false);
renderComposeAttachments();
$("#compose-error").classList.add("hidden");
$("#compose-status").textContent = "";
$("#compose-title").textContent = mode === "reply" ? "Reply" : mode === "reply-all" ? "Reply all" : mode === "forward" ? "Forward" : "New message";
const editor = $("#compose-editor");
editor.replaceChildren();
editor.spellcheck = state.prefs.spellcheckEnabled !== false;
setComposeMode(state.prefs.composeFormat || "html");
$("#compose-priority").value = ["high", "normal", "low"].includes(state.prefs.defaultPriority) ? state.prefs.defaultPriority : "normal";
$("#compose-mdn").checked = Boolean(state.prefs.requestMdnDefault);
state.pgpRecipientStatus = [];
$("#compose-pgp-passphrase").value = "";
updateComposePgpUi(true);
if (detail && (mode === "reply" || mode === "reply-all")) {
state.composeReplyMeta = { inReplyTo: detail.message_id || null, references: buildReferences(detail) };
$("#compose-to").value = detail.from_address || "";
if (mode === "reply-all") {
const own = ownAddressSet();
const cc = [...(detail.to_addresses || []), ...(detail.cc_addresses || [])]
.map((entry) => entry.address || "")
.filter((address) => address && address.toLowerCase() !== String(detail.from_address || "").toLowerCase() && !own.has(address.toLowerCase()));
$("#compose-cc").value = [...new Set(cc)].join(", ");
}
$("#compose-subject").value = /^re:/i.test(detail.subject || "") ? detail.subject : `Re: ${detail.subject || ""}`;
const quoteStyle = state.prefs.quoteStyle || "standard";
const quote = quoteStyle === "none" ? null : el("div", "compose-quote");
if (quote) {
const compact = quoteStyle === "compact";
if ($("#compose-mode").value === "html") {
const attribution = compact
? `<strong>${escapeHtml(detail.from_address || "")}</strong> · ${escapeHtml(formatDate(detail.date, true))}`
: `On ${escapeHtml(formatDate(detail.date, true))}, ${escapeHtml(detail.from_address || "")} wrote:`;
quote.innerHTML = `<blockquote>${attribution}<br>${quoteTextAsHtml(detail.text || "")}</blockquote>`;
} else {
const quoted = cleanPlainMessageText(detail.text || "").split("\n").map((line) => `> ${line}`).join("\n");
const attribution = compact
? `${detail.from_address || ""} · ${formatDate(detail.date, true)}`
: `On ${formatDate(detail.date, true)}, ${detail.from_address || ""} wrote:`;
quote.textContent = `\n\n${attribution}\n${quoted}`;
}
}
const entry = el("div", "compose-reply-entry");
entry.innerHTML = "<br>";
if (state.prefs.replyPosition === "below" && quote) { editor.append(quote, entry); }
else { editor.append(entry); if (quote) editor.append(quote); }
}
if (detail && mode === "forward") {
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`;
const quote = el("div", "compose-quote");
if ($("#compose-mode").value === "html") {
quote.innerHTML = `<blockquote><strong>Forwarded message</strong><br>From: ${escapeHtml(detail.from_address || "")}<br>Date: ${escapeHtml(formatDate(detail.date, true))}<br>Subject: ${escapeHtml(detail.subject || "")}<br><br>${quoteTextAsHtml(detail.text || "")}</blockquote>`;
} else {
quote.textContent = `\n\nForwarded message\nFrom: ${detail.from_address || ""}\nDate: ${formatDate(detail.date, true)}\nSubject: ${detail.subject || ""}\n\n${cleanPlainMessageText(detail.text || "")}`;
}
editor.append(quote);
}
insertIdentitySignature(editor, mode);
let fullScreen = $("#compose-dialog").classList.contains("compose-fullscreen");
try { fullScreen = localStorage.getItem("rustbox.composeFullscreen") === "1"; } catch (_) {}
setComposeFullscreen(fullScreen, false);
$("#compose-dialog").showModal();
setTimeout(() => (mode === "new" ? $("#compose-to") : editor).focus(), 30);
}
function buildReferences(detail) {
if (!detail) return null;
const values = [detail.references, detail.message_id].filter(Boolean);
return values.length ? values.join(" ") : null;
}
async function sendMessage(event) {
event.preventDefault();
if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning");
clearTimeout(state.inlineImageCleanupTimer);
await cleanupUnusedInlineAttachments();
const send = $("#send-btn");
const errorBox = $("#compose-error");
errorBox.classList.add("hidden");
if (attachmentReminderNeeded() && !await appConfirm("The message mentions an attachment but no file is attached. Send anyway?", { title:"Missing attachment", confirmLabel:"Send anyway" })) return;
const pgpMode = $("#compose-pgp-mode").value || "none";
const pgpInline = Boolean($("#compose-pgp-inline").checked);
if (pgpInline && state.composeAttachments.length) return toast("Inline OpenPGP cannot carry attachments. Disable inline mode to use PGP/MIME.", "warning");
if (pgpInline && $("#compose-mode").value !== "text" && !await appConfirm("Inline OpenPGP sends the plain-text representation and drops HTML/Markdown formatting. Continue?", { title:"Inline OpenPGP", confirmLabel:"Continue" })) return;
if (pgpMode === "encrypt" || pgpMode === "sign-encrypt") {
const statuses = await checkComposePgpRecipients(false);
const missing = statuses.filter((item) => !item.hasKey);
const untrusted = statuses.filter((item) => item.hasKey && item.trust === "untrusted");
const notTrusted = state.pgpPrefs.requireTrustedEncryption
? statuses.filter((item) => item.hasKey && !["trusted", "verified"].includes(item.trust))
: [];
if (missing.length) return toast(`Missing OpenPGP key for ${missing.map((item) => item.email).join(", ")}`, "warning");
if (untrusted.length) return toast(`OpenPGP key marked untrusted for ${untrusted.map((item) => item.email).join(", ")}`, "warning");
if (notTrusted.length) return toast(`OpenPGP policy requires trusted keys: ${notTrusted.map((item) => item.email).join(", ")}`, "warning");
}
if ($("#compose-pgp-attach-recipient-keys").checked) {
const visibleRecipients = composePgpVisibleRecipients();
if (!visibleRecipients.length) return toast("Add at least one To/Cc recipient before attaching recipient OpenPGP keys", "warning");
const statuses = await checkComposePgpRecipients(false, visibleRecipients);
if (!statuses.some((item) => item.hasKey)) return toast("No OpenPGP public key is available for any To/Cc recipient", "warning");
}
setBusy(send, true);
const editor = $("#compose-editor");
try {
await request("/messages/send", {
method: "POST",
body: JSON.stringify({
to: parseRecipients($("#compose-to").value),
cc: parseRecipients($("#compose-cc").value),
bcc: parseRecipients($("#compose-bcc").value),
subject: $("#compose-subject").value,
text_body: editor.innerText,
html_body: serializeComposeHtml(editor),
in_reply_to: state.composeReplyMeta?.inReplyTo || null,
references: state.composeReplyMeta?.references || null,
draft_id: state.composeDraftId,
from_identity_id: $("#compose-from").value ? Number($("#compose-from").value) : null,
request_mdn: Boolean($("#compose-mdn").checked),
priority: $("#compose-priority").value || "normal",
pgp_mode: $("#compose-pgp-mode").value || "none",
pgp_inline: Boolean($("#compose-pgp-inline").checked),
pgp_passphrase: $("#compose-pgp-passphrase").value || "",
pgp_attach_public_key: Boolean($("#compose-pgp-attach-key").checked),
pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys").checked),
}),
});
$("#compose-dialog").close();
clearLocalDraftSnapshot(state.composeDraftId);
state.composeDraftId = null;
state.composeAttachments = [];
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = null;
state.composeDirty = false;
state.composeNeedsRemoteSync = false;
state.pgpRecipientStatus = [];
$("#compose-pgp-passphrase").value = "";
toast("Message sent", "success");
await loadFolders();
} catch (error) {
state.composeDirty = true;
state.composeNeedsRemoteSync = true;
storeLocalDraftSnapshot();
await saveCurrentDraft(true, true, true);
errorBox.textContent = error.message;
errorBox.classList.remove("hidden");
} finally { setBusy(send, false); }
}
async function updateFlag(flag, add) {
if (!state.currentDetail) return;
const detail = state.currentDetail;
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, {
method: "PATCH",
body: JSON.stringify({ flags: [flag], add }),
});
const flags = new Set(detail.flags || []);
add ? flags.add(flag) : flags.delete(flag);
detail.flags = [...flags];
const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder);
if (item) item.flags = detail.flags.join(" ");
renderMessages();
renderMessageDetail(detail);
}
async function toggleUnread() {
if (!state.currentDetail) return;
const seen = (state.currentDetail.flags || []).includes("\\Seen");
try { await updateFlag("\\Seen", !seen); toast(seen ? "Marked unread" : "Marked read", "success"); } catch (error) { toast(error.message, "error"); }
}
async function toggleFlagged() {
if (!state.currentDetail) return;
const flagged = (state.currentDetail.flags || []).includes("\\Flagged");
try { await updateFlag("\\Flagged", !flagged); toast(flagged ? "Flag removed" : "Message flagged", "success"); } catch (error) { toast(error.message, "error"); }
}
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) }),
});
state.selected = null;
state.currentDetail = null;
state.selectedMessages.clear();
closeReader();
await refreshFolderListOnly();
await refreshCurrentMessageView();
toast("Move undone", "success");
},
};
}
function bulkMoveUndoAction(undoMoves) {
if (!Array.isArray(undoMoves) || !undoMoves.length) return null;
return {
label: "Undo",
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) }),
});
restored += 1;
}
state.selected = null;
state.currentDetail = null;
state.selectedMessages.clear();
closeReader();
await refreshFolderListOnly();
await refreshCurrentMessageView();
toast(`Undo completed: ${restored} message${restored === 1 ? "" : "s"}`, "success");
},
};
}
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 wasCurrentFolder = state.folder === fromFolder;
if (wasCurrentFolder) {
state.messages = state.messages.filter((message) => !(message.uid === uid && (message.folder || state.folder) === fromFolder));
state.selectedMessages.delete(`${fromFolder}\u0000${uid}`);
state.total = Math.max(0, state.total - 1);
if (state.currentDetail?.uid === uid && state.currentDetail?.folder === fromFolder) {
state.selected = null;
state.currentDetail = null;
closeReader();
}
renderMessages();
}
await refreshFolderListOnly();
toast(`Moved to ${targetFolder}`, "success", 9000, moveUndoAction(result));
}
async function moveCurrentTo(targetFolder, successMessage = null) {
if (!state.currentDetail || !targetFolder || targetFolder === state.currentDetail.folder) return;
const detail = state.currentDetail;
try {
const result = await request("/messages/move", {
method: "POST",
body: JSON.stringify({ from_folder: detail.folder, to_folder: targetFolder, uid: detail.uid }),
});
state.messages = state.messages.filter((msg) => !(msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder));
state.selectedMessages.delete(`${detail.folder}\u0000${detail.uid}`);
state.total = Math.max(0, state.total - 1);
state.selected = null;
state.currentDetail = null;
closeReader();
renderMessages();
await refreshFolderListOnly();
toast(successMessage || `Moved to ${targetFolder}`, "success", 9000, moveUndoAction(result));
} catch (error) { toast(error.message, "error"); }
}
async function copyCurrentTo(targetFolder) {
if (!state.currentDetail || !targetFolder || targetFolder === state.currentDetail.folder) return;
const detail = state.currentDetail;
try {
await request("/messages/copy", { method:"POST", body:JSON.stringify({ from_folder:detail.folder, to_folder:targetFolder, uid:detail.uid }) });
await refreshFolderListOnly();
toast(`Copied to ${targetFolder}`);
} catch (error) { toast(error.message, "error"); }
}
const SPAM_DEFAULT_FOLDER_KEY = "rustbox.defaultSpamFolder";
function configuredBrowserSpamFolder() {
const preferred = (localStorage.getItem(SPAM_DEFAULT_FOLDER_KEY) || "").trim();
if (!preferred) return null;
const match = state.folders.find((folder) => String(folder.name || "").toLowerCase() === preferred.toLowerCase());
return match?.name || null;
}
function autoDetectSpecialFolder(role) {
const attr = { sent:"\\Sent", drafts:"\\Drafts", trash:"\\Trash", junk:"\\Junk", archive:"\\Archive" }[role];
if (attr) {
const byAttr = state.folders.find((folder) => (folder.attributes || []).some((value) => String(value).toLowerCase() === attr.toLowerCase()));
if (byAttr) return byAttr.name;
}
const names = { sent:["sent"], drafts:["draft"], trash:["trash","deleted"], junk:["junk","spam"], archive:["archive"] }[role] || [];
return state.folders.find((folder) => names.some((name) => String(folder.name || "").toLowerCase().includes(name)))?.name || null;
}
function detectSpecialFolder(role) {
const configured = state.specialFolders?.[role];
if (configured && state.folders.some((folder) => folder.name === configured)) return configured;
if (role === "junk") {
const browserDefault = configuredBrowserSpamFolder();
if (browserDefault) return browserDefault;
}
return autoDetectSpecialFolder(role);
}
function isJunkFolder(folder) {
const junk = detectSpecialFolder("junk");
return Boolean(junk && String(folder || "").toLowerCase() === junk.toLowerCase());
}
function inboxFolder() {
return state.folders.find((folder) => String(folder.name || "").toUpperCase() === "INBOX")?.name || "INBOX";
}
async function archiveCurrent() {
const archive = detectSpecialFolder("archive");
if (!archive) return toast("Configure an Archive folder in Settings → Folders", "error");
await moveCurrentTo(archive, "Message archived");
}
async function junkCurrent() {
if (!state.currentDetail) return;
const junk = detectSpecialFolder("junk");
if (!junk) return toast("Configure a Junk folder in Settings → Folders", "error");
if (isJunkFolder(state.currentDetail.folder)) {
await moveCurrentTo(inboxFolder(), tr("marked_not_spam", "Marked as not spam"));
} else {
await moveCurrentTo(junk, tr("marked_spam", "Marked as spam"));
}
}
async function deleteCurrent() {
if (!state.selected) return;
const { folder, uid } = state.selected;
const trash = detectSpecialFolder("trash");
if (trash && folder !== trash) {
await moveCurrentTo(trash);
return;
}
if (!await appConfirm("Permanently delete this message?", { title:"Delete message permanently", confirmLabel:"Delete permanently", danger:true })) return;
try {
await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "DELETE" });
state.messages = state.messages.filter((msg) => !(msg.uid === uid && (msg.folder || state.folder) === folder));
state.selectedMessages.delete(`${folder}\u0000${uid}`);
state.total = Math.max(0, state.total - 1);
state.selected = null;
state.currentDetail = null;
closeReader();
renderMessages();
await refreshFolderListOnly();
toast("Message deleted", "success");
} catch (error) { toast(error.message, "error"); }
}
async function performBulk(action, targetFolder = null) {
const items = selectedMessages();
if (!items.length) return;
const bulkJunkToInbox = action === "junk" && items.every((message) => isJunkFolder(message.folder || state.folder));
if (action === "archive" && !detectSpecialFolder("archive")) {
toast("Configure an Archive folder in Settings → Folders", "error");
return;
}
if (action === "junk" && !bulkJunkToInbox && !detectSpecialFolder("junk")) {
toast("Configure a Junk folder in Settings → Folders", "error");
return;
}
const permanentDelete = action === "delete" && items.every((message) => (message.folder || state.folder) === detectSpecialFolder("trash"));
if (permanentDelete && !await appConfirm(`Permanently delete ${items.length} selected messages?`, { title:"Delete selected messages", confirmLabel:"Delete permanently", danger:true })) return;
let completed = 0;
const undoMoves = [];
let movedOperations = 0;
try {
for (const message of items) {
const folder = message.folder || state.folder;
if (action === "read" || action === "unread" || action === "flag" || action === "unflag") {
const flag = action === "read" || action === "unread" ? "\\Seen" : "\\Flagged";
const add = action === "read" || action === "flag";
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method:"PATCH", body:JSON.stringify({ flags:[flag], add }) });
} else if (action === "move" || action === "archive" || action === "junk" || action === "delete") {
let destination = targetFolder;
if (action === "archive") destination = detectSpecialFolder("archive");
if (action === "junk") destination = bulkJunkToInbox ? inboxFolder() : detectSpecialFolder("junk");
if (action === "delete") destination = detectSpecialFolder("trash");
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 }) });
if (result?.undo_move) undoMoves.push(result.undo_move);
} else if (action === "delete") {
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}`, { method:"DELETE" });
}
} else if (action === "copy" && targetFolder && targetFolder !== folder) {
await request("/messages/copy", { method:"POST", body:JSON.stringify({ from_folder:folder, to_folder:targetFolder, uid:message.uid }) });
}
completed += 1;
$("#bulk-count").textContent = `${completed}/${items.length}`;
}
state.selectedMessages.clear();
state.searchAllResults = null;
await refreshFolderListOnly();
await refreshCurrentMessageView();
const bulkMessages = { junk: bulkJunkToInbox ? tr("marked_not_spam", "Marked as not spam") : tr("marked_spam", "Marked as spam"), archive: "Archived", read: "Marked read", unread: "Marked unread", flag: "Flagged", unflag: "Unflagged", delete: "Deleted", move: "Moved", copy: "Copied" };
const undoAction = movedOperations > 0 && undoMoves.length === movedOperations ? bulkMoveUndoAction(undoMoves) : null;
toast(`${bulkMessages[action] || "Updated"}: ${completed} message${completed === 1 ? "" : "s"}`, "success", undoAction ? 10000 : null, undoAction);
} catch (error) {
toast(`${completed}/${items.length} completed: ${error.message}`, "error");
state.searchAllResults = null;
await refreshCurrentMessageView();
}
}
async function refreshFolderListOnly() {
try {
const data = await request("/folders", { method: "GET" });
state.folders = data.folders || [];
updateUnreadAppBadge();
renderFolders();
} catch (_) {}
}
async function refreshCurrentMessageView() {
if (state.searchContext) {
await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop:state.searchContext.scrollTop });
} else {
await loadMessages(true);
}
}
async function runSearch(params, label = "Search", options = {}) {
const cleanParams = new URLSearchParams(params.toString());
cleanParams.delete("all");
const queryString = cleanParams.toString();
if (!queryString) return;
$("#folder-title").textContent = label;
$("#message-list").innerHTML = `<div class="empty-list">Searching…</div>`;
state.selectedMessages.clear();
state.searchAllResults = null;
state.searchContext = { params:queryString, label, scrollTop:Number(options.restoreScrollTop) || 0 };
saveActiveSearchContext();
renderBulkToolbar();
try {
const data = await request(`/search?${queryString}`, { method: "GET" });
state.messages = data.results || [];
state.total = data.total_count || state.messages.length;
renderMessages();
$("#folder-title").textContent = label;
$("#message-count").textContent = `${state.total} result${state.total === 1 ? "" : "s"}`;
const restoreScrollTop = Number(options.restoreScrollTop) || 0;
if (restoreScrollTop > 0) requestAnimationFrame(() => { $("#message-list").scrollTop = restoreScrollTop; });
} catch (error) { toast(error.message, "error"); }
}
async function searchMail(event) {
event.preventDefault();
const query = $("#search-input").value.trim();
if (!query) {
clearActiveSearchContext();
return loadMessages(true);
}
const params = new URLSearchParams({ q: query, limit: "100" });
await runSearch(params, `Search: ${query}`);
}
function populateAdvancedSearchFolders() {
const select = $("#search-folder");
const current = select.value;
select.replaceChildren(new Option("All folders", ""));
for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name));
if ([...select.options].some((option) => option.value === current)) select.value = current;
}
function buildAdvancedSearchParams() {
const params = new URLSearchParams();
const set = (name, value) => { if (String(value || "").trim()) params.set(name, String(value).trim()); };
set("q", $("#search-text").value);
set("folder", $("#search-folder").value);
set("from", $("#search-from").value);
set("to", $("#search-to").value);
set("subject", $("#search-subject").value);
const dateFrom = $("#search-date-from").value;
const dateTo = $("#search-date-to").value;
if (dateFrom) params.set("date_from", String(Math.floor(new Date(`${dateFrom}T00:00:00`).getTime() / 1000)));
if (dateTo) params.set("date_to", String(Math.floor(new Date(`${dateTo}T23:59:59`).getTime() / 1000)));
const read = $("#search-read").value;
if (read) params.set("unread", read === "unread" ? "true" : "false");
set("flagged", $("#search-flagged").value);
set("has_attachment", $("#search-attachment").value);
params.set("sort", $("#search-sort").value || "date_desc");
params.set("limit", "250");
return params;
}
function resetAdvancedSearch() {
$("#advanced-search-form").reset();
$("#search-sort").value = "date_desc";
populateAdvancedSearchFolders();
}
async function openAdvancedSearch() {
populateAdvancedSearchFolders();
$("#advanced-search-dialog").showModal();
await loadSavedSearches();
}
async function submitAdvancedSearch(event) {
event.preventDefault();
const params = buildAdvancedSearchParams();
const meaningful = [...params.keys()].some((key) => !["sort", "limit"].includes(key));
if (!meaningful) return toast("Choose at least one search criterion", "error");
$("#advanced-search-dialog").close();
await runSearch(params, "Advanced search");
}
async function loadSavedSearches() {
try {
const data = await request("/search/saved", { method:"GET" });
state.savedSearches = data.searches || [];
renderSavedSearches();
} catch (error) { toast(error.message, "error"); }
}
function renderSavedSearches() {
const list = $("#saved-search-list");
list.replaceChildren();
if (!state.savedSearches.length) { list.append(el("div", "empty-list", "No saved searches")); return; }
for (const saved of state.savedSearches) {
const row = el("div", "saved-search-item");
const name = el("strong", "", saved.name);
const run = el("button", "button quiet", "Run"); run.type = "button";
const remove = el("button", "button quiet danger", "Delete"); remove.type = "button";
run.addEventListener("click", async () => { $("#advanced-search-dialog").close(); await runSearch(new URLSearchParams(saved.query_string), `Search: ${saved.name}`); });
remove.addEventListener("click", async () => {
if (!await appConfirm(`Delete saved search “${saved.name}”?`, { title:"Delete saved search", confirmLabel:"Delete", danger:true })) return;
try {
await request(`/search/saved/${saved.id}`, { method:"DELETE" });
await loadSavedSearches();
toast("Saved search deleted", "success");
} catch (error) { toast(error.message, "error"); }
});
row.append(name, run, remove); list.append(row);
}
}
async function saveAdvancedSearch() {
const params = buildAdvancedSearchParams();
const meaningful = [...params.keys()].some((key) => !["sort", "limit"].includes(key));
if (!meaningful) return toast("Choose at least one search criterion", "error");
const name = await appPrompt("Save search", { message:"Name this search so you can run it again later.", placeholder:"Search name", confirmLabel:"Save" });
if (!name?.trim()) return;
try {
await request("/search/saved", { method:"POST", body:JSON.stringify({ name:name.trim(), query_string:params.toString() }) });
await loadSavedSearches();
toast("Search saved");
} catch (error) { toast(error.message, "error"); }
}
function openFolderDialog() {
$("#current-folder-name").value = state.folder;
$("#new-folder-name").value = "";
const current = state.folders.find((folder) => folder.name === state.folder);
$("#subscribe-folder-btn").textContent = current?.is_subscribed ? "Unsubscribe" : "Subscribe";
$("#folder-dialog").showModal();
}
async function createFolder() {
const name = $("#new-folder-name").value.trim();
if (!name) return;
try {
await request("/folders", { method: "POST", body: JSON.stringify({ name }) });
$("#new-folder-name").value = "";
await refreshFolderListOnly();
toast("Folder created");
} catch (error) { toast(error.message, "error"); }
}
async function renameFolder() {
const current = state.folder;
const next = await appPrompt("Rename folder", { value:current, confirmLabel:"Rename", required:true });
if (!next || next === current) return;
try {
await request(`/folders/${encodeURIComponent(current)}`, { method: "PATCH", body: JSON.stringify({ new_name: next }) });
state.folder = next;
$("#current-folder-name").value = next;
await loadFolders();
toast("Folder renamed");
} catch (error) { toast(error.message, "error"); }
}
async function deleteFolder() {
const current = state.folder;
if (!await appConfirm(`Delete folder “${current}”?`, { title:"Delete folder", confirmLabel:"Delete", danger:true })) return;
try {
await request(`/folders/${encodeURIComponent(current)}`, { method: "DELETE" });
$("#folder-dialog").close();
state.folder = "INBOX";
await loadFolders();
toast("Folder deleted");
} catch (error) { toast(error.message, "error"); }
}
function hideContextMenu() {
const menu = $("#context-menu");
menu.classList.add("hidden");
menu.replaceChildren();
}
function showContextMenu(x, y, items) {
const menu = $("#context-menu");
menu.replaceChildren();
for (const item of items) {
if (item === "separator") { menu.append(el("div", "separator")); continue; }
const button = el("button", item.danger ? "danger" : "", item.label);
button.type = "button";
button.addEventListener("click", async () => { hideContextMenu(); await item.action(); });
menu.append(button);
}
menu.classList.remove("hidden");
const rect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(6, Math.min(x, innerWidth - rect.width - 6))}px`;
menu.style.top = `${Math.max(6, Math.min(y, innerHeight - rect.height - 6))}px`;
}
async function ensureMessageOpen(message) {
const folder = message.folder || state.folder;
if (!state.currentDetail || state.currentDetail.uid !== message.uid || state.currentDetail.folder !== folder) {
await openMessage(folder, message.uid);
}
}
async function promptFolder(label, exclude = null) {
const options = orderedFolders().map((folder) => folder.name).filter((name) => name !== exclude);
if (!options.length) return null;
return appSelect(label, options.map((name) => ({ value:name, label:name })), { value:options[0], confirmLabel:"Choose folder" });
}
function openMessageContextMenu(x, y, message) {
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : String(message.flags || "");
const seen = flags.includes("\\Seen");
const flagged = flags.includes("\\Flagged");
showContextMenu(x, y, [
{ label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) },
{ label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } },
{ label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } },
{ label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } },
{ label: "Forward as attachment", action: async () => { await ensureMessageOpen(message); await forwardAsAttachment(); } },
"separator",
{ label: seen ? "Mark unread" : "Mark read", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Seen", !seen); } },
{ label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } },
"separator",
{ label: "Archive", action: async () => { await ensureMessageOpen(message); await archiveCurrent(); } },
{ label: "Move to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Move to folder", message.folder || state.folder); if (target) await moveCurrentTo(target); } },
{ label: "Copy to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Copy to folder", message.folder || state.folder); if (target) await copyCurrentTo(target); } },
{ label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } },
{ label: "Remove label…", action: async () => { const attached = state.tags.filter((tag) => (message.tags || []).some((item) => item.id === tag.id)); const tag = await promptTag("Remove label", attached); if (tag) await removeTagFromMessage(message, tag); } },
{ label: isJunkFolder(message.folder || state.folder) ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"), action: async () => { await ensureMessageOpen(message); await junkCurrent(); } },
{ label: "Create filter from sender", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("sender"); } },
{ label: "Create filter from domain", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("domain"); } },
{ label: "Create filter from subject", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("subject"); } },
{ label: "Add sender to contacts", action: async () => { await ensureMessageOpen(message); await addSenderToContacts(); } },
{ label: "Search messages from sender", action: async () => { await ensureMessageOpen(message); await searchFromCurrentSender(); } },
{ label: "Unsubscribe from mailing list", action: async () => { await ensureMessageOpen(message); await openListUnsubscribe(); } },
"separator",
{ label: "Show headers", action: async () => { await ensureMessageOpen(message); showHeaders(); } },
{ label: "Show source", action: async () => { await ensureMessageOpen(message); await showMessageSource(); } },
{ label: "Copy Message-ID", action: async () => { await ensureMessageOpen(message); await copyMessageId(); } },
{ label: "Copy sender address", action: async () => { await ensureMessageOpen(message); await copySenderAddress(); } },
{ label: "Download .eml", action: async () => { await ensureMessageOpen(message); downloadEml(); } },
{ label: "Print", action: async () => { await ensureMessageOpen(message); printCurrentMessage(); } },
{ label: "Delete", danger: true, action: async () => { await ensureMessageOpen(message); await deleteCurrent(); } },
]);
}
async function configureFolderFlaggedOnTop(folder) {
try {
const current = await request(`/folders/${encodeURIComponent(folder.name)}/preferences`, { method:"GET" });
const value = current.flagged_on_top === true ? "on" : current.flagged_on_top === false ? "off" : "inherit";
const choice = await appSelect("Flagged messages on top", [
{ value:"inherit", label:`Use mailbox default (${current.effective_flagged_on_top ? "on" : "off"})` },
{ value:"on", label:"Always on for this folder" },
{ value:"off", label:"Always off for this folder" },
], { message:`Folder: ${folder.name}`, value, confirmLabel:"Save" });
if (choice === null) return;
const flagged_on_top = choice === "inherit" ? null : choice === "on";
await request(`/folders/${encodeURIComponent(folder.name)}/preferences`, { method:"PUT", body:JSON.stringify({ flagged_on_top }) });
if (state.folder === folder.name && !state.searchContext) await loadMessages(true);
toast("Folder list preference saved", "success");
} catch (error) { toast(error.message, "error"); }
}
function openFolderContextMenu(x, y, folder) {
const subscribed = Boolean(folder.is_subscribed);
showContextMenu(x, y, [
{ label: "Open", action: async () => { state.folder = folder.name; renderFolders(); await loadMessages(true); } },
{ label: "Create subfolder", action: async () => {
const delimiter = folder.delimiter || "/";
const child = await appPrompt("Create subfolder", { message:`Parent folder: ${folder.name}`, placeholder:"Subfolder name", confirmLabel:"Create", required:true });
if (!child) return;
try {
await request("/folders", { method:"POST", body:JSON.stringify({ name:`${folder.name}${delimiter}${child}` }) });
await refreshFolderListOnly();
toast("Subfolder created", "success");
} catch (error) { toast(error.message, "error"); }
} },
{ label: subscribed ? "Unsubscribe" : "Subscribe", action: () => setFolderSubscription(folder.name, !subscribed) },
{ label: "Flagged on top…", action: () => configureFolderFlaggedOnTop(folder) },
{ label: "Mark all read", action: () => markFolderAllRead(folder.name) },
{ label: "Import .eml…", action: () => importEmlToFolder(folder.name) },
{ label: "Empty folder", danger: true, action: () => emptyFolder(folder.name) },
"separator",
{ label: "Properties", action: () => showFolderProperties(folder) },
{ label: "Rename", action: async () => { state.folder = folder.name; await renameFolder(); } },
{ label: "Delete", danger: true, action: async () => { state.folder = folder.name; await deleteFolder(); } },
]);
}
function importEmlToFolder(folderName) {
const input = document.createElement("input");
input.type = "file";
input.accept = ".eml,message/rfc822";
input.multiple = false;
input.addEventListener("change", async () => {
const file = input.files?.[0];
if (!file) return;
if (file.size > 50 * 1024 * 1024) return toast(".eml exceeds the 50 MB import limit", "error");
const form = new FormData();
form.append("file", file, file.name || "message.eml");
try {
await request(`/folders/${encodeURIComponent(folderName)}/import-eml`, { method:"POST", body:form });
if (state.folder === folderName) await loadMessages(true);
await refreshFolderListOnly();
toast(`Imported ${file.name || "message.eml"}`);
} catch (error) { toast(error.message, "error"); }
}, { once:true });
input.click();
}
function showFolderProperties(folder) {
const roles = Object.entries(state.specialFolders || {}).filter(([, value]) => value === folder.name).map(([key]) => key);
const dialog = document.createElement("dialog");
dialog.className = "dialog";
const card = el("div", "dialog-card folder-properties-card");
const header = el("header", "dialog-header");
header.append(el("strong", "", "Folder properties"));
const close = el("button", "button quiet", "Close"); close.type="button"; close.addEventListener("click", () => dialog.close());
header.append(close);
const body = el("div", "folder-properties-list");
const rows = [
["Name", folder.name || ""],
["Subscribed", folder.is_subscribed ? "Yes" : "No"],
["Messages", String(folder.total_count ?? folder.exists ?? 0)],
["Unread", String(folder.unread_count ?? 0)],
["Delimiter", folder.delimiter || "(none)"],
["Special role", roles.length ? roles.join(", ") : "None"],
["Attributes", Array.isArray(folder.attributes) ? folder.attributes.join(" ") : String(folder.attributes || "None")],
];
for (const [label,value] of rows) { const row=el("div","folder-property-row"); row.append(el("span","muted",label),el("strong","",value)); body.append(row); }
card.append(header, body); dialog.append(card); document.body.append(dialog);
dialog.addEventListener("close", () => dialog.remove(), { once:true });
dialog.showModal();
}
async function markFolderAllRead(name) {
try {
await request(`/folders/${encodeURIComponent(name)}/mark-all-read`, { method:"POST", body:"{}" });
if (state.folder === name) await loadMessages(true);
await refreshFolderListOnly();
toast("All messages marked read");
} catch (error) { toast(error.message, "error"); }
}
async function emptyFolder(name) {
if (!await appConfirm(`Permanently remove every message from “${name}”?`, { title:"Empty folder", confirmLabel:"Empty folder", danger:true })) return;
try {
await request(`/folders/${encodeURIComponent(name)}/empty`, { method:"POST", body:"{}" });
if (state.folder === name) { state.selected=null; state.currentDetail=null; closeReader(); await loadMessages(true); }
await refreshFolderListOnly();
toast("Folder emptied");
} catch (error) { toast(error.message, "error"); }
}
async function setFolderSubscription(name, subscribed) {
try {
await request(`/folders/${encodeURIComponent(name)}/subscribe`, { method:"PATCH", body:JSON.stringify({ subscribed }) });
await refreshFolderListOnly();
toast(subscribed ? "Folder subscribed" : "Folder unsubscribed");
} catch (error) { toast(error.message, "error"); }
}
async function toggleCurrentFolderSubscription() {
const folder = state.folders.find((item) => item.name === state.folder);
if (!folder) return;
await setFolderSubscription(folder.name, !folder.is_subscribed);
}
function rawHeaderValue(name, raw = state.currentDetail?.raw_headers || "") {
if (!raw) return "";
const unfolded = raw.replace(/\r?\n[ \t]+/g, " ");
const prefix = `${String(name).toLowerCase()}:`;
const line = unfolded.split(/\r?\n/).find((item) => item.toLowerCase().startsWith(prefix));
return line ? line.slice(line.indexOf(":") + 1).trim() : "";
}
async function openListUnsubscribe() {
const detail = state.currentDetail;
if (!detail) return;
const value = rawHeaderValue("List-Unsubscribe");
if (!value) return toast("This message does not provide List-Unsubscribe", "error");
const targets = [...value.matchAll(/<([^>]+)>/g)].map((match) => match[1].trim()).filter(Boolean);
const httpsTarget = targets.find((item) => /^https:\/\//i.test(item));
const mailtoTarget = targets.find((item) => /^mailto:/i.test(item));
const oneClick = rawHeaderValue("List-Unsubscribe-Post")
.split(",")
.some((item) => item.trim().toLowerCase() === "list-unsubscribe=one-click");
if (oneClick && httpsTarget) {
const confirmed = await appConfirm("Unsubscribe from this mailing list?", {
title: "One-click unsubscribe",
confirmLabel: "Unsubscribe",
danger: true,
});
if (!confirmed) return;
try {
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/unsubscribe`, { method:"POST" });
toast("Unsubscribe request sent", "success");
} catch (error) {
toast(error.message, "error");
}
return;
}
const target = httpsTarget || mailtoTarget;
if (!target) return toast("No supported HTTPS or mailto unsubscribe target", "error");
if (/^https:\/\//i.test(target)) {
const confirmed = await appConfirm("This mailing list does not support one-click unsubscribe. Open its external unsubscribe page?", {
title: "Open unsubscribe page",
confirmLabel: "Open page",
});
if (confirmed) window.open(target, "_blank", "noopener,noreferrer");
} else {
window.location.href = target;
}
}
function showHeaders() {
const detail = state.currentDetail;
if (!detail) return;
const summary = $("#headers-summary");
summary.replaceChildren();
const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", ");
const cc = (detail.cc_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", ");
const fields = [
["From", detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address],
["To", to], ["Cc", cc], ["Date", detail.date ? formatDate(detail.date, true) : rawHeaderValue("Date")],
["Message-ID", detail.message_id || rawHeaderValue("Message-ID")],
["In-Reply-To", detail.in_reply_to || rawHeaderValue("In-Reply-To")],
["References", detail.references || rawHeaderValue("References")],
["Return-Path", rawHeaderValue("Return-Path")], ["Delivered-To", rawHeaderValue("Delivered-To")],
["List-ID", rawHeaderValue("List-ID")], ["List-Unsubscribe", rawHeaderValue("List-Unsubscribe")],
["List-Unsubscribe-Post", rawHeaderValue("List-Unsubscribe-Post")],
["User-Agent", rawHeaderValue("User-Agent") || rawHeaderValue("X-Mailer")],
["Authentication-Results", rawHeaderValue("Authentication-Results")], ["Content-Type", rawHeaderValue("Content-Type")],
];
for (const [label, value] of fields) {
if (!String(value || "").trim()) continue;
const row = el("div", "header-summary-row");
row.append(el("span", "muted", label), el("code", "", String(value)));
summary.append(row);
}
$("#headers-content").textContent = detail.raw_headers || "No raw headers available.";
$("#headers-dialog").showModal();
}
async function fetchEmlBlob(detail = state.currentDetail) {
if (!detail) throw new Error("No message selected");
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/source`), { credentials:"include", headers });
if (!response.ok) throw new Error(`Message source failed (${response.status})`);
return response.blob();
}
async function showMessageSource() {
const detail = state.currentDetail;
if (!detail) return;
try {
const blob = await fetchEmlBlob(detail);
const source = await blob.text();
const dialog = document.createElement("dialog");
dialog.className = "dialog";
const card = el("div", "dialog-card source-card");
const header = el("header", "dialog-header");
header.append(el("strong", "", "Message source"));
const close = el("button", "button quiet", "Close"); close.type="button"; close.addEventListener("click", () => dialog.close());
header.append(close);
const pre = el("pre", "message-source-content"); pre.textContent = source;
const actions = el("footer", "dialog-actions");
const copy = el("button", "button quiet", "Copy source"); copy.type="button";
copy.addEventListener("click", async () => { try { await navigator.clipboard.writeText(source); toast("Message source copied"); } catch (_) { toast("Could not copy source", "error"); } });
const download = el("button", "button primary", "Download .eml"); download.type="button"; download.addEventListener("click", downloadEml);
actions.append(copy, download);
card.append(header, pre, actions); dialog.append(card); document.body.append(dialog);
dialog.addEventListener("close", () => dialog.remove(), { once:true });
dialog.showModal();
} catch (error) { toast(error.message, "error"); }
}
function printCurrentMessage() {
const detail = state.currentDetail;
if (!detail) return toast("Select a message before printing", "warning");
const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", ");
const body = detail.html ? sanitizeMessageHtml(detail.html) : `<pre>${escapeHtml(cleanPlainMessageText(detail.text || ""))}</pre>`;
const frame = document.createElement("iframe");
frame.className = "print-frame";
frame.setAttribute("aria-hidden", "true");
const html = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'"><title>${escapeHtml(detail.subject || "Message")}</title><style>body{margin:24px;color:#111;background:#fff;font:14px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}.meta{padding-bottom:14px;margin-bottom:18px;border-bottom:1px solid #bbb}.meta h1{font-size:20px;margin:0 0 12px}.meta div{margin:3px 0}.label{display:inline-block;width:58px;color:#555}img{max-width:100%;height:auto}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.55 ui-monospace,monospace}blockquote{border-left:2px solid #bbb;margin-left:0;padding-left:12px}@page{margin:14mm}@media print{body{margin:0}}</style></head><body><section class="meta"><h1>${escapeHtml(detail.subject || "(no subject)")}</h1><div><span class="label">From</span>${escapeHtml(detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address || "")}</div><div><span class="label">To</span>${escapeHtml(to)}</div><div><span class="label">Date</span>${escapeHtml(formatDate(detail.date, true))}</div></section>${body}</body></html>`;
frame.addEventListener("load", () => {
const cleanup = () => frame.remove();
try {
frame.contentWindow?.addEventListener("afterprint", cleanup, { once:true });
frame.contentWindow?.focus();
frame.contentWindow?.print();
toast("Print dialog opened", "info");
setTimeout(cleanup, 60000);
} catch (_) {
cleanup();
toast("Could not open the print dialog", "error");
}
}, { once:true });
frame.srcdoc = html;
document.body.append(frame);
}
async function copyMessageId() {
const value = state.currentDetail?.message_id;
if (!value) return toast("Message-ID is not available", "error");
try { await navigator.clipboard.writeText(value); toast("Message-ID copied"); }
catch (_) { toast("Could not copy Message-ID", "error"); }
}
async function copySenderAddress() {
const value = state.currentDetail?.from_address;
if (!value) return;
try { await navigator.clipboard.writeText(value); toast("Sender address copied"); }
catch (_) { toast("Could not copy address", "error"); }
}
async function downloadEml() {
const detail = state.currentDetail;
if (!detail) return;
try {
const blob = await fetchEmlBlob(detail);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `message-${detail.uid}.eml`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 30000);
toast("Downloading .eml", "info");
} catch (error) { toast(error.message, "error"); }
}
async function forwardAsAttachment() {
const detail = state.currentDetail;
if (!detail) return;
try {
const blob = await fetchEmlBlob(detail);
openCompose("new");
$("#compose-title").textContent = "Forward as attachment";
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`;
const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`;
const file = new File([blob], `${safe}.eml`, { type:"message/rfc822" });
await uploadComposeFiles([file]);
} catch (error) { toast(error.message, "error"); }
}
function openCurrentSenderMenu(x, y) {
const detail = state.currentDetail;
if (!detail?.from_address) return;
showContextMenu(x, y, [
{ label: "Compose to sender", action: () => { openCompose("new"); $("#compose-to").value = detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address; $("#compose-subject").value = ""; } },
{ label: "Add to contacts", action: addSenderToContacts },
{ label: "Copy address", action: copySenderAddress },
{ label: "Search messages", action: searchFromCurrentSender },
"separator",
{ label: "Create filter", action: async () => createFilterFromCurrentMessage("sender") },
]);
}
async function addSenderToContacts() {
const detail = state.currentDetail;
if (!detail?.from_address) return;
try {
await request("/contacts", { method:"POST", body:JSON.stringify({ id:null, email:detail.from_address, name:detail.from_name || "", company:"", notes:"", is_favorite:false, source:"message" }) });
toast("Sender added to contacts");
} catch (error) { toast(error.message, "error"); }
}
async function searchFromCurrentSender() {
const detail = state.currentDetail;
if (!detail?.from_address) return;
const query = `from:${detail.from_address}`;
$("#search-input").value = query;
await runSearch(new URLSearchParams({ q:query, limit:"100" }), `From: ${detail.from_address}`);
}
async function copyHeaders() {
if (!state.currentDetail?.raw_headers) return;
try { await navigator.clipboard.writeText(state.currentDetail.raw_headers); toast("Headers copied"); }
catch (_) { toast("Could not copy headers", "error"); }
}
function quickFilterSource(detail, kind) {
const sender = String(detail?.from_address || "").trim();
const domain = sender.includes("@") ? sender.split("@").pop() : sender;
const subject = String(detail?.subject || "").trim();
if (kind === "domain") return { kind:"domain", value:domain, match:"is" };
if (kind === "subject") return { kind:"subject", value:subject, match:"contains" };
return { kind:"sender", value:sender, match:"is" };
}
function buildQuickFilterRule({ kind, value, match, action, target, stop, markRead = false }) {
if (!value.trim()) throw new Error("Filter value is required");
const matchToken = match === "contains" ? ":contains" : ":is";
let condition;
if (kind === "domain") condition = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
else if (kind === "subject") condition = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
else condition = `address ${matchToken} "from" "${sieveQuote(value)}"`;
const requirements = new Set();
let command;
if (action === "move") {
if (!target) throw new Error("Choose a destination folder");
requirements.add("fileinto");
command = `fileinto "${sieveQuote(target)}";`;
} else if (action === "copy") {
if (!target) throw new Error("Choose a destination folder");
requirements.add("fileinto");
requirements.add("copy");
command = `fileinto :copy "${sieveQuote(target)}";`;
} else if (action === "mark-read") {
requirements.add("imap4flags");
command = 'addflag "\\Seen";';
} else if (action === "flag") {
requirements.add("imap4flags");
command = 'addflag "\\Flagged";';
} else if (action === "discard") command = "discard;";
else command = "keep;";
if (markRead && action !== "mark-read") requirements.add("imap4flags");
const ruleId = newSieveVisualRuleId();
const commands = [command, markRead && action !== "mark-read" ? 'addflag "\\Seen";' : "", stop ? "stop;" : ""].filter(Boolean).join("\n ");
return {
requirements:[...requirements],
rule:`# RUSTBOX-RULE-BEGIN ${ruleId}\n# Created from message\nif ${condition} {\n ${commands}\n}\n# RUSTBOX-RULE-END`,
};
}
async function prepareQuickFilterAdvanced(config) {
if ($("#settings-dialog").open) $("#settings-dialog").close();
selectSettingsTab("sieve");
$("#settings-dialog").showModal();
await loadSieve();
const scripts = state.sieveScripts || [];
const targetScript = config.script || scripts.find((script) => script.active)?.name || scripts.find((script) => script.name === "rustbox")?.name || "rustbox";
if (scripts.some((script) => script.name === targetScript)) await openSieveScript(targetScript);
else {
$("#sieve-name").value = targetScript;
$("#sieve-editor").value = "";
renderManagedSieveRules();
}
state.sieveEditingRuleId = null;
const suggestedName = config.kind === "subject" ? "subject_filter" : config.kind === "domain" ? "sender_domain" : "sender_filter";
$("#sieve-builder-name").value = suggestedName;
$("#sieve-builder-enabled").checked = true;
$("#sieve-builder-stop").checked = Boolean(config.stop);
$("#sieve-builder-condition").value = config.kind === "domain" ? "from-domain" : config.kind === "subject" ? "subject" : "from";
$("#sieve-builder-match").value = config.match;
$("#sieve-builder-value").value = config.value;
$("#sieve-builder-action").value = config.action === "flag" ? "add-flag" : config.action;
$("#sieve-builder-target").value = config.action === "flag" ? "\\Flagged" : config.target || "";
$("#sieve-builder-condition-2").value = "";
$("#sieve-builder-condition-3").value = "";
$("#sieve-builder-extra-conditions").replaceChildren();
$("#sieve-builder-action-2").value = config.markRead && config.action !== "mark-read" ? "mark-read" : "";
$("#sieve-builder-extra-actions").replaceChildren();
updateSieveBuilder();
$("#sieve-result").textContent = "Quick-filter values loaded. Review the rule, add it, validate, then save.";
showSieveRuleDialog("new");
}
async function createFilterFromCurrentMessage(kind = "sender") {
const detail = state.currentDetail;
if (!detail) return;
let sieveInfo;
let scripts;
try {
[sieveInfo, scripts] = await Promise.all([
request("/sieve/status", { method:"GET" }),
request("/sieve/scripts", { method:"GET" }),
]);
state.sieveInfo = sieveInfo;
state.sieveScripts = scripts.scripts || [];
} catch (error) {
toast(`ManageSieve unavailable: ${error.message}`, "error");
return;
}
const source = quickFilterSource(detail, kind);
const activeScript = state.sieveScripts.find((script) => script.active);
const defaultScript = activeScript?.name || state.sieveScripts.find((script) => script.name === "rustbox")?.name || "rustbox";
const defaultTarget = detectSpecialFolder("archive") || orderedFolders().find((folder) => folder.name !== (detail.folder || state.folder))?.name || "";
const extensions = new Set((sieveInfo.extensions || []).map((value) => String(value).toLowerCase()));
const dialog = el("dialog", "dialog quick-filter-dialog");
const form = el("form", "dialog-card quick-filter-card");
form.method = "dialog";
const header = el("header", "dialog-header");
header.append(el("strong", "", "Create filter from message"));
const close = el("button", "icon-button"); close.type = "button"; close.innerHTML = svgIcon("x"); close.setAttribute("aria-label", "Close");
header.append(close);
const body = el("div", "quick-filter-body");
const grid = el("div", "quick-filter-grid");
const makeField = (labelText, control) => { const label = el("label"); label.append(el("span", "", labelText), control); return label; };
const condition = document.createElement("select");
condition.add(new Option("Sender address", "sender"));
condition.add(new Option("Sender domain", "domain"));
condition.add(new Option("Subject", "subject"));
condition.value = source.kind;
const match = document.createElement("select");
match.add(new Option("is exactly", "is"));
match.add(new Option("contains", "contains"));
match.value = source.match;
const value = document.createElement("input"); value.value = source.value; value.required = true;
const action = document.createElement("select");
[
["Move to folder", "move", ["fileinto"]],
["Copy to folder", "copy", ["fileinto", "copy"]],
["Mark as read", "mark-read", ["imap4flags"]],
["Flag message", "flag", ["imap4flags"]],
["Discard", "discard", []],
["Keep", "keep", []],
].forEach(([label, actionValue, requires]) => {
const option = new Option(label, actionValue);
option.disabled = requires.some((requirement) => !extensions.has(requirement));
action.add(option);
});
if (action.options[0]?.disabled) action.value = [...action.options].find((option) => !option.disabled)?.value || "keep";
const target = document.createElement("select");
target.replaceChildren(...orderedFolders().map((folder) => new Option(folder.name, folder.name)));
target.value = defaultTarget;
const script = document.createElement("select");
const scriptNames = new Set(state.sieveScripts.map((item) => item.name));
for (const item of state.sieveScripts) script.add(new Option(`${item.name}${item.active ? " · active" : ""}`, item.name));
if (!scriptNames.has("rustbox")) script.add(new Option("rustbox · new", "rustbox"));
script.value = defaultScript;
const stop = document.createElement("input"); stop.type = "checkbox"; stop.checked = true;
const markRead = document.createElement("input"); markRead.type = "checkbox"; markRead.disabled = !extensions.has("imap4flags");
const activate = document.createElement("input"); activate.type = "checkbox"; activate.checked = !activeScript;
const targetField = makeField("Destination folder", target);
grid.append(
makeField("Match", condition), makeField("Comparison", match),
makeField("Value", value), makeField("Action", action),
targetField, makeField("Sieve script", script)
);
body.append(grid);
const toggles = el("div", "appearance-toggles quick-filter-toggles");
const stopLabel = el("label", "checkbox-row"); stopLabel.append(stop, el("span", "", "Stop processing after this rule"));
const readLabel = el("label", "checkbox-row"); readLabel.append(markRead, el("span", "", "Also mark matching mail as read"));
if (markRead.disabled) readLabel.title = "Server does not advertise imap4flags";
const activateLabel = el("label", "checkbox-row"); activateLabel.append(activate, el("span", "", "Activate selected script after saving"));
toggles.append(stopLabel, readLabel, activateLabel);
body.append(toggles);
const preview = el("div", "quick-filter-preview"); body.append(preview);
const hint = el("div", "muted quick-filter-hint"); body.append(hint);
const actions = el("footer", "dialog-actions");
const cancel = el("button", "button quiet", "Cancel"); cancel.type = "button";
const advanced = el("button", "button quiet", "Advanced editor"); advanced.type = "button";
const save = el("button", "button primary", "Create filter"); save.type = "submit";
actions.append(cancel, advanced, save);
form.append(header, body, actions); dialog.append(form); document.body.append(dialog);
const sourceValues = {
sender: quickFilterSource(detail, "sender"),
domain: quickFilterSource(detail, "domain"),
subject: quickFilterSource(detail, "subject"),
};
const update = () => {
const needsFolder = ["move", "copy"].includes(action.value);
targetField.classList.toggle("hidden", !needsFolder);
const requires = action.value === "copy" ? ["fileinto", "copy"] : action.value === "move" ? ["fileinto"] : ["mark-read", "flag"].includes(action.value) ? ["imap4flags"] : [];
const missing = requires.filter((item) => !extensions.has(item));
const conditionLabel = condition.value === "domain" ? "sender domain" : condition.value === "subject" ? "subject" : "sender";
const actionLabel = action.selectedOptions[0]?.textContent || action.value;
const displayValue = value.value.trim() || "…";
preview.textContent = `If ${conditionLabel} ${match.value === "contains" ? "contains" : "is"} “${displayValue}” → ${actionLabel}${needsFolder && target.value ? ` “${target.value}”` : ""}${markRead.checked && action.value !== "mark-read" ? " + mark as read" : ""}.`;
hint.textContent = missing.length
? `Server does not advertise: ${missing.join(", ")}`
: `Will add this rule to “${script.value}”${activate.checked ? " and activate the script" : ""}. It applies to new incoming mail; existing messages are not changed.`;
save.disabled = missing.length > 0;
};
condition.addEventListener("change", () => { const next = sourceValues[condition.value]; value.value = next.value; match.value = next.match; update(); });
match.addEventListener("change", update);
value.addEventListener("input", update);
action.addEventListener("change", update);
target.addEventListener("change", update);
script.addEventListener("change", update);
activate.addEventListener("change", update);
markRead.addEventListener("change", update);
let settled = false;
const cleanup = () => { if (settled) return; settled = true; if (dialog.open) dialog.close(); else dialog.remove(); };
close.addEventListener("click", cleanup);
cancel.addEventListener("click", cleanup);
dialog.addEventListener("cancel", (event) => { event.preventDefault(); cleanup(); });
dialog.addEventListener("close", () => dialog.remove(), { once:true });
advanced.addEventListener("click", async () => {
const config = { kind:condition.value, match:match.value, value:value.value.trim(), action:action.value, target:target.value, script:script.value, markRead:markRead.checked };
cleanup();
await prepareQuickFilterAdvanced(config);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!form.reportValidity()) return;
setBusy(save, true);
try {
const config = { kind:condition.value, match:match.value, value:value.value.trim(), action:action.value, target:target.value, stop:stop.checked, markRead:markRead.checked };
const built = buildQuickFilterRule(config);
const existing = state.sieveScripts.find((item) => item.name === script.value);
let current = "";
if (existing) current = (await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method:"GET" })).content || "";
const content = mergeSieveRequirements([current.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements);
const validation = await request("/sieve/check", { method:"POST", body:JSON.stringify({ content }) });
if (!validation.valid) throw new Error(validation.error || "Filter validation failed");
await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method:"PUT", body:JSON.stringify({ content }) });
if (activate.checked || existing?.active) await request(`/sieve/scripts/${encodeURIComponent(script.value)}/activate`, { method:"POST", body:"{}" });
cleanup();
toast(`Filter added to ${script.value}`, "success");
} catch (error) {
hint.textContent = error.message;
toast(error.message, "error");
} finally { setBusy(save, false); }
});
update();
dialog.showModal();
setTimeout(() => value.focus(), 20);
}
async function openContacts() {
$("#contacts-dialog").showModal();
await loadContactGroups();
await loadContacts();
}
async function loadContactGroups() {
try {
const data = await request("/contact-groups", { method:"GET" });
state.contactGroups = data.groups || [];
renderContactGroupFilter();
renderContactGroupMembership($("#contact-id").value || null, []);
} catch (error) { toast(error.message, "error"); }
}
function renderContactGroupFilter() {
const select = $("#contact-group-filter");
const current = select.value;
select.replaceChildren(new Option("All contacts", ""));
for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id));
if ([...select.options].some((option) => option.value === current)) select.value = current;
const hasGroup = Boolean(select.value);
$("#contact-group-rename").disabled = !hasGroup;
$("#contact-group-delete").disabled = !hasGroup;
}
async function downloadContactExport() {
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers });
if (!response.ok) throw new Error(`Export failed (${response.status})`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "contacts.vcf";
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast("Calendar exported", "success");
} catch (error) { toast(error.message, "error"); }
}
async function importContactVcard(files) {
const file = files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error");
const form = new FormData();
form.append("file", file, file.name || "contacts.vcf");
try {
const data = await request("/contacts/import", { method: "POST", body: form });
toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`);
await loadContacts($("#contact-search").value.trim());
} catch (error) { toast(error.message, "error"); }
finally { $("#contact-import-file").value = ""; }
}
async function showContactDuplicates() {
try {
const data = await request("/contacts/duplicates", { method:"GET" });
const groups = data.duplicates || [];
const list = $("#contact-duplicate-list");
list.replaceChildren();
if (!groups.length) {
list.append(el("div", "empty-list", "No duplicate contacts found"));
}
for (const group of groups) {
const card = el("section", "duplicate-contact-group");
card.append(el("strong", "", group.email));
const rows = el("div", "duplicate-contact-options");
for (const contact of group.contacts || []) {
const row = el("div", "duplicate-contact-row");
const summary = el("div", "duplicate-contact-summary");
summary.append(el("strong", "", contact.name || contact.email));
summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
const keep = el("button", "button quiet", "Keep this");
keep.type = "button";
keep.addEventListener("click", async () => {
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
if (!sources.length) return;
if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title:"Merge duplicate contacts", confirmLabel:"Merge" })) return;
try {
let target = contact;
for (const source of sources) {
target = await request("/contacts/merge", { method:"POST", body:JSON.stringify({ target_id:target.id, source_id:source.id }) });
}
toast("Contacts merged");
await loadContacts($("#contact-search").value.trim());
await showContactDuplicates();
editContact(target);
} catch (error) { toast(error.message, "error"); }
});
row.append(summary, keep); rows.append(row);
}
card.append(rows); list.append(card);
}
if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal();
} catch (error) { toast(error.message, "error"); }
}
async function loadContacts(query = "") {
try {
const groupId = $("#contact-group-filter")?.value || "";
let contacts;
if (groupId) {
const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method:"GET" });
contacts = data.members || [];
if (query) { const needle=query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); }
} else {
const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method:"GET" });
contacts = data.contacts || [];
}
state.contacts = contacts;
renderContacts();
} catch (error) { toast(error.message, "error"); }
}
async function createContactGroup() {
const name = await appPrompt("New contact group", { placeholder:"Group name", confirmLabel:"Create", required:true });
if (!name?.trim()) return;
try { await request("/contact-groups", { method:"POST", body:JSON.stringify({ name:name.trim() }) }); await loadContactGroups(); toast("Group created"); }
catch (error) { toast(error.message, "error"); }
}
async function renameContactGroup() {
const id = $("#contact-group-filter").value;
const group = state.contactGroups.find((item) => item.id === id);
if (!group) return;
const name = await appPrompt("Rename contact group", { value:group.name, confirmLabel:"Rename", required:true });
if (!name?.trim() || name.trim() === group.name) return;
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method:"PUT", body:JSON.stringify({ name:name.trim() }) }); await loadContactGroups(); toast("Group renamed"); }
catch (error) { toast(error.message, "error"); }
}
async function deleteContactGroup() {
const id = $("#contact-group-filter").value;
const group = state.contactGroups.find((item) => item.id === id);
if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title:"Delete contact group", confirmLabel:"Delete", danger:true })) return;
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method:"DELETE" }); $("#contact-group-filter").value=""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); }
catch (error) { toast(error.message, "error"); }
}
async function loadContactMembership(contactId) {
if (!contactId) return renderContactGroupMembership(null, []);
try {
const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method:"GET" });
renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id));
} catch (error) { toast(error.message, "error"); }
}
function renderContactGroupMembership(contactId, selectedIds) {
const list = $("#contact-group-membership");
if (!list) return;
const selected = new Set(selectedIds || []);
list.replaceChildren();
if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; }
for (const group of state.contactGroups) {
const label = el("label", "checkbox-row");
const input = document.createElement("input"); input.type="checkbox"; input.checked=selected.has(group.id); input.disabled=!contactId;
input.addEventListener("change", async () => {
try {
if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method:"POST", body:JSON.stringify({ contact_id:contactId }) });
else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method:"DELETE" });
await loadContactGroups();
} catch (error) { input.checked=!input.checked; toast(error.message, "error"); }
});
label.append(input, el("span", "", group.name)); list.append(label);
}
}
function renderContacts() {
const list = $("#contact-list");
list.replaceChildren();
if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts"));
for (const contact of state.contacts) {
const button = el("button", "contact-item");
button.type = "button";
button.innerHTML = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`;
button.addEventListener("click", () => editContact(contact));
list.append(button);
}
}
function editContact(contact = null) {
$("#contact-id").value = contact?.id || "";
$("#contact-name").value = contact?.name || "";
$("#contact-email").value = contact?.email || "";
$("#contact-emails").value = (contact?.emails || []).join("\n");
$("#contact-company").value = contact?.company || "";
$("#contact-phone").value = contact?.phone || "";
$("#contact-phones").value = (contact?.phones || []).join("\n");
$("#contact-job-title").value = contact?.job_title || "";
$("#contact-website").value = contact?.website || "";
$("#contact-birthday").value = contact?.birthday || "";
$("#contact-address").value = contact?.address || "";
$("#contact-addresses").value = (contact?.addresses || []).join("\n");
$("#contact-notes").value = contact?.notes || "";
$("#contact-favorite").checked = Boolean(contact?.is_favorite);
$("#contact-delete").disabled = !contact;
$("#contact-compose").disabled = !contact?.email;
$("#contact-photo-upload").disabled = !contact?.id;
$("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo;
renderContactPhoto(contact);
loadContactMembership(contact?.id || null);
}
function clearContactPhotoObjectUrl() {
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl);
state.contactPhotoUrl = null;
}
async function renderContactPhoto(contact) {
clearContactPhotoObjectUrl();
const image = $("#contact-photo-preview");
const placeholder = $("#contact-photo-placeholder");
image.classList.add("hidden");
image.removeAttribute("src");
placeholder.classList.remove("hidden");
if (!contact?.id || !contact?.has_photo) return;
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials:"include", headers });
if (!response.ok) return;
const blob = await response.blob();
state.contactPhotoUrl = URL.createObjectURL(blob);
image.src = state.contactPhotoUrl;
image.classList.remove("hidden");
placeholder.classList.add("hidden");
} catch (_) {}
}
async function uploadContactPhoto(files) {
const id = $("#contact-id").value;
const file = files?.[0];
if (!id || !file) return;
if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value=""; return; }
const form = new FormData();
form.append("file", file, file.name || "photo");
try {
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method:"POST", body:form });
const contact = state.contacts.find((item) => item.id === id);
if (contact) contact.has_photo = true;
$("#contact-photo-delete").disabled = false;
await renderContactPhoto({ id, has_photo:true });
toast("Contact photo saved");
} catch (error) { toast(error.message, "error"); }
finally { $("#contact-photo-file").value=""; }
}
async function deleteContactPhoto() {
const id = $("#contact-id").value;
if (!id) return;
try {
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method:"DELETE" });
const contact = state.contacts.find((item) => item.id === id);
if (contact) contact.has_photo = false;
$("#contact-photo-delete").disabled = true;
await renderContactPhoto({ id, has_photo:false });
toast("Contact photo removed");
} catch (error) { toast(error.message, "error"); }
}
async function saveContact(event) {
event.preventDefault();
const payload = {
id: $("#contact-id").value || null,
name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(),
phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(),
birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value,
is_favorite: $("#contact-favorite").checked, source:"manual",
};
try { const saved = await request("/contacts", { method:"POST", body:JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); }
catch (error) { toast(error.message, "error"); }
}
async function deleteContact() {
const id = $("#contact-id").value;
if (!id || !await appConfirm("Delete this contact?", { title:"Delete contact", confirmLabel:"Delete", danger:true })) return;
try { await request(`/contacts/${encodeURIComponent(id)}`, { method:"DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); }
catch (error) { toast(error.message, "error"); }
}
function composeToContact() {
const email = $("#contact-email").value.trim();
if (!email) return;
$("#contacts-dialog").close();
openCompose("new");
$("#compose-to").value = email;
}
function startOfCalendarDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function addCalendarDays(date, count) {
const next = new Date(date);
next.setDate(next.getDate() + count);
return next;
}
function startOfCalendarWeek(date) {
const start = startOfCalendarDay(date);
const day = start.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
return addCalendarDays(start, mondayOffset);
}
function calendarRange(date, view = state.calendarView) {
if (view === "week") {
const start = startOfCalendarWeek(date);
return { start, end: addCalendarDays(start, 7) };
}
if (view === "day") {
const start = startOfCalendarDay(date);
return { start, end: addCalendarDays(start, 1) };
}
if (view === "agenda") {
const start = startOfCalendarDay(date);
return { start, end: addCalendarDays(start, 30) };
}
const first = new Date(date.getFullYear(), date.getMonth(), 1);
const start = startOfCalendarWeek(first);
return { start, end: addCalendarDays(start, 42) };
}
function calendarDayKey(value) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,"0")}-${String(date.getDate()).padStart(2,"0")}`;
}
function calendarRangeLabel() {
const cursor = state.calendarCursor;
if (state.calendarView === "month") return cursor.toLocaleDateString([], { month:"long", year:"numeric" });
if (state.calendarView === "day") return cursor.toLocaleDateString([], { weekday:"long", year:"numeric", month:"long", day:"numeric" });
const { start, end } = calendarRange(cursor);
const last = addCalendarDays(end, -1);
if (state.calendarView === "agenda") return `${start.toLocaleDateString()} – ${last.toLocaleDateString()}`;
return `${start.toLocaleDateString([], { month:"short", day:"numeric" })} – ${last.toLocaleDateString([], { month:"short", day:"numeric", year:"numeric" })}`;
}
function shiftCalendarCursor(direction) {
const cursor = new Date(state.calendarCursor);
if (state.calendarView === "month") cursor.setMonth(cursor.getMonth() + direction);
else if (state.calendarView === "week") cursor.setDate(cursor.getDate() + (7 * direction));
else if (state.calendarView === "agenda") cursor.setDate(cursor.getDate() + (30 * direction));
else cursor.setDate(cursor.getDate() + direction);
state.calendarCursor = cursor;
}
function toLocalInput(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0,16);
}
async function importCalendarIcs(files) {
const file = files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("ICS file is too large", "error");
try {
const text = await file.text();
const headers = new Headers({ "Content-Type": "text/calendar; charset=utf-8", "X-Requested-With": "XMLHttpRequest" });
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath("/calendar/events/import-ics"), { method:"POST", credentials:"include", headers, body:text });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data?.error?.message || `ICS import failed (${response.status})`);
toast(`ICS import: ${data.created || 0} created, ${data.skipped || 0} skipped`);
await loadCalendar();
} catch (error) { toast(error.message, "error"); }
finally { $("#calendar-import-file").value = ""; }
}
async function exportCalendarIcs() {
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath("/calendar/events/export-ics"), { credentials:"include", headers });
if (!response.ok) throw new Error(`Calendar export failed (${response.status})`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "rustbox-calendar.ics";
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (error) { toast(error.message, "error"); }
}
async function openCalendar() {
$("#calendar-dialog").showModal();
await loadCalendar();
}
async function loadCalendar() {
const { start, end } = calendarRange(state.calendarCursor);
$("#calendar-view").value = state.calendarView;
$("#calendar-range").textContent = calendarRangeLabel();
try {
const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method:"GET" });
state.calendarEvents = data.events || [];
renderCalendarEvents();
} catch (error) { toast(error.message, "error"); }
}
function createCalendarEventButton(event, compact = false) {
const button = el("button", compact ? "calendar-event-chip" : "calendar-event");
button.type = "button";
const color = /^#[0-9a-f]{6}$/i.test(String(event.color || "")) ? event.color : "var(--accent)";
button.style.setProperty("--event-color", color);
if (compact) {
const time = event.all_day ? "All day" : new Date(event.start_time).toLocaleTimeString([], { hour:"2-digit", minute:"2-digit" });
button.innerHTML = `<span>${escapeHtml(time)}</span><strong>${escapeHtml(event.title)}</strong>`;
} else {
button.innerHTML = `<time>${escapeHtml(formatDate(event.start_time, true))}</time><div><strong>${escapeHtml(event.title)}</strong><small>${escapeHtml(event.location || "")}</small></div><span>${escapeHtml(event.status || "")}</span>`;
}
button.addEventListener("click", () => editCalendarEvent(event));
return button;
}
function renderCalendarMonthOrWeek(view) {
const host = $("#calendar-events");
const { start, end } = calendarRange(state.calendarCursor, view);
const days = view === "week" ? 7 : 42;
const grid = el("div", `calendar-grid calendar-${view}-grid`);
for (const label of ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]) grid.append(el("div", "calendar-weekday", label));
const byDay = new Map();
for (const event of state.calendarEvents) {
const key = calendarDayKey(event.start_time);
if (!byDay.has(key)) byDay.set(key, []);
byDay.get(key).push(event);
}
for (let i=0; i<days; i++) {
const date = addCalendarDays(start, i);
const cell = el("section", "calendar-day-cell");
if (view === "month" && date.getMonth() !== state.calendarCursor.getMonth()) cell.classList.add("outside-month");
if (calendarDayKey(date) === calendarDayKey(new Date())) cell.classList.add("today");
const heading = el("button", "calendar-day-number", String(date.getDate()));
heading.type = "button";
heading.title = "Create event on this day";
heading.addEventListener("click", () => {
const startDate = new Date(date); startDate.setHours(9,0,0,0);
const endDate = new Date(startDate.getTime()+3600000);
editCalendarEvent(null);
$("#calendar-start").value = toLocalInput(startDate);
$("#calendar-end").value = toLocalInput(endDate);
});
cell.append(heading);
const dayEvents = (byDay.get(calendarDayKey(date)) || []).sort((a,b) => new Date(a.start_time)-new Date(b.start_time));
for (const event of dayEvents.slice(0, view === "month" ? 4 : 12)) cell.append(createCalendarEventButton(event, true));
if (view === "month" && dayEvents.length > 4) cell.append(el("span", "calendar-more", `+${dayEvents.length-4} more`));
grid.append(cell);
}
host.append(grid);
}
function renderCalendarAgenda() {
const host = $("#calendar-events");
const groups = new Map();
for (const event of state.calendarEvents) {
const key = calendarDayKey(event.start_time);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(event);
}
if (!groups.size) { host.append(el("div", "empty-list", "No events in this range")); return; }
for (const [key, events] of [...groups.entries()].sort(([a],[b]) => a.localeCompare(b))) {
const date = new Date(`${key}T12:00:00`);
const section = el("section", "calendar-agenda-day");
section.append(el("h4", "", date.toLocaleDateString([], { weekday:"long", month:"long", day:"numeric", year:"numeric" })));
for (const event of events.sort((a,b) => new Date(a.start_time)-new Date(b.start_time))) section.append(createCalendarEventButton(event));
host.append(section);
}
}
function renderCalendarEvents() {
const list = $("#calendar-events");
list.replaceChildren();
if (state.calendarView === "month" || state.calendarView === "week") {
renderCalendarMonthOrWeek(state.calendarView);
return;
}
if (!state.calendarEvents.length) {
list.append(el("div", "empty-list", state.calendarView === "day" ? "No events today" : "No events in this range"));
return;
}
renderCalendarAgenda();
}
function updateCalendarRecurrenceUi() {
$("#calendar-rrule-wrap").classList.toggle("hidden", $("#calendar-recurrence").value !== "custom");
}
function editCalendarEvent(event = null) {
$("#calendar-form").classList.remove("hidden");
$("#calendar-event-id").value = event?.id || "";
$("#calendar-title").value = event?.title || "";
$("#calendar-start").value = event ? toLocalInput(event.start_time) : toLocalInput(new Date());
$("#calendar-end").value = event ? toLocalInput(event.end_time) : toLocalInput(new Date(Date.now()+3600000));
$("#calendar-all-day").checked = Boolean(event?.all_day);
const rule = event?.recurrence_rule || "";
const commonRules = new Set(["", "FREQ=DAILY", "FREQ=WEEKLY", "FREQ=MONTHLY", "FREQ=YEARLY"]);
$("#calendar-recurrence").value = commonRules.has(rule) ? rule : "custom";
$("#calendar-rrule").value = commonRules.has(rule) ? "" : rule;
updateCalendarRecurrenceUi();
let attendees = [];
try { attendees = JSON.parse(event?.attendees || "[]"); } catch (_) {}
$("#calendar-attendees").value = Array.isArray(attendees) ? attendees.join(", ") : "";
$("#calendar-organizer").value = event?.organizer || state.accounts.find((item) => item.id === state.accountId)?.email || "";
$("#calendar-status").value = String(event?.status || "confirmed").toLowerCase();
$("#calendar-location").value = event?.location || "";
$("#calendar-meeting-url").value = event?.meeting_url || "";
$("#calendar-description").value = event?.description || "";
const color = String(event?.color || "");
$("#calendar-color").value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#6b7280";
const reminder = event?.reminder_minutes == null ? "" : String(event.reminder_minutes);
$("#calendar-reminder").value = Array.from($("#calendar-reminder").options).some((option) => option.value === reminder) ? reminder : "";
$("#calendar-delete").disabled = !event;
}
async function saveCalendarEvent(event) {
event.preventDefault();
const id = $("#calendar-event-id").value;
const recurrenceChoice = $("#calendar-recurrence").value;
const recurrenceRule = recurrenceChoice === "custom" ? $("#calendar-rrule").value.trim() : recurrenceChoice;
const attendees = $("#calendar-attendees").value.split(",").map((value) => value.trim()).filter(Boolean);
const startValue = $("#calendar-start").value;
const endValue = $("#calendar-end").value;
if (!startValue || !endValue) return toast("Start and end are required", "error");
const base = {
title:$("#calendar-title").value.trim(),
description:$("#calendar-description").value,
location:$("#calendar-location").value.trim(),
start_time:new Date(startValue).toISOString(),
end_time:new Date(endValue).toISOString(),
all_day:$("#calendar-all-day").checked,
recurrence_rule:recurrenceRule || null,
attendees:JSON.stringify(attendees),
organizer:$("#calendar-organizer").value.trim(),
status:$("#calendar-status").value || "confirmed",
meeting_url:$("#calendar-meeting-url").value.trim() || null,
color:$("#calendar-color").value || null,
reminder_minutes:$("#calendar-reminder").value === "" ? null : Number($("#calendar-reminder").value),
};
const payload = id ? base : { ...base, source:"manual", source_uid:null, source_message_uid:null, source_message_folder:null };
try { await request(id ? `/calendar/events/${encodeURIComponent(id)}` : "/calendar/events", { method:id ? "PUT" : "POST", body:JSON.stringify(payload) }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event saved"); }
catch (error) { toast(error.message, "error"); }
}
async function deleteCalendarEvent() {
const id = $("#calendar-event-id").value;
if (!id || !await appConfirm("Delete this event?", { title:"Delete event", confirmLabel:"Delete", danger:true })) return;
try { await request(`/calendar/events/${encodeURIComponent(id)}`, { method:"DELETE" }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event deleted"); }
catch (error) { toast(error.message, "error"); }
}
async function loadOpenPgp() {
try {
const [keys, prefs] = await Promise.all([
request("/pgp/keys", { method:"GET" }),
request("/pgp/preferences", { method:"GET" }),
]);
state.pgpKeys = Array.isArray(keys) ? keys : [];
state.pgpPrefs = { ...state.pgpPrefs, ...(prefs || {}) };
renderOpenPgp();
renderIdentityPgpOptions();
updateComposePgpUi();
if (state.currentDetail) renderPgpReaderState(state.currentDetail);
} catch (error) {
state.pgpKeys = [];
updateComposePgpUi();
if (state.currentDetail) renderPgpReaderState(state.currentDetail);
toast(error.message, "error");
}
}
function pgpFingerprintLabel(value) {
const fp = String(value || "").replace(/\s+/g, "").toUpperCase();
return fp ? fp.replace(/(.{4})/g, "$1 ").trim() : "";
}
function renderOpenPgp() {
if (!$("#pgp-key-list")) return;
$("#pgp-pref-wkd").checked = Boolean(state.pgpPrefs.wkdEnabled);
$("#pgp-pref-hkp").checked = Boolean(state.pgpPrefs.hkpEnabled);
$("#pgp-pref-trusted").checked = Boolean(state.pgpPrefs.requireTrustedEncryption);
const discoveryEnabled = Boolean(state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled);
if ($("#pgp-discover")) {
$("#pgp-discover").disabled = !discoveryEnabled;
$("#pgp-discover").title = discoveryEnabled ? "" : "Enable WKD or the HTTPS key server first";
}
$("#pgp-key-summary").textContent = `${state.pgpKeys.length} key${state.pgpKeys.length === 1 ? "" : "s"} · ${state.pgpKeys.filter((key) => key.hasSecret).length} private`;
const list = $("#pgp-key-list");
list.replaceChildren();
if (!state.pgpKeys.length) list.append(el("div", "empty-list", "No OpenPGP keys yet"));
for (const key of state.pgpKeys) {
const card = el("article", "pgp-key-card");
const head = el("div", "pgp-key-head");
const title = el("div");
title.innerHTML = `<strong>${escapeHtml(key.userId || key.email || key.keyId)}</strong><small>${escapeHtml(key.email || "No email")} · ${key.hasSecret ? "private + public" : "public"} · ${escapeHtml(key.source || "import")}</small>`;
const trust = document.createElement("select");
trust.setAttribute("aria-label", `Trust for ${key.email || key.keyId}`);
for (const value of ["unknown","verified","trusted","untrusted"]) trust.add(new Option(value[0].toUpperCase() + value.slice(1), value));
trust.value = key.trust || "unknown";
trust.addEventListener("change", async () => {
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/trust`, { method:"PATCH", body:JSON.stringify({ trust:trust.value }) }); await loadOpenPgp(); toast("OpenPGP trust updated", "success"); }
catch (error) { toast(error.message, "error"); }
});
head.append(title, trust);
const fp = el("code", "pgp-fingerprint", pgpFingerprintLabel(key.fingerprint));
fp.title = "Fingerprint";
const actions = el("div", "inline-actions");
const pub = el("button", "button quiet small", "Export public"); pub.type="button"; pub.addEventListener("click", () => downloadPgpKey(key, false)); actions.append(pub);
if (key.hasSecret) {
const test = el("button", "button quiet small", "Test passphrase"); test.type="button"; test.addEventListener("click", async () => {
const passphrase = await appPrompt("Test OpenPGP passphrase", { message:"Enter the passphrase for this private key. It is used only for this test and is not stored.", type:"password", confirmLabel:"Test" });
if (passphrase === null) return;
setBusy(test, true);
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/test-passphrase`, { method:"POST", body:JSON.stringify({ passphrase }) }); toast("OpenPGP private key unlocked successfully", "success"); }
catch (error) { toast(error.message, "error"); } finally { setBusy(test, false); }
}); actions.append(test);
const change = el("button", "button quiet small", "Change passphrase"); change.type="button"; change.addEventListener("click", async () => {
const currentPassphrase = await appPrompt("Current OpenPGP passphrase", { message:"Enter the current passphrase. Leave it empty if the key is currently unprotected.", type:"password", confirmLabel:"Continue" });
if (currentPassphrase === null) return;
const newPassphrase = await appPrompt("New OpenPGP passphrase", { message:"Enter the new passphrase. Leave it empty only if you intentionally want to remove passphrase protection.", type:"password", confirmLabel:"Change" });
if (newPassphrase === null) return;
if (newPassphrase === "" && !await appConfirm("Remove passphrase protection from this private key? The key file will still be protected by server filesystem permissions, but anyone who obtains it could use it immediately.", { title:"Remove OpenPGP passphrase", confirmLabel:"Remove passphrase", danger:true })) return;
setBusy(change, true);
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/change-passphrase`, { method:"POST", body:JSON.stringify({ currentPassphrase, newPassphrase }) }); toast("OpenPGP passphrase changed", "success"); }
catch (error) { toast(error.message, "error"); } finally { setBusy(change, false); }
}); actions.append(change);
const sec = el("button", "button quiet small", "Export private"); sec.type="button"; sec.addEventListener("click", async () => { if (await appConfirm("Export the private OpenPGP key? Anyone with this file and its passphrase may use your identity.", { title:"Export private key", confirmLabel:"Export" })) downloadPgpKey(key, true); }); actions.append(sec);
}
const del = el("button", "button quiet small danger", "Delete"); del.type="button"; del.addEventListener("click", async () => { if (!await appConfirm(`Delete OpenPGP key ${key.keyId}?`, { title:"Delete OpenPGP key", confirmLabel:"Delete", danger:true })) return; try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}`, { method:"DELETE" }); await loadOpenPgp(); await loadIdentities(); toast("OpenPGP key deleted", "success"); } catch (error) { toast(error.message,"error"); } }); actions.append(del);
card.append(head, fp, actions);
list.append(card);
}
}
async function downloadPgpKey(key, secret) {
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/export?secret=${secret ? "true" : "false"}`), { credentials:"include", headers });
if (!response.ok) throw new Error(await response.text() || `Export failed (${response.status})`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); a.href=url; a.download=`openpgp-${key.keyId}-${secret ? "private" : "public"}.asc`; a.click();
setTimeout(() => URL.revokeObjectURL(url), 30000);
} catch (error) { toast(error.message, "error"); }
}
async function savePgpPreferences() {
try {
state.pgpPrefs = await request("/pgp/preferences", { method:"PUT", body:JSON.stringify({ wkdEnabled:$("#pgp-pref-wkd").checked, hkpEnabled:$("#pgp-pref-hkp").checked, requireTrustedEncryption:$("#pgp-pref-trusted").checked }) });
renderOpenPgp();
updateComposePgpUi();
if (state.currentDetail) renderPgpReaderState(state.currentDetail);
toast("OpenPGP policy saved", "success");
} catch (error) { toast(error.message, "error"); }
}
async function generatePgpKey() {
const button = $("#pgp-generate"); setBusy(button, true);
try {
const key = await request("/pgp/keys/generate", { method:"POST", body:JSON.stringify({ name:$("#pgp-generate-name").value.trim(), email:$("#pgp-generate-email").value.trim(), passphrase:$("#pgp-generate-passphrase").value }) });
$("#pgp-generate-passphrase").value=""; await loadOpenPgp(); toast(`Generated OpenPGP key ${key.keyId}`, "success");
} catch (error) { toast(error.message,"error"); } finally { setBusy(button,false); }
}
async function importPgpText(text = $("#pgp-import-data").value) {
if (!String(text || "").trim()) return toast("Paste or choose an OpenPGP key first", "warning");
try { const key = await request("/pgp/keys", { method:"POST", body:JSON.stringify({ keyData:String(text), trust:$("#pgp-import-trust").value || "unknown" }) }); $("#pgp-import-data").value=""; await loadOpenPgp(); toast(`Imported OpenPGP key ${key.keyId}`, "success"); }
catch (error) { toast(error.message,"error"); }
}
function bytesToBase64(bytes) {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length)));
}
return btoa(binary);
}
async function importPgpFile(file) {
if (!file) return;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
if (!bytes.length) return toast("The selected OpenPGP key file is empty", "warning");
const key = await request("/pgp/keys", {
method:"POST",
body:JSON.stringify({ keyDataBase64:bytesToBase64(bytes), trust:$("#pgp-import-trust").value || "unknown" }),
});
$("#pgp-import-data").value="";
await loadOpenPgp();
toast(`Imported OpenPGP key ${key.keyId}`, "success");
} catch (error) { toast(error.message,"error"); }
}
async function discoverPgpEmail(email = $("#pgp-discover-email").value.trim(), quiet = false) {
if (!email) return null;
try {
const result = await request("/pgp/discover", { method:"POST", body:JSON.stringify({ email }) });
if (result.found) { await loadOpenPgp(); if (!quiet) toast(`OpenPGP key discovered via ${(result.source || "discovery").toUpperCase()}`, "success"); return result.key; }
if (!quiet) toast(`No OpenPGP key found for ${email}`, "warning");
return null;
} catch (error) { if (!quiet) toast(error.message,"error"); return null; }
}
function renderIdentityPgpOptions(selectedFingerprint = null) {
const select = $("#identity-pgp-key");
if (!select) return;
const value = selectedFingerprint ?? select.value;
select.replaceChildren(new Option("Automatic / none", ""));
for (const key of state.pgpKeys.filter((item) => item.hasSecret)) {
select.add(new Option(`${key.email || key.userId || key.keyId} · ${key.keyId}`, key.fingerprint));
}
select.value = value || "";
}
function pgpModeForIdentity(identity) {
if (!identity) return "none";
if (identity.pgp_sign_default && identity.pgp_encrypt_default) return "sign-encrypt";
if (identity.pgp_encrypt_default) return "encrypt";
if (identity.pgp_sign_default) return "sign";
return "none";
}
function updateComposePgpUi(applyDefaults = false) {
const modeSelect = $("#compose-pgp-mode");
const panel = $("#compose-pgp-panel");
const option = $("#compose-pgp-option");
if (!modeSelect || !panel) return;
const configured = state.pgpKeys.length > 0;
option?.classList.toggle("hidden", !configured);
panel.classList.toggle("hidden", !configured);
if (!configured) {
panel.open = false;
modeSelect.value = "none";
$("#compose-pgp-inline").checked = false;
$("#compose-pgp-attach-key").checked = false;
$("#compose-pgp-attach-recipient-keys").checked = false;
state.pgpRecipientStatus = [];
return;
}
const identity = selectedIdentity();
const accountEmail = state.accounts.find((item) => item.id === state.accountId)?.email || "";
const senderEmail = identity?.email || accountEmail;
const selectedKey = identity?.pgp_fingerprint ? state.pgpKeys.find((key) => key.fingerprint === identity.pgp_fingerprint) : null;
const senderPublicKey = selectedKey || state.pgpKeys.find((key) => key.email?.toLowerCase() === senderEmail.toLowerCase());
const signingKey = selectedKey?.hasSecret ? selectedKey : state.pgpKeys.find((key) => key.hasSecret && key.email?.toLowerCase() === senderEmail.toLowerCase());
if (applyDefaults) {
modeSelect.value = pgpModeForIdentity(identity);
$("#compose-pgp-inline").checked = Boolean(identity?.pgp_inline_default);
panel.open = false;
}
const hasSigningKey = Boolean(signingKey?.hasSecret);
for (const value of ["sign", "sign-encrypt"]) {
const modeOption = [...modeSelect.options].find((item) => item.value === value);
if (modeOption) modeOption.disabled = !hasSigningKey;
}
if (!hasSigningKey && ["sign", "sign-encrypt"].includes(modeSelect.value)) modeSelect.value = modeSelect.value === "sign-encrypt" ? "encrypt" : "none";
const activeMode = modeSelect.value || "none";
const needsSigning = activeMode === "sign" || activeMode === "sign-encrypt";
$("#compose-pgp-passphrase-row").classList.toggle("hidden", !needsSigning);
const inline = $("#compose-pgp-inline");
if (inline) inline.disabled = activeMode === "none" || Boolean(state.composeAttachments.length);
if (inline?.disabled) inline.checked = false;
const senderRow = $("#compose-pgp-attach-sender-row");
senderRow?.classList.toggle("hidden", !senderPublicKey);
if (!senderPublicKey) $("#compose-pgp-attach-key").checked = false;
const discoveryEnabled = Boolean(state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled);
const discoverButton = $("#compose-pgp-discover");
if (discoverButton) {
discoverButton.disabled = !discoveryEnabled;
discoverButton.title = discoveryEnabled ? "" : "Enable WKD or the HTTPS key server in OpenPGP settings first";
}
const recipientAttach = $("#compose-pgp-attach-recipient-keys");
const parts = [];
if (needsSigning) parts.push(`Signing key ${signingKey.keyId}`);
if (activeMode.includes("encrypt")) parts.push(state.pgpRecipientStatus.length ? recipientPgpSummary(state.pgpRecipientStatus) : "Recipient keys not checked yet");
if (recipientAttach?.checked) {
const available = state.pgpRecipientStatus.filter((item) => item.hasKey).length;
parts.push(state.pgpRecipientStatus.length ? `${available} visible recipient key${available === 1 ? "" : "s"} will be attached` : "Recipient keys to attach not checked yet");
}
if (!parts.length) parts.push(senderPublicKey ? `OpenPGP ready · ${senderPublicKey.keyId}` : "OpenPGP public keys available");
$("#compose-pgp-status").textContent = parts.join(" · ");
}
function recipientPgpSummary(statuses) {
const missing = statuses.filter((item) => !item.hasKey);
const untrusted = statuses.filter((item) => item.hasKey && item.trust === "untrusted");
const notTrusted = state.pgpPrefs.requireTrustedEncryption
? statuses.filter((item) => item.hasKey && !["trusted", "verified"].includes(item.trust))
: [];
if (missing.length) return `${missing.length} recipient key${missing.length === 1 ? "" : "s"} missing`;
if (untrusted.length) return `${untrusted.length} recipient key${untrusted.length === 1 ? "" : "s"} marked untrusted`;
if (notTrusted.length) return `${notTrusted.length} recipient key${notTrusted.length === 1 ? " is" : "s are"} not trusted by policy`;
return `Keys available for ${statuses.length} recipient${statuses.length === 1 ? "" : "s"}`;
}
function pgpRecipientStatusHasWarning(item) {
if (!item.hasKey || item.trust === "untrusted") return true;
return Boolean(state.pgpPrefs.requireTrustedEncryption && !["trusted", "verified"].includes(item.trust));
}
function composePgpVisibleRecipients() {
return [...parseRecipients($("#compose-to").value), ...parseRecipients($("#compose-cc").value)];
}
function composePgpRecipients() {
return [...composePgpVisibleRecipients(), ...parseRecipients($("#compose-bcc").value)];
}
async function checkComposePgpRecipients(showToast = true, emails = composePgpRecipients()) {
if (!emails.length) { state.pgpRecipientStatus=[]; updateComposePgpUi(); return []; }
try {
const statuses = await request("/pgp/recipients", { method:"POST", body:JSON.stringify({ emails }) });
state.pgpRecipientStatus = statuses || [];
updateComposePgpUi();
if (showToast) toast(recipientPgpSummary(state.pgpRecipientStatus), state.pgpRecipientStatus.some(pgpRecipientStatusHasWarning) ? "warning" : "success");
return state.pgpRecipientStatus;
} catch (error) { if (showToast) toast(error.message,"error"); return []; }
}
async function discoverComposePgpRecipients() {
const statuses = await checkComposePgpRecipients(false);
const missing = statuses.filter((item) => !item.hasKey).map((item) => item.email);
if (!missing.length) return toast("All recipient OpenPGP keys are already available", "success");
let found = 0;
for (const email of missing) if (await discoverPgpEmail(email, true)) found += 1;
await checkComposePgpRecipients(false);
toast(found ? `Discovered ${found} OpenPGP key${found === 1 ? "" : "s"}` : "No missing OpenPGP keys were discovered", found ? "success" : "warning");
}
async function loadIdentities() {
try {
const data = await request("/identities", { method:"GET" });
state.identities = data.identities || [];
renderIdentities();
renderComposeIdentities();
renderIdentityPgpOptions();
updateComposePgpUi();
} catch (error) { toast(error.message, "error"); }
}
function renderComposeIdentities() {
const select = $("#compose-from");
if (!select) return;
select.replaceChildren(new Option("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);
}
}
function renderIdentities() {
const list = $("#identity-list");
if (!list) return;
list.replaceChildren();
if (!state.identities.length) list.append(el("div", "empty-list", "No identities yet"));
for (const identity of state.identities) {
const button = el("button", "identity-item");
button.type = "button";
button.innerHTML = `<strong>${escapeHtml(identity.display_name || identity.email)}</strong><small>${escapeHtml(identity.email)}${identity.is_default ? " · default" : ""}</small>`;
button.addEventListener("click", () => editIdentity(identity));
list.append(button);
}
}
function editIdentity(identity = null) {
$("#identity-id").value = identity?.id || "";
$("#identity-name").value = identity?.display_name || "";
$("#identity-email").value = identity?.email || "";
$("#identity-reply-to").value = identity?.reply_to || "";
$("#identity-bcc").value = identity?.bcc || "";
$("#identity-organization").value = identity?.organization || "";
$("#identity-signature-text").value = identity?.signature_text || "";
$("#identity-signature").innerHTML = sanitizeMessageHtml(identity?.signature_html || "");
$("#identity-signature-position").value = identity?.signature_position || "above";
renderIdentityPgpOptions(identity?.pgp_fingerprint || "");
$("#identity-pgp-sign").checked = Boolean(identity?.pgp_sign_default);
$("#identity-pgp-encrypt").checked = Boolean(identity?.pgp_encrypt_default);
$("#identity-pgp-inline").checked = Boolean(identity?.pgp_inline_default);
$("#identity-signature-new").checked = identity?.signature_new !== false;
$("#identity-signature-reply").checked = identity?.signature_reply !== false;
$("#identity-signature-forward").checked = identity?.signature_forward !== false;
$("#identity-default").checked = Boolean(identity?.is_default);
$("#identity-delete").disabled = !identity;
$("#identity-dialog-title").textContent = identity ? `Edit identity · ${identity.email}` : "New identity & signature";
const pgpConfigured = state.pgpKeys.length > 0;
$("#identity-pgp-key-row")?.classList.toggle("hidden", !pgpConfigured);
$$(".identity-pgp-default").forEach((row) => row.classList.toggle("hidden", !pgpConfigured));
const dialog = $("#identity-dialog");
if (!dialog.open) dialog.showModal();
setTimeout(() => (identity ? $("#identity-name") : $("#identity-email"))?.focus(), 20);
}
async function saveIdentity(event) {
event.preventDefault();
const id = $("#identity-id").value;
const signatureHtml = sanitizeMessageHtml($("#identity-signature").innerHTML);
if (new TextEncoder().encode(signatureHtml).length > 3 * 1024 * 1024) return toast("HTML signature is too large. Reduce imported image sizes.", "warning");
const payload = {
display_name: $("#identity-name").value.trim(),
email: $("#identity-email").value.trim(),
reply_to: $("#identity-reply-to").value.trim(),
bcc: $("#identity-bcc").value.trim(),
organization: $("#identity-organization").value.trim(),
signature_text: $("#identity-signature-text").value,
signature_html: signatureHtml,
signature_position: $("#identity-signature-position").value,
pgp_fingerprint: $("#identity-pgp-key").value,
pgp_sign_default: $("#identity-pgp-sign").checked,
pgp_encrypt_default: $("#identity-pgp-encrypt").checked,
pgp_inline_default: $("#identity-pgp-inline").checked,
signature_new: $("#identity-signature-new").checked,
signature_reply: $("#identity-signature-reply").checked,
signature_forward: $("#identity-signature-forward").checked,
is_default: $("#identity-default").checked,
};
try {
await request(id ? `/identities/${id}` : "/identities", { method:id ? "PUT" : "POST", body:JSON.stringify(payload) });
await loadIdentities();
const saved = state.identities.find((item) => item.email === payload.email);
if (saved) {
$("#identity-id").value = saved.id || "";
}
if ($("#identity-dialog").open) $("#identity-dialog").close();
toast("Identity saved");
} catch (error) { toast(error.message, "error"); }
}
async function deleteIdentity() {
const id = $("#identity-id").value;
if (!id || !await appConfirm("Delete this identity?", { title:"Delete identity", confirmLabel:"Delete", danger:true })) return;
try {
await request(`/identities/${id}`, { method:"DELETE" });
await loadIdentities();
if ($("#identity-dialog").open) $("#identity-dialog").close();
toast("Identity deleted");
} catch (error) { toast(error.message, "error"); }
}
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;
}
function composeActionFromTitle() {
const title = $("#compose-title").textContent;
if (title === "Reply" || title === "Reply all") return "reply";
if (title === "Forward") return "forward";
return "new";
}
function dataUrlToSignatureFile(src, index = 0) {
const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\r\n]+)$/i.exec(String(src || ""));
if (!match) return null;
try {
const binary = atob(match[2].replace(/\s+/g, ""));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
const type = match[1].toLowerCase();
const ext = type === "image/jpeg" ? "jpg" : type.slice("image/".length);
return new File([bytes], `signature-${index + 1}.${ext}`, { type });
} catch (_) {
return null;
}
}
async function materializeSignatureImages(signature, generation) {
const images = [...signature.querySelectorAll('img[src^="data:image/"]')];
if (!images.length) return;
const pairs = images.map((image, index) => ({ image, file:dataUrlToSignatureFile(image.getAttribute("src"), index) })).filter((item) => item.file);
if (!pairs.length) return;
const uploaded = await uploadComposeFiles(pairs.map((item) => item.file), { announce:false });
if (!uploaded.length) return;
if (generation !== state.composeSignatureGeneration || !signature.isConnected) {
for (const attachment of uploaded) state.inlineAttachmentIds.add(String(attachment.id));
renderComposeAttachments();
scheduleUnusedInlineAttachmentCleanup();
return;
}
const draftId = ensureDraftId();
for (let index = 0; index < Math.min(pairs.length, uploaded.length); index += 1) {
const { image } = pairs[index];
const attachment = uploaded[index];
state.inlineAttachmentIds.add(String(attachment.id));
image.dataset.rustboxCid = String(attachment.id);
image.dataset.rustboxSignatureImage = "true";
image.setAttribute("src", composeAttachmentContentUrl(draftId, attachment.id));
}
renderComposeAttachments();
scheduleDraftSave();
}
function insertIdentitySignature(editor, action = composeActionFromTitle()) {
state.composeSignatureGeneration += 1;
const generation = state.composeSignatureGeneration;
editor.querySelectorAll(".compose-signature").forEach((node) => node.remove());
scheduleUnusedInlineAttachmentCleanup();
const identity = selectedIdentity();
if (!identity) return;
const enabled = action === "reply" ? identity.signature_reply !== false : action === "forward" ? identity.signature_forward !== false : identity.signature_new !== false;
if (!enabled) return;
const format = $("#compose-mode").value;
const signature = el("div", "compose-signature");
if (format === "html") {
if (!identity.signature_html) return;
signature.innerHTML = sanitizeMessageHtml(identity.signature_html);
} else {
const text = identity.signature_text || "";
if (!text) return;
signature.textContent = `\n-- \n${text}\n`;
}
const quote = editor.querySelector(".compose-quote");
if (quote && identity.signature_position !== "below") editor.insertBefore(signature, quote);
else editor.append(signature);
if (format === "html") void materializeSignatureImages(signature, generation);
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("Could not read image"));
reader.readAsDataURL(file);
});
}
async function insertIdentitySignatureImage(file, range = state.identitySignatureRange) {
state.identitySignatureRange = null;
if (!file) return;
if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type || "")) return toast("Choose a PNG, JPEG, GIF or WebP image", "warning");
if (file.size > 2 * 1024 * 1024) return toast("Signature image must be 2 MB or smaller", "warning");
const editor = $("#identity-signature");
try {
const src = await readFileAsDataUrl(file);
editor.focus();
restoreEditorRange(range, editor);
const alt = escapeHtml((file.name || "signature image").replace(/\.[^.]+$/, ""));
document.execCommand("insertHTML", false, `<img src="${escapeHtml(src)}" alt="${alt}" style="max-width:320px;height:auto">`);
} catch (error) {
toast(error.message || "Could not import signature image", "error");
}
}
function populateSpecialFolderSelects() {
const roles = ["sent", "drafts", "trash", "junk", "archive"];
for (const role of roles) {
const select = $(`#special-${role}`);
if (!select) continue;
const automaticLabel = role === "junk" ? "Use default / auto-detect" : "Automatic / not set";
select.replaceChildren(new Option(automaticLabel, ""));
for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name));
select.value = state.specialFolders?.[role] || "";
}
populateSpamDefaultFolderSelect();
}
function populateSpamDefaultFolderSelect() {
const select = $("#spam-default-folder");
if (!select) return;
const stored = (localStorage.getItem(SPAM_DEFAULT_FOLDER_KEY) || "").trim();
select.replaceChildren(new Option("Automatic IMAP Junk/Spam", ""));
for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name));
if (stored && !state.folders.some((folder) => folder.name === stored)) {
const stale = new Option(`${stored} (not available in this account)`, stored);
stale.disabled = true;
select.add(stale);
}
select.value = stored;
}
async function loadSpecialFolders() {
try { state.specialFolders = { ...state.specialFolders, ...(await request("/settings/special-folders", { method:"GET" })) }; }
catch (error) { console.warn("Could not load special folders", error); }
populateSpecialFolderSelects();
}
async function saveSpecialFolders() {
const value = {};
for (const role of ["sent", "drafts", "trash", "junk", "archive"]) value[role] = $(`#special-${role}`).value || null;
const browserSpamDefault = $("#spam-default-folder")?.value || "";
try {
state.specialFolders = await request("/settings/special-folders", { method:"PUT", body:JSON.stringify(value) });
if (browserSpamDefault) localStorage.setItem(SPAM_DEFAULT_FOLDER_KEY, browserSpamDefault);
else localStorage.removeItem(SPAM_DEFAULT_FOLDER_KEY);
populateSpecialFolderSelects();
toast("Folder settings saved");
} catch (error) { toast(error.message, "error"); }
}
async function autoDetectSpecialFolders() {
for (const role of ["sent", "drafts", "trash", "junk", "archive"]) {
const select = $(`#special-${role}`);
if (select) select.value = autoDetectSpecialFolder(role) || "";
}
await saveSpecialFolders();
}
async function loadQuota() {
const summary = $("#quota-summary span");
const bar = $("#quota-bar");
if (!summary || !bar) return;
summary.textContent = "Loading quota…";
bar.style.width = "0%";
try {
const quota = await request("/quota", { method:"GET" });
const used = Number(quota.usage_bytes || 0);
const limit = Number(quota.limit_bytes || 0);
if (limit > 0) {
const percent = Math.max(0, Math.min(100, (used / limit) * 100));
summary.textContent = `${formatBytes(used)} of ${formatBytes(limit)} (${percent.toFixed(1)}%)`;
bar.style.width = `${percent}%`;
const quotaBox = $("#quota-summary");
const level = percent >= 95 ? "danger" : percent >= 80 ? "warning" : "normal";
quotaBox?.classList.toggle("quota-warning", level === "warning");
quotaBox?.classList.toggle("quota-danger", level === "danger");
if (level !== "normal" && state.quotaWarningLevel !== `${state.accountId}:${level}`) {
state.quotaWarningLevel = `${state.accountId}:${level}`;
toast(level === "danger" ? `Mailbox storage is ${percent.toFixed(0)}% full` : `Mailbox storage is ${percent.toFixed(0)}% used`, level === "danger" ? "error" : "warning");
}
} else {
summary.textContent = `${formatBytes(used)} used · server limit unavailable`;
}
} catch (error) { summary.textContent = `Quota unavailable: ${error.message}`; }
}
function openSettings(tab = "appearance") {
selectSettingsTab(tab);
$("#settings-dialog").showModal();
if (tab === "sieve") loadSieve();
if (tab === "identities") { loadIdentities(); loadOpenPgp(); }
if (tab === "openpgp") loadOpenPgp();
if (tab === "notifications") loadNotificationPreferences();
if (tab === "mailbox") loadMailPreferences();
if (tab === "diagnostics") loadDiagnostics();
if (tab === "account") { renderAccountSettings(); loadActiveSessions(); }
if (tab === "folders") { loadSpecialFolders(); loadQuota(); }
}
function selectSettingsTab(name) {
$$(".settings-tab").forEach((button) => button.classList.toggle("active", button.dataset.tab === name));
$$(".settings-panel").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.panel !== name));
}
async function loadNotificationPreferences() {
try {
const prefs = await request("/settings/notifications", { method:"GET" });
state.notificationPrefs = { ...state.notificationPrefs, ...prefs };
$("#notify-enabled").checked = Boolean(prefs.enabled);
$("#notify-sound").checked = Boolean(prefs.sound);
$("#notify-dnd-enabled").checked = Boolean(prefs.dnd_enabled);
$("#notify-dnd-start").value = prefs.dnd_start || "22:00";
$("#notify-dnd-end").value = prefs.dnd_end || "07:00";
renderNotificationFolders(prefs.folders || ["INBOX"]);
} catch (error) { toast(error.message, "error"); }
}
function renderNotificationFolders(selected) {
const set = new Set(selected || []);
const list = $("#notify-folders");
list.replaceChildren();
for (const folder of orderedFolders()) {
const label = el("label", "checkbox-row");
const input = document.createElement("input");
input.type = "checkbox";
input.value = folder.name;
input.checked = set.has(folder.name);
label.append(input, el("span", "", folder.name));
list.append(label);
}
}
async function saveNotificationPreferences() {
const folders = $$('#notify-folders input[type="checkbox"]:checked').map((input) => input.value);
try {
const prefs = await request("/settings/notifications", { method:"PUT", body:JSON.stringify({ enabled:$("#notify-enabled").checked, sound:$("#notify-sound").checked, folders, dnd_enabled:$("#notify-dnd-enabled").checked, dnd_start:$("#notify-dnd-start").value || "22:00", dnd_end:$("#notify-dnd-end").value || "07:00" }) });
state.notificationPrefs = { ...state.notificationPrefs, ...prefs };
toast("Notification settings saved");
} catch (error) { toast(error.message, "error"); }
}
async function requestNotificationPermission() {
if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error");
const result = await Notification.requestPermission();
toast(`Notification permission: ${result}`);
}
function dndActive(prefs = state.notificationPrefs) {
if (!prefs?.dnd_enabled) return false;
const toMinutes = (value) => {
const [h, m] = String(value || "00:00").split(":").map(Number);
return h * 60 + m;
};
const now = new Date();
const current = now.getHours() * 60 + now.getMinutes();
const start = toMinutes(prefs.dnd_start);
const end = toMinutes(prefs.dnd_end);
if (start === end) return true;
return start < end ? current >= start && current < end : current >= start || current < end;
}
function playNotificationSound() {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
const context = new AudioContext();
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.frequency.value = 620;
gain.gain.setValueAtTime(0.0001, context.currentTime);
gain.gain.exponentialRampToValueAtTime(0.035, context.currentTime + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.16);
oscillator.connect(gain); gain.connect(context.destination);
oscillator.start(); oscillator.stop(context.currentTime + 0.17);
oscillator.onended = () => context.close().catch(() => {});
} catch (_) {}
}
function notifyNewMail(data, accountId) {
const prefs = state.notificationPrefs || {};
if (!prefs.enabled || dndActive(prefs)) return;
const folders = prefs.folders || [];
if (folders.length && !folders.includes(data.folder)) return;
if ("Notification" in window && Notification.permission === "granted") {
const account = state.accounts.find((item) => item.id === accountId);
const title = data.latest_sender || account?.email || "New mail";
const body = data.latest_subject || `${data.count || 1} new message(s) in ${data.folder}`;
const notification = new Notification(title, { body, tag:`rustbox-${accountId}-${data.folder}`, renotify:true });
notification.onclick = () => { window.focus(); notification.close(); };
}
if (prefs.sound) playNotificationSound();
}
function scheduleRealtimeRefresh(event, accountId) {
if (accountId !== state.accountId) return;
clearTimeout(state.realtimeRefreshTimer);
state.realtimeRefreshTimer = setTimeout(async () => {
await refreshFolderListOnly();
if (["NewMessages", "FlagsChanged"].includes(event.type) && event.data?.folder === state.folder) {
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop:state.searchContext.scrollTop });
else await loadMessages(true);
}
}, 250);
}
function connectRealtime() {
if (!state.accounts.length) return;
if (state.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(state.ws.readyState)) return;
clearTimeout(state.wsRetryTimer);
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws`);
state.ws = socket;
socket.onmessage = (message) => {
try {
const payload = JSON.parse(message.data);
const event = payload.event || {};
if (event.type === "NewMessages") notifyNewMail(event.data || {}, payload.accountId);
if (["NewMessages", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
} catch (error) { console.warn("Invalid realtime event", error); }
};
socket.onclose = () => {
if (state.ws === socket) state.ws = null;
if (state.accounts.length) state.wsRetryTimer = setTimeout(connectRealtime, 2500);
};
socket.onerror = () => socket.close();
}
function disconnectRealtime() {
clearTimeout(state.wsRetryTimer);
clearTimeout(state.realtimeRefreshTimer);
state.wsRetryTimer = null;
state.realtimeRefreshTimer = null;
const socket = state.ws;
state.ws = null;
if (socket && socket.readyState < WebSocket.CLOSING) socket.close();
}
function capabilityBadge(label, supported) {
return `<span class="capability-badge ${supported ? "supported" : "unsupported"}">${escapeHtml(label)}<strong>${supported ? "yes" : "no"}</strong></span>`;
}
async function loadDiagnostics() {
const imapStatus = $("#diagnostic-imap-status");
const smtpStatus = $("#diagnostic-smtp-status");
const sieveStatus = $("#diagnostic-sieve-status");
const wsStatus = $("#diagnostic-ws-status");
const syncStatus = $("#diagnostic-sync-status");
const searchStatus = $("#diagnostic-search-status");
imapStatus.textContent = "Checking";
smtpStatus.textContent = "Checking";
sieveStatus.textContent = "Checking";
syncStatus.textContent = "Checking";
searchStatus.textContent = "Checking";
wsStatus.textContent = state.ws?.readyState === WebSocket.OPEN ? "Connected" : "Disconnected";
try {
const info = await request("/diagnostics/imap", { method:"GET" });
imapStatus.textContent = info.connected ? "Connected" : "Unavailable";
$("#diagnostic-imap-summary").innerHTML = [
["IDLE", info.idle], ["MOVE", info.moveExtension], ["UIDPLUS", info.uidplus], ["CONDSTORE", info.condstore],
["QRESYNC", info.qresync], ["SORT", info.sort], ["THREAD", info.thread], ["NAMESPACE", info.namespace],
["SPECIAL-USE", info.specialUse], ["QUOTA", info.quota], ["ACL", info.acl], ["LIST-EXTENDED", info.listExtended], ["UTF8=ACCEPT", info.utf8Accept], ["CUSTOM KEYWORDS", info.customKeywords],
].map(([label, supported]) => capabilityBadge(label, supported)).join("");
$("#diagnostic-imap-caps").textContent = (info.capabilities || []).join(" · ") || "No capabilities reported";
} catch (error) {
imapStatus.textContent = "Unavailable";
$("#diagnostic-imap-summary").textContent = error.message;
$("#diagnostic-imap-caps").textContent = "";
}
try {
const info = await request("/diagnostics/smtp", { method:"GET" });
smtpStatus.textContent = info.connected ? "Connected" : "Unavailable";
$("#diagnostic-smtp-summary").textContent = info.connected ? (info.secureTransport ? "Authenticated connection · TLS enabled" : "Authenticated connection · plaintext transport") : "SMTP unavailable";
} catch (error) {
smtpStatus.textContent = "Unavailable";
$("#diagnostic-smtp-summary").textContent = error.message;
}
try {
const info = await request("/sieve/status", { method:"GET" });
sieveStatus.textContent = info.connected ? "Connected" : "Unavailable";
$("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`;
} catch (error) {
sieveStatus.textContent = "Unavailable";
$("#diagnostic-sieve-summary").textContent = error.message;
}
try {
const info = await request("/diagnostics/sync", { method:"GET" });
syncStatus.textContent = info.unsyncedFolders ? "Partial" : "Ready";
const folderSync = info.latestFolderListSync ? formatDate(info.latestFolderListSync, true) : "never";
const messageSync = info.latestMessageSync ? formatDate(info.latestMessageSync, true) : "never";
$("#diagnostic-sync-summary").textContent = `${info.cachedFolders} cached folders · ${info.cachedMessages} cached messages · folder list ${folderSync} · messages ${messageSync}${info.unsyncedFolders ? ` · ${info.unsyncedFolders} folders awaiting message sync` : ""} · ${info.idleTasks || 0} IDLE task(s) / ${info.idleSubscribers || 0} subscriber(s)`;
} catch (error) {
syncStatus.textContent = "Unavailable";
$("#diagnostic-sync-summary").textContent = error.message;
}
try {
const info = await request("/diagnostics/search", { method:"GET" });
searchStatus.textContent = "Ready";
$("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`;
} catch (error) {
searchStatus.textContent = "Unavailable";
$("#diagnostic-search-summary").textContent = error.message;
}
}
async function reconnectImap() {
const button = $("#diagnostic-imap-reconnect");
if (button) button.disabled = true;
try {
const result = await request("/diagnostics/imap/reconnect", { method:"POST", body:"{}" });
toast(`IMAP reconnected · ${result.capabilityCount || 0} capabilities`);
await loadDiagnostics();
await loadFolders(false);
} catch (error) {
toast(error.message, "error");
} finally {
if (button) button.disabled = false;
}
}
async function rebuildSearchIndex() {
const button = $("#diagnostic-search-rebuild");
if (!await appConfirm("Rebuild the search index from the local message cache?", { title:"Rebuild search index", confirmLabel:"Rebuild" })) return;
button.disabled = true;
try {
const result = await request("/diagnostics/search/rebuild", { method:"POST", body:"{}" });
toast(`Search index rebuilt: ${result.indexedDocuments} messages`);
await loadDiagnostics();
} catch (error) {
toast(error.message, "error");
} finally {
button.disabled = false;
}
}
async function loadSieve() {
const status = $("#sieve-status");
status.textContent = "Connecting…";
try {
const [info, scripts] = await Promise.all([request("/sieve/status", { method: "GET" }), request("/sieve/scripts", { method: "GET" })]);
status.textContent = `${info.host}:${info.port} · ${info.tlsMode} · ${info.implementation || "ManageSieve"}`;
state.sieveInfo = info;
state.sieveScripts = scripts.scripts || [];
renderSieveScripts();
const selectedName = $("#sieve-name")?.value.trim();
const target = state.sieveScripts.find((script) => script.name === selectedName)
|| state.sieveScripts.find((script) => script.active)
|| state.sieveScripts[0];
if (target) await openSieveScript(target.name);
else {
$("#sieve-editor").value = "";
clearSieveRuleBuilder();
updateSieveBuilder();
}
} catch (error) {
status.textContent = error.message;
toast(error.message, "error");
}
}
function sieveQuote(value) {
return String(value ?? "").replaceAll("\\", "\\\\").replaceAll('"', '\\"').replace(/[\r\n]+/g, " ");
}
function sieveBuilderRequirements(condition, action) {
const requirements = new Set();
if (["move", "copy"].includes(action)) requirements.add("fileinto");
if (["copy", "redirect-copy"].includes(action)) requirements.add("copy");
if (["mark-read", "add-flag", "set-flags"].includes(action)) requirements.add("imap4flags");
return [...requirements];
}
function mergeSieveRequirements(script, requirements) {
const found = new Set(requirements);
const without = String(script || "").replace(/^\s*require\s+(?:\[([^\]]*)\]|"([^"]+)")\s*;\s*$/gim, (_line, list, single) => {
const raw = single ? [`"${single}"`] : (list || "").split(",");
for (const item of raw) {
const match = item.match(/"([^"]+)"/);
if (match) found.add(match[1]);
}
return "";
}).trim();
const header = found.size ? `require [${[...found].sort().map((item) => `"${sieveQuote(item)}"`).join(",")}];` : "";
return [header, without].filter(Boolean).join("\n\n").trim() + "\n";
}
function sieveMatchLabel(match) {
return match === "is" ? "is exactly" : match === "matches" ? "matches pattern" : "contains";
}
function sieveConditionLabel(condition, header = "") {
if (condition === "from") return "From address";
if (condition === "from-domain") return "From domain";
if (condition === "to") return "To / Cc address";
if (condition === "header-from") return "From header";
if (condition === "header-to") return "To header";
if (condition === "subject") return "Subject";
if (condition === "list-id") return "List-ID";
return header || "Header";
}
function buildSieveCondition(suffix = "", required = true) {
const condition = $(`#sieve-builder-condition${suffix}`).value;
const value = $(`#sieve-builder-value${suffix}`).value.trim();
if (!condition || !value) {
if (required) throw new Error("Rule value is required");
return null;
}
const match = $(`#sieve-builder-match${suffix}`).value;
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
let test;
let header = "";
if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`;
else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`;
else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`;
else {
header = $(`#sieve-builder-header${suffix}`).value.trim();
if (!/^[A-Za-z0-9-]+$/.test(header)) {
const label = suffix === "-3" ? "Third custom" : suffix === "-2" ? "Second custom" : "Custom";
throw new Error(`${label} header name is invalid`);
}
test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`;
}
const negate = $(`#sieve-builder-negate${suffix}`).checked;
if (negate) test = `not ${test}`;
const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”`;
return { test, condition, match, value, header, negate, summary };
}
function sieveFlagValues(target) {
return String(target || "").split(",").map((value) => value.trim()).filter(Boolean);
}
function buildSieveActionFromValues(action, target, label = "Action") {
let command;
let summary;
if (action === "move") {
if (!target) throw new Error(`${label} target folder is required`);
command = `fileinto "${sieveQuote(target)}";`;
summary = `Move to “${target}”`;
} else if (action === "copy") {
if (!target) throw new Error(`${label} target folder is required`);
command = `fileinto :copy "${sieveQuote(target)}";`;
summary = `Copy to “${target}”`;
} else if (action === "redirect" || action === "redirect-copy") {
if (!target || !target.includes("@")) throw new Error(`${label} redirect address is required`);
command = `redirect${action === "redirect-copy" ? " :copy" : ""} "${sieveQuote(target)}";`;
summary = action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}`;
} else if (action === "discard") {
command = "discard;";
summary = "Discard the message";
} else if (action === "mark-read") {
command = 'addflag "\\Seen";';
summary = "Mark as read";
} else if (action === "add-flag") {
if (!target) throw new Error(`${label} IMAP flag is required`);
command = `addflag "${sieveQuote(target)}";`;
summary = `Add IMAP flag ${target}`;
} else if (action === "set-flags") {
const flags = sieveFlagValues(target);
if (!flags.length) throw new Error(`${label} IMAP flags are required`);
command = `setflag [${flags.map((flag) => `"${sieveQuote(flag)}"`).join(",")}];`;
summary = `Set IMAP flags ${flags.join(", ")}`;
} else {
command = "keep;";
summary = "Keep in the inbox";
}
return { action, target, command, summary, requirements: sieveBuilderRequirements("", action) };
}
function buildSieveAction(suffix = "", required = true) {
const action = $(`#sieve-builder-action${suffix}`).value;
const target = $(`#sieve-builder-target${suffix}`)?.value.trim() || "";
if (!action) {
if (required) throw new Error("Action is required");
return null;
}
return buildSieveActionFromValues(action, target, suffix ? "Second action" : "Action");
}
function newSieveVisualRuleId() {
const random = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `rule-${random.replace(/[^A-Za-z0-9_-]/g, "")}`;
}
function sieveUnescape(value) {
return String(value || "").replace(/\\([\\"])/g, "$1");
}
function sieveQuotedValues(value) {
const values = [];
const pattern = /"((?:\\.|[^"\\])*)"/g;
let match;
while ((match = pattern.exec(String(value || "")))) values.push(sieveUnescape(match[1]));
return values;
}
function splitSieveTopLevel(value) {
const result = [];
let start = 0;
let depth = 0;
let bracket = 0;
let quoted = false;
let escaped = false;
const text = String(value || "");
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoted) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') quoted = false;
continue;
}
if (char === '"') { quoted = true; continue; }
if (char === "(") depth += 1;
else if (char === ")") depth = Math.max(0, depth - 1);
else if (char === "[") bracket += 1;
else if (char === "]") bracket = Math.max(0, bracket - 1);
else if (char === "," && depth === 0 && bracket === 0) {
result.push(text.slice(start, index).trim());
start = index + 1;
}
}
result.push(text.slice(start).trim());
return result.filter(Boolean);
}
function splitSieveStatements(value) {
const result = [];
let start = 0;
let quoted = false;
let escaped = false;
const text = String(value || "");
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoted) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') quoted = false;
continue;
}
if (char === '"') { quoted = true; continue; }
if (char === "#") {
while (index < text.length && text[index] !== "\n") index += 1;
continue;
}
if (char === ";") {
const statement = text.slice(start, index + 1).trim();
if (statement) result.push(statement);
start = index + 1;
}
}
return result;
}
function findSieveClosingBrace(source, openIndex) {
let depth = 0;
let quoted = false;
let escaped = false;
for (let index = openIndex; index < source.length; index += 1) {
const char = source[index];
if (quoted) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') quoted = false;
continue;
}
if (char === '"') { quoted = true; continue; }
if (char === "#") {
while (index < source.length && source[index] !== "\n") index += 1;
continue;
}
if (char === "{") depth += 1;
else if (char === "}") {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function parseSieveConditionToken(value) {
let raw = String(value || "").trim();
const negate = /^not\s+/i.test(raw);
if (negate) raw = raw.replace(/^not\s+/i, "").trim();
const match = raw.match(/^(header|address)\s+(?:(:domain)\s+)?:(contains|is|matches)\s+([\s\S]+)$/i);
if (!match) return { raw:value, supported:false, summary:String(value || "").trim() || "Unsupported condition" };
const values = sieveQuotedValues(match[4]);
if (values.length < 2) return { raw:value, supported:false, summary:String(value || "").trim() };
const targetValue = values.at(-1);
const fields = values.slice(0, -1).map((item) => item.toLowerCase());
const primaryField = fields[0] || "";
let kind = "header";
let header = primaryField;
const testType = match[1].toLowerCase();
if (testType === "address" && primaryField === "from") kind = match[2] ? "from-domain" : "from";
else if (testType === "address" && (fields.includes("to") || fields.includes("cc"))) kind = "to";
else if (testType === "header" && primaryField === "from") kind = "header-from";
else if (testType === "header" && primaryField === "to" && fields.length === 1) kind = "header-to";
else if (primaryField === "subject") kind = "subject";
else if (primaryField === "list-id") kind = "list-id";
const comparator = match[3].toLowerCase();
const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(kind, header)} ${sieveMatchLabel(comparator)} “${targetValue}”`;
return { raw:value, supported:true, kind, header, match:comparator, value:targetValue, negate, summary };
}
function parseSieveConditionExpression(value) {
const raw = String(value || "").trim();
const group = raw.match(/^(allof|anyof)\s*\(([\s\S]*)\)$/i);
if (!group) {
const condition = parseSieveConditionToken(raw);
return { logic:"allof", conditions:[condition], supported:condition.supported, mixed:false, summary:condition.summary };
}
const logic = group[1].toLowerCase();
const parts = splitSieveTopLevel(group[2]);
const conditions = [];
let supported = true;
let mixed = false;
const summaries = [];
for (const part of parts) {
const parsed = parseSieveConditionExpression(part);
if (parsed.conditions.length > 1 && parsed.logic !== logic) mixed = true;
conditions.push(...parsed.conditions);
supported = supported && parsed.supported;
mixed = mixed || parsed.mixed;
summaries.push(parsed.summary);
}
return { logic, conditions, supported, mixed, summary:summaries.join(logic === "anyof" ? " OR " : " AND ") };
}
function parseSieveActionStatement(statement) {
const raw = String(statement || "").trim().replace(/;\s*$/, "");
let match = raw.match(/^fileinto\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i);
if (match) {
const target = sieveUnescape(match[2]);
const action = match[1] ? "copy" : "move";
return { action, target, supported:true, summary:action === "copy" ? `Copy to “${target}”` : `Move to “${target}”` };
}
match = raw.match(/^redirect\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i);
if (match) {
const target = sieveUnescape(match[2]);
const action = match[1] ? "redirect-copy" : "redirect";
return { action, target, supported:true, summary:action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}` };
}
match = raw.match(/^addflag\s+([\s\S]+)$/i);
if (match) {
const flags = sieveQuotedValues(match[1]);
const target = flags.join(", ");
const markRead = flags.length === 1 && flags[0] === "\\Seen";
return { action:markRead ? "mark-read" : "add-flag", target:markRead ? "" : target, supported:Boolean(flags.length), summary:markRead ? "Mark as read" : `Add IMAP flag ${target}` };
}
match = raw.match(/^setflag\s+([\s\S]+)$/i);
if (match) {
const flags = sieveQuotedValues(match[1]);
const target = flags.join(", ");
return { action:"set-flags", target, supported:Boolean(flags.length), summary:`Set IMAP flags ${target}` };
}
if (/^discard$/i.test(raw)) return { action:"discard", target:"", supported:true, summary:"Discard the message" };
if (/^keep$/i.test(raw)) return { action:"keep", target:"", supported:true, summary:"Keep in the inbox" };
if (/^stop$/i.test(raw)) return { action:"stop", target:"", supported:true, stop:true, summary:"Stop processing more rules" };
return { action:"unsupported", target:"", supported:false, summary:raw || "Unsupported action", raw };
}
function parseSieveRules(script = $("#sieve-editor")?.value || "") {
const source = String(script || "");
const markerPattern = /^\s*#\s*(?:rule:\[([^\]\r\n]+)\]|RUSTBOX-RULE-BEGIN\s+([A-Za-z0-9_-]+))\s*$/gim;
const markers = [];
let marker;
while ((marker = markerPattern.exec(source))) markers.push({ index:marker.index, end:markerPattern.lastIndex, name:marker[1]?.trim() || "", rustboxId:marker[2] || "" });
const rules = [];
const nameCounts = new Map();
for (let markerIndex = 0; markerIndex < markers.length; markerIndex += 1) {
const info = markers[markerIndex];
const boundary = markers[markerIndex + 1]?.index ?? source.length;
const segment = source.slice(info.end, boundary);
const ifMatch = /\bif\s+([\s\S]*?)\{/i.exec(segment);
if (!ifMatch) continue;
const openBrace = info.end + ifMatch.index + ifMatch[0].lastIndexOf("{");
const closeBrace = findSieveClosingBrace(source, openBrace);
if (closeBrace < 0 || closeBrace >= boundary) continue;
const conditionOffset = ifMatch[0].indexOf(ifMatch[1]);
const conditionStart = info.end + ifMatch.index + conditionOffset;
const conditionEnd = conditionStart + ifMatch[1].length;
const rawCondition = ifMatch[1].trim();
const disabledMatch = rawCondition.match(/^false\s*#\s*([\s\S]+)$/i);
const enabled = !disabledMatch;
const actualCondition = (disabledMatch ? disabledMatch[1] : rawCondition).trim();
const condition = parseSieveConditionExpression(actualCondition);
const body = source.slice(openBrace + 1, closeBrace);
const parsedActions = splitSieveStatements(body).map(parseSieveActionStatement);
const stop = parsedActions.some((action) => action.stop);
const actions = parsedActions.filter((action) => !action.stop);
let end = closeBrace + 1;
if (info.rustboxId) {
const tail = source.slice(end, boundary);
const endMarker = new RegExp(`^\\s*#\\s*RUSTBOX-RULE-END\\s*$`, "im").exec(tail);
if (endMarker) end += endMarker.index + endMarker[0].length;
}
const fallbackName = info.rustboxId ? `RustBox ${info.rustboxId.replace(/^rule-/, "").slice(0, 12)}` : `Rule ${rules.length + 1}`;
const name = info.name || fallbackName;
const count = (nameCounts.get(name) || 0) + 1;
nameCounts.set(name, count);
const id = `${info.rustboxId ? "rustbox" : "named"}:${name}:${count}`;
const editable = condition.supported && !condition.mixed && actions.every((action) => action.supported);
rules.push({
id, name, enabled, start:info.index, end, full:source.slice(info.index, end), rawCondition, actualCondition,
conditionStart, conditionEnd, condition, actions, stop, editable, rustboxId:info.rustboxId,
});
}
return rules;
}
function setSieveConditionControls(suffix, condition) {
const kind = condition?.kind || "";
$(`#sieve-builder-condition${suffix}`).value = kind;
$(`#sieve-builder-match${suffix}`).value = condition?.match || "contains";
$(`#sieve-builder-value${suffix}`).value = condition?.value || "";
$(`#sieve-builder-negate${suffix}`).checked = Boolean(condition?.negate);
const header = $(`#sieve-builder-header${suffix}`);
if (header) header.value = condition?.header || "";
}
function setSieveDynamicCondition(row, condition, logic) {
row.querySelector(".sieve-dynamic-logic").value = logic === "anyof" ? "anyof" : "allof";
row.querySelector(".sieve-dynamic-kind").value = condition.kind || "from";
row.querySelector(".sieve-dynamic-match").value = condition.match || "contains";
row.querySelector(".sieve-dynamic-header").value = condition.header || "";
row.querySelector(".sieve-dynamic-value").value = condition.value || "";
row.querySelector(".sieve-dynamic-negate").checked = Boolean(condition.negate);
}
function setSieveActionControls(suffix, action) {
$(`#sieve-builder-action${suffix}`).value = action?.action || (suffix ? "" : "move");
const target = $(`#sieve-builder-target${suffix}`);
if (target) target.value = action?.target || "";
}
function setSieveDynamicAction(row, action) {
row.querySelector(".sieve-dynamic-action").value = action.action || "keep";
row.querySelector(".sieve-dynamic-target").value = action.target || "";
configureSieveDynamicAction(row);
}
function showSieveRuleDialog(mode = "new", ruleName = "") {
const dialog = $("#sieve-rule-dialog");
if (!dialog) return;
$("#sieve-rule-dialog-title").textContent = mode === "edit" ? `Edit filter · ${ruleName}` : "Create filter";
$("#sieve-rule-dialog-subtitle").textContent = mode === "edit"
? "Change the rule visually; the live preview shows exactly what will be written to Sieve."
: "Build the rule visually and verify the preview before applying it.";
if (!dialog.open) dialog.showModal();
}
function openNewSieveRuleDialog() {
clearSieveRuleBuilder();
showSieveRuleDialog("new");
setTimeout(() => $("#sieve-builder-name")?.focus(), 20);
}
function loadSieveRuleIntoBuilder(rule) {
if (!rule?.editable) return toast("This rule uses Sieve syntax the visual editor cannot safely rewrite. Edit it in Advanced Sieve source.", "warning");
state.sieveEditingRuleId = rule.id;
$("#sieve-builder-name").value = rule.name;
$("#sieve-builder-enabled").checked = rule.enabled;
$("#sieve-builder-stop").checked = rule.stop;
$("#sieve-builder-extra-conditions").replaceChildren();
$("#sieve-builder-extra-actions").replaceChildren();
const conditions = rule.condition.conditions;
setSieveConditionControls("", conditions[0]);
setSieveConditionControls("-2", conditions[1] || null);
setSieveConditionControls("-3", conditions[2] || null);
$("#sieve-builder-logic").value = rule.condition.logic === "anyof" ? "anyof" : "allof";
$("#sieve-builder-logic-3").value = rule.condition.logic === "anyof" ? "anyof" : "allof";
for (const condition of conditions.slice(3)) {
const row = createSieveDynamicCondition();
setSieveDynamicCondition(row, condition, rule.condition.logic);
$("#sieve-builder-extra-conditions").append(row);
}
setSieveActionControls("", rule.actions[0] || { action:"keep", target:"" });
setSieveActionControls("-2", rule.actions[1] || null);
for (const action of rule.actions.slice(2)) {
const row = createSieveDynamicAction();
setSieveDynamicAction(row, action);
$("#sieve-builder-extra-actions").append(row);
}
$("#sieve-builder-replace").disabled = false;
updateSieveBuilder();
renderManagedSieveRules();
showSieveRuleDialog("edit", rule.name);
setTimeout(() => $("#sieve-builder-name")?.focus(), 20);
}
function clearSieveRuleBuilder() {
state.sieveEditingRuleId = null;
$("#sieve-builder-name").value = "";
$("#sieve-builder-enabled").checked = true;
$("#sieve-builder-stop").checked = true;
$("#sieve-builder-condition").value = "from";
$("#sieve-builder-match").value = "contains";
$("#sieve-builder-value").value = "";
$("#sieve-builder-header").value = "";
$("#sieve-builder-negate").checked = false;
for (const suffix of ["-2", "-3"]) {
$(`#sieve-builder-condition${suffix}`).value = "";
$(`#sieve-builder-match${suffix}`).value = "contains";
$(`#sieve-builder-value${suffix}`).value = "";
$(`#sieve-builder-header${suffix}`).value = "";
$(`#sieve-builder-negate${suffix}`).checked = false;
}
$("#sieve-builder-logic").value = "allof";
$("#sieve-builder-logic-3").value = "allof";
$("#sieve-builder-extra-conditions").replaceChildren();
$("#sieve-builder-action").value = "move";
$("#sieve-builder-target").value = "";
$("#sieve-builder-action-2").value = "";
$("#sieve-builder-target-2").value = "";
$("#sieve-builder-extra-actions").replaceChildren();
$("#sieve-builder-replace").disabled = true;
updateSieveBuilder();
renderManagedSieveRules();
if ($("#sieve-rule-dialog")?.open) {
$("#sieve-rule-dialog-title").textContent = "Create filter";
$("#sieve-rule-dialog-subtitle").textContent = "Build the rule visually and verify the preview before applying it.";
}
}
function moveParsedSieveRule(ruleId, delta) {
const editor = $("#sieve-editor");
const rules = parseSieveRules(editor.value);
const index = rules.findIndex((rule) => rule.id === ruleId);
const target = index + delta;
if (index < 0 || target < 0 || target >= rules.length) return;
const first = delta > 0 ? rules[index] : rules[target];
const second = delta > 0 ? rules[target] : rules[index];
const separator = editor.value.slice(first.end, second.start);
const replacement = second.full + separator + first.full;
editor.value = editor.value.slice(0, first.start) + replacement + editor.value.slice(second.end);
state.sieveEditingRuleId = null;
renderManagedSieveRules();
$("#sieve-result").textContent = "Rule order changed — save to apply";
}
function toggleSieveRuleEnabled(rule) {
const editor = $("#sieve-editor");
const source = editor.value;
const replacement = rule.enabled ? `false # ${rule.actualCondition}` : rule.actualCondition;
editor.value = source.slice(0, rule.conditionStart) + replacement + source.slice(rule.conditionEnd);
state.sieveEditingRuleId = null;
renderManagedSieveRules();
$("#sieve-result").textContent = `${rule.name} ${rule.enabled ? "disabled" : "enabled"} — save to apply`;
}
async function deleteSieveRule(rule) {
if (!await appConfirm(`Delete Sieve rule “${rule.name}”?`, { title:"Delete filter rule", confirmLabel:"Delete", danger:true })) return;
const editor = $("#sieve-editor");
editor.value = `${editor.value.slice(0, rule.start).trimEnd()}\n\n${editor.value.slice(rule.end).trimStart()}`.trim() + "\n";
state.sieveEditingRuleId = null;
clearSieveRuleBuilder();
renderManagedSieveRules();
$("#sieve-result").textContent = `Rule “${rule.name}” removed — save to apply`;
}
function renderManagedSieveRules() {
const list = $("#sieve-managed-rules");
if (!list) return;
const rules = parseSieveRules();
const count = $("#sieve-rule-count");
if (count) count.textContent = String(rules.length);
if (state.sieveEditingRuleId && !rules.some((rule) => rule.id === state.sieveEditingRuleId)) {
state.sieveEditingRuleId = null;
const update = $("#sieve-builder-replace");
if (update) update.disabled = true;
}
list.replaceChildren();
if (!rules.length) {
list.append(el("div", "muted", "No named rules found. Rules using # rule:[name] are parsed automatically."));
return;
}
rules.forEach((rule, index) => {
const item = el("article", `sieve-managed-rule${rule.enabled ? "" : " disabled"}${rule.editable ? "" : " unsupported"}${state.sieveEditingRuleId === rule.id ? " selected" : ""}`);
const main = el("div", "sieve-managed-rule-main");
const title = el("div", "sieve-managed-rule-title");
title.append(el("strong", "", rule.name), el("span", `sieve-rule-state ${rule.enabled ? "enabled" : ""}`, rule.enabled ? "ON" : "OFF"));
const summary = el("div", "sieve-managed-rule-summary");
const when = el("div"); when.innerHTML = `<b>When:</b> ${escapeHtml(rule.condition.summary || rule.actualCondition)}`;
const actionText = rule.actions.length ? rule.actions.map((action) => action.summary).join("; ") : "No action parsed";
const then = el("div"); then.innerHTML = `<b>Then:</b> ${escapeHtml(actionText)}${rule.stop ? "; stop processing more rules" : ""}`;
summary.append(when, then);
main.append(title, summary);
if (!rule.editable) main.append(el("div", "sieve-rule-warning", "Uses syntax not fully supported by the visual editor; source is preserved unchanged."));
const actions = el("div", "sieve-managed-rule-actions");
const edit = el("button", "button quiet small", "Edit"); edit.type="button"; edit.disabled=!rule.editable; edit.addEventListener("click", () => loadSieveRuleIntoBuilder(rule));
const toggle = el("button", "button quiet small", rule.enabled ? "Disable" : "Enable"); toggle.type="button"; toggle.addEventListener("click", () => toggleSieveRuleEnabled(rule));
const up = el("button", "button quiet small", "↑"); up.type="button"; up.title="Move rule up"; up.disabled=index===0; up.addEventListener("click", () => moveParsedSieveRule(rule.id, -1));
const down = el("button", "button quiet small", "↓"); down.type="button"; down.title="Move rule down"; down.disabled=index===rules.length-1; down.addEventListener("click", () => moveParsedSieveRule(rule.id, 1));
const remove = el("button", "button quiet small danger", "Delete"); remove.type="button"; remove.addEventListener("click", () => deleteSieveRule(rule));
actions.append(edit, toggle, up, down, remove);
item.append(main, actions);
list.append(item);
});
}
function createSieveDynamicCondition() {
const row = el("div", "sieve-dynamic-row sieve-dynamic-condition");
row.innerHTML = `<label><span>Join</span><select class="sieve-dynamic-logic"><option value="allof">AND</option><option value="anyof">OR</option></select></label><label><span>Condition</span><select class="sieve-dynamic-kind"><option value="from">From address</option><option value="from-domain">From domain</option><option value="to">To / Cc address</option><option value="header-from">From header</option><option value="header-to">To header</option><option value="subject">Subject</option><option value="list-id">List-ID</option><option value="header">Custom header</option></select></label><label><span>Match</span><select class="sieve-dynamic-match"><option value="contains">contains</option><option value="is">is exactly</option><option value="matches">wildcard matches</option></select></label><label class="sieve-dynamic-header-wrap hidden"><span>Header name</span><input class="sieve-dynamic-header" placeholder="X-Spam-Status"></label><label><span>Value</span><input class="sieve-dynamic-value" placeholder="value"></label><label class="checkbox-row"><input class="sieve-dynamic-negate" type="checkbox"><span>NOT / negate</span></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
const refresh = () => {
const kind = row.querySelector(".sieve-dynamic-kind").value;
row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", kind !== "header");
updateSieveBuilder();
};
row.querySelector(".sieve-dynamic-kind").addEventListener("change", refresh);
row.querySelectorAll("select,input").forEach((node) => node.addEventListener("input", updateSieveBuilder));
row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); });
return row;
}
function buildSieveDynamicCondition(row) {
const condition = row.querySelector(".sieve-dynamic-kind").value;
const value = row.querySelector(".sieve-dynamic-value").value.trim();
if (!condition || !value) throw new Error("Every additional condition needs a value");
const match = row.querySelector(".sieve-dynamic-match").value;
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
let test;
let header = "";
if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`;
else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`;
else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`;
else {
header = row.querySelector(".sieve-dynamic-header").value.trim();
if (!/^[A-Za-z0-9-]+$/.test(header)) throw new Error("Additional custom header name is invalid");
test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`;
}
const negate = row.querySelector(".sieve-dynamic-negate").checked;
if (negate) test = `not ${test}`;
return { test, condition, match, value, header, negate, logic:row.querySelector(".sieve-dynamic-logic").value === "anyof" ? "anyof" : "allof", summary:`${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”` };
}
function createSieveDynamicAction() {
const row = el("div", "sieve-dynamic-row sieve-dynamic-action-row");
row.innerHTML = `<label><span>Action</span><select class="sieve-dynamic-action"><option value="move">Move to folder</option><option value="copy">Copy to folder</option><option value="redirect">Redirect</option><option value="redirect-copy">Redirect and keep copy</option><option value="discard">Discard</option><option value="mark-read">Mark read</option><option value="add-flag">Add IMAP flag</option><option value="set-flags">Set IMAP flags</option><option value="keep">Keep</option></select></label><label class="sieve-dynamic-target-wrap"><span class="sieve-dynamic-target-label">Folder</span><input class="sieve-dynamic-target" list="sieve-folder-options" placeholder="Archive"></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
const refresh = () => { configureSieveDynamicAction(row); updateSieveBuilder(); };
row.querySelector(".sieve-dynamic-action").addEventListener("change", refresh);
row.querySelector(".sieve-dynamic-target").addEventListener("input", updateSieveBuilder);
row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); });
configureSieveDynamicAction(row);
return row;
}
function configureSieveDynamicAction(row) {
const action = row.querySelector(".sieve-dynamic-action").value;
const wrap = row.querySelector(".sieve-dynamic-target-wrap");
const label = row.querySelector(".sieve-dynamic-target-label");
const target = row.querySelector(".sieve-dynamic-target");
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action);
wrap.classList.toggle("hidden", !needsTarget);
label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder";
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive";
target.removeAttribute("list");
if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options");
}
function buildSieveDynamicAction(row) {
return buildSieveActionFromValues(row.querySelector(".sieve-dynamic-action").value, row.querySelector(".sieve-dynamic-target").value.trim(), "Additional action");
}
function cleanSieveRuleName(value) {
const name = String(value || "").trim();
if (!name) throw new Error("Rule name is required");
if (/[\]\r\n]/.test(name)) throw new Error("Rule name cannot contain ] or a line break");
return name;
}
function buildSieveVisualRule() {
const name = cleanSieveRuleName($("#sieve-builder-name").value);
const enabled = $("#sieve-builder-enabled").checked;
const primary = buildSieveCondition("", true);
const secondary = buildSieveCondition("-2", false);
const tertiary = buildSieveCondition("-3", false);
let test = primary.test;
let summary = primary.summary;
if (secondary) {
const logic = $("#sieve-builder-logic").value === "anyof" ? "anyof" : "allof";
test = `${logic} (${test}, ${secondary.test})`;
summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${secondary.summary}`;
}
if (tertiary) {
const logic = $("#sieve-builder-logic-3").value === "anyof" ? "anyof" : "allof";
test = `${logic} (${test}, ${tertiary.test})`;
summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${tertiary.summary}`;
}
for (const row of $$("#sieve-builder-extra-conditions .sieve-dynamic-condition")) {
const extra = buildSieveDynamicCondition(row);
test = `${extra.logic} (${test}, ${extra.test})`;
summary = `${summary} ${extra.logic === "anyof" ? "OR" : "AND"} ${extra.summary}`;
}
const actions = [buildSieveAction("", true), buildSieveAction("-2", false), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action-row").map(buildSieveDynamicAction)].filter(Boolean);
const commands = actions.map((item) => item.command);
const stop = $("#sieve-builder-stop").checked;
if (stop) commands.push("stop;");
const requirements = new Set();
for (const item of actions) for (const requirement of item.requirements) requirements.add(requirement);
const conditionCode = enabled ? test : `false # ${test}`;
return {
name, enabled, stop, requirements:[...requirements], conditionSummary:summary,
actionSummary:actions.map((item) => item.summary).join("; ") + (stop ? "; stop processing more rules" : ""),
rule:`# rule:[${name}]\nif ${conditionCode}\n{\n\t${commands.join("\n\t")}\n}`,
};
}
function configureSieveActionFields(suffix = "") {
const action = $(`#sieve-builder-action${suffix}`).value;
const wrap = $(`#sieve-builder-target-wrap${suffix}`);
const label = $(`#sieve-builder-target-label${suffix}`);
const target = $(`#sieve-builder-target${suffix}`);
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action);
wrap?.classList.toggle("hidden", !needsTarget);
if (!target || !label) return;
label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder";
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive";
target.removeAttribute("list");
if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options");
}
function applySieveCapabilityMatrix(extensions) {
const supports = (name) => extensions.has(name.toLowerCase());
const actionAllowed = (action) => {
if (["move", "copy"].includes(action) && !supports("fileinto")) return false;
if (["copy", "redirect-copy"].includes(action) && !supports("copy")) return false;
if (["mark-read", "add-flag", "set-flags"].includes(action) && !supports("imap4flags")) return false;
return true;
};
for (const select of [$("#sieve-builder-action"), $("#sieve-builder-action-2"), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action")]) {
if (!select) continue;
for (const option of select.options) option.disabled = Boolean(option.value) && !actionAllowed(option.value);
if (select.selectedOptions[0]?.disabled) select.value = select.id === "sieve-builder-action-2" ? "" : "keep";
}
$("#sieve-vacation-append").disabled = Boolean(state.sieveInfo) && !supports("vacation");
}
function renderSieveBuilderPreview() {
const preview = $("#sieve-builder-preview-text");
const code = $("#sieve-builder-preview-code");
const statePill = $("#sieve-builder-preview-state");
if (!preview || !code || !statePill) return;
try {
const built = buildSieveVisualRule();
preview.innerHTML = `<div><strong>When:</strong> ${escapeHtml(built.conditionSummary)}</div><div><strong>Then:</strong> ${escapeHtml(built.actionSummary || "No action")}</div>`;
code.textContent = built.rule;
statePill.textContent = built.enabled ? "Enabled" : "Disabled";
statePill.className = `sieve-status-pill ${built.enabled ? "ok" : ""}`;
} catch (error) {
preview.textContent = error.message;
code.textContent = "";
statePill.textContent = "Incomplete";
statePill.className = "sieve-status-pill error";
}
}
function updateSieveBuilder() {
const condition = $("#sieve-builder-condition").value;
$("#sieve-builder-header-wrap").classList.toggle("hidden", condition !== "header");
for (const suffix of ["-2", "-3"]) {
const nextCondition = $(`#sieve-builder-condition${suffix}`).value;
const enabled = Boolean(nextCondition);
$(`#sieve-builder-header-wrap${suffix}`).classList.toggle("hidden", nextCondition !== "header");
$(`#sieve-builder-match${suffix}`).disabled = !enabled;
$(`#sieve-builder-value${suffix}`).disabled = !enabled;
$(`#sieve-builder-negate${suffix}`).disabled = !enabled;
const customHeader = $(`#sieve-builder-header${suffix}`);
if (customHeader) customHeader.disabled = !enabled || nextCondition !== "header";
}
$("#sieve-builder-logic").disabled = !Boolean($("#sieve-builder-condition-2").value);
$("#sieve-builder-logic-3").disabled = !Boolean($("#sieve-builder-condition-3").value);
configureSieveActionFields("");
configureSieveActionFields("-2");
$$("#sieve-builder-extra-actions .sieve-dynamic-action-row").forEach(configureSieveDynamicAction);
$$("#sieve-builder-extra-conditions .sieve-dynamic-condition").forEach((row) => {
row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", row.querySelector(".sieve-dynamic-kind").value !== "header");
});
const list = $("#sieve-folder-options");
list.replaceChildren(...state.folders.map((folder) => {
const option = document.createElement("option");
option.value = folder.name;
return option;
}));
const extensions = new Set((state.sieveInfo?.extensions || []).map((value) => value.toLowerCase()));
applySieveCapabilityMatrix(extensions);
const actions = ["", "-2"].map((suffix) => $(`#sieve-builder-action${suffix}`)?.value).filter(Boolean)
.concat($$("#sieve-builder-extra-actions .sieve-dynamic-action").map((node) => node.value).filter(Boolean));
const requirements = new Set();
for (const actionName of actions) for (const requirement of sieveBuilderRequirements("", actionName)) requirements.add(requirement);
const missing = [...requirements].filter((requirement) => state.sieveInfo && !extensions.has(requirement.toLowerCase()));
$("#sieve-builder-capability").textContent = missing.length
? `Server does not advertise: ${missing.join(", ")}`
: requirements.size ? `Requires: ${[...requirements].join(", ")}` : "No optional extension required";
const vacationSupported = extensions.has("vacation");
const vacationScheduled = Boolean($("#sieve-vacation-start")?.value || $("#sieve-vacation-end")?.value);
const scheduleSupported = extensions.has("date") && extensions.has("relational");
$("#sieve-vacation-capability").textContent = state.sieveInfo
? !vacationSupported
? "Server does not advertise vacation"
: vacationScheduled && !scheduleSupported
? "Scheduled dates require date + relational"
: vacationScheduled
? "Server supports vacation scheduling"
: "Server supports vacation"
: "";
$("#sieve-builder-replace").disabled = !state.sieveEditingRuleId;
renderSieveBuilderPreview();
}
function buildSieveVacationRule() {
const subject = $("#sieve-vacation-subject").value.trim() || "Out of office";
const message = $("#sieve-vacation-message").value.trim();
const days = Math.max(1, Math.min(30, Number.parseInt($("#sieve-vacation-days").value, 10) || 7));
const start = $("#sieve-vacation-start").value.trim();
const end = $("#sieve-vacation-end").value.trim();
if (!message) throw new Error("Autoresponder message is required");
if (start && !/^\d{4}-\d{2}-\d{2}$/.test(start)) throw new Error("Vacation start date is invalid");
if (end && !/^\d{4}-\d{2}-\d{2}$/.test(end)) throw new Error("Vacation end date is invalid");
if (start && end && end < start) throw new Error("Vacation end date must not be before the start date");
const extensions = new Set((state.sieveInfo?.extensions || []).map((value) => value.toLowerCase()));
const requirements = new Set(["vacation"]);
const tests = [];
if (start || end) {
if (state.sieveInfo && (!extensions.has("date") || !extensions.has("relational"))) {
throw new Error("This server does not advertise the Sieve date + relational extensions required for scheduled vacation");
}
requirements.add("date");
requirements.add("relational");
if (start) tests.push(`currentdate :value "ge" "date" "${sieveQuote(start)}"`);
if (end) tests.push(`currentdate :value "le" "date" "${sieveQuote(end)}"`);
}
if ($("#sieve-vacation-skip-lists").checked) tests.push('not exists ["list-id"]');
if ($("#sieve-vacation-skip-auto").checked) tests.push('not header :matches "auto-submitted" ["auto-replied", "auto-generated"]');
const command = `vacation :days ${days} :subject "${sieveQuote(subject)}" "${sieveQuote(message)}";`;
const rule = tests.length
? `# RustBox autoresponder\nif allof (${tests.join(", ")}) {\n ${command}\n}`
: `# RustBox autoresponder\n${command}`;
return { requirements: [...requirements], rule };
}
async function appendSieveVacation() {
try {
const built = buildSieveVacationRule();
const editor = $("#sieve-editor");
editor.value = mergeSieveRequirements([editor.value.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements);
$("#sieve-result").textContent = "Autoresponder appended";
await validateSieve();
} catch (error) { toast(error.message, "error"); }
}
async function applySieveVisualRule(updateSelected) {
try {
const built = buildSieveVisualRule();
const editor = $("#sieve-editor");
let body = editor.value;
if (updateSelected) {
if (!state.sieveEditingRuleId) throw new Error("Select a parsed rule to update first");
const selected = parseSieveRules(body).find((rule) => rule.id === state.sieveEditingRuleId);
if (!selected) throw new Error("The selected rule no longer exists in the source");
body = `${body.slice(0, selected.start)}${built.rule}${body.slice(selected.end)}`;
} else {
body = [body.trim(), built.rule].filter(Boolean).join("\n\n");
}
editor.value = mergeSieveRequirements(body, built.requirements);
const parsed = parseSieveRules(editor.value);
const matching = parsed.find((rule) => rule.name === built.name);
state.sieveEditingRuleId = matching?.id || null;
renderManagedSieveRules();
updateSieveBuilder();
$("#sieve-result").textContent = updateSelected ? `Rule "${built.name}" updated - save to apply` : `Rule "${built.name}" added - save to apply`;
await validateSieve();
if ($("#sieve-rule-dialog")?.open) $("#sieve-rule-dialog").close();
} catch (error) {
toast(error.message, "error");
}
}
function renderSieveScripts() {
const list = $("#sieve-scripts");
list.replaceChildren();
if (!state.sieveScripts.length) list.append(el("div", "empty-list", "No scripts"));
for (const script of state.sieveScripts) {
const button = el("button", `sieve-script ${script.active ? "active" : ""}`);
button.type = "button";
button.innerHTML = `<span>${escapeHtml(script.name)}</span><span>${script.active ? "ACTIVE" : ""}</span>`;
button.addEventListener("click", () => openSieveScript(script.name));
list.append(button);
}
}
async function openSieveScript(name) {
try {
const data = await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "GET" });
$("#sieve-name").value = data.name;
$("#sieve-editor").value = data.content;
state.sieveEditingRuleId = null;
clearSieveRuleBuilder();
renderManagedSieveRules();
$("#sieve-result").textContent = "Loaded";
} catch (error) { toast(error.message, "error"); }
}
function newSieveScript() {
$("#sieve-name").value = "rustbox";
$("#sieve-editor").value = `require ["fileinto"];\n`;
state.sieveEditingRuleId = null;
clearSieveRuleBuilder();
renderManagedSieveRules();
$("#sieve-result").textContent = "New script";
$("#sieve-name").focus();
}
async function validateSieve() {
try {
const data = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content: $("#sieve-editor").value }) });
$("#sieve-result").textContent = data.valid ? (data.warning || "Valid script") : (data.error || "Invalid script");
return data.valid;
} catch (error) {
$("#sieve-result").textContent = error.message;
toast(error.message, "error");
return false;
}
}
async function backupSieveScripts() {
try {
if (!state.sieveScripts.length) await loadSieve();
if (!state.sieveScripts.length) return toast("No Sieve scripts to back up", "info");
const scripts = [];
for (const script of state.sieveScripts) {
const data = await request(`/sieve/scripts/${encodeURIComponent(script.name)}`, { method:"GET" });
scripts.push({ name:script.name, active:Boolean(script.active), content:data.content || "" });
}
const payload = {
format: "rustbox-sieve-backup",
version: 1,
exported_at: new Date().toISOString(),
scripts,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type:"application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `rustbox-sieve-${new Date().toISOString().slice(0, 10)}.json`;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast(`Backed up ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}`, "success");
} catch (error) { toast(error.message, "error"); }
}
async function restoreSieveBackup(file) {
if (!file) return;
try {
if (file.size > 2 * 1024 * 1024) throw new Error("Sieve backup is too large");
const payload = JSON.parse(await file.text());
if (payload?.format !== "rustbox-sieve-backup" || payload?.version !== 1 || !Array.isArray(payload.scripts)) {
throw new Error("This is not a supported RustBox Sieve backup");
}
const scripts = payload.scripts.slice(0, 100).map((script) => ({
name: String(script?.name || "").trim(),
active: Boolean(script?.active),
content: String(script?.content || ""),
}));
if (!scripts.length || scripts.some((script) => !script.name || script.name.length > 128 || /[\r\n]/.test(script.name))) {
throw new Error("Backup contains an invalid script name");
}
if (payload.scripts.length > 100) throw new Error("Backup contains too many scripts");
const activeCount = scripts.filter((script) => script.active).length;
if (activeCount > 1) throw new Error("Backup marks more than one Sieve script as active");
const existing = new Set(state.sieveScripts.map((script) => script.name));
const overwriteCount = scripts.filter((script) => existing.has(script.name)).length;
const confirmed = await appConfirm(
`Restore ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}${overwriteCount ? ` and overwrite ${overwriteCount} existing script${overwriteCount === 1 ? "" : "s"}` : ""}? Existing scripts not present in the backup will be kept.`,
{ title:"Restore Sieve backup", confirmLabel:"Restore", danger:overwriteCount > 0 }
);
if (!confirmed) return;
// Validate the complete backup against the current server before writing any script.
for (const script of scripts) {
const result = await request("/sieve/check", { method:"POST", body:JSON.stringify({ content:script.content }) });
if (!result.valid) throw new Error(`Script “${script.name}” is not valid on this server: ${result.error || "validation failed"}`);
}
for (const script of scripts) {
await request(`/sieve/scripts/${encodeURIComponent(script.name)}`, { method:"PUT", body:JSON.stringify({ content:script.content }) });
}
const active = scripts.find((script) => script.active);
if (active) await request(`/sieve/scripts/${encodeURIComponent(active.name)}/activate`, { method:"POST", body:"{}" });
await loadSieve();
toast(`Restored ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}`, "success");
} catch (error) { toast(error.message, "error"); }
finally { $("#sieve-restore-file").value = ""; }
}
async function saveSieve() {
const name = $("#sieve-name").value.trim();
if (!name) return toast("Script name is required", "error");
const existing = state.sieveScripts.find((script) => script.name === name);
if (existing?.active) {
const confirmed = await appConfirm(`“${name}” is the active Sieve script. Validate and overwrite it?`, { title:"Overwrite active filter script", confirmLabel:"Overwrite", danger:true });
if (!confirmed) return;
}
if (!await validateSieve()) return;
try {
const data = await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "PUT", body: JSON.stringify({ content: $("#sieve-editor").value }) });
$("#sieve-result").textContent = data.warning || "Saved";
await loadSieve();
toast(`Sieve script “${name}” saved`, "success");
} catch (error) { toast(error.message, "error"); }
}
async function activateSieve() {
const name = $("#sieve-name").value.trim();
if (!name) return toast("Select a script first", "error");
try {
await request(`/sieve/scripts/${encodeURIComponent(name)}/activate`, { method: "POST", body: "{}" });
await loadSieve();
toast(`Sieve script “${name}” activated`, "success");
} catch (error) { toast(error.message, "error"); }
}
async function deactivateSieve() {
try {
await request("/sieve/deactivate", { method: "POST", body: "{}" });
await loadSieve();
toast("Sieve script deactivated", "success");
} catch (error) { toast(error.message, "error"); }
}
async function deleteSieve() {
const name = $("#sieve-name").value.trim();
if (!name || !await appConfirm(`Delete Sieve script “${name}”?`, { title:"Delete Sieve script", confirmLabel:"Delete", danger:true })) return;
try {
await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "DELETE" });
$("#sieve-name").value = "";
$("#sieve-editor").value = "";
await loadSieve();
toast(`Sieve script “${name}” deleted`, "success");
} catch (error) { toast(error.message, "error"); }
}
function bindEditor() {
$$(".editor-toolbar [data-command]").forEach((button) => button.addEventListener("click", () => {
$("#compose-editor").focus();
document.execCommand(button.dataset.command, false, button.dataset.commandValue || null);
}));
$("#editor-link").addEventListener("click", async () => {
const url = await appPrompt("Insert link", { value:"https://", type:"url", confirmLabel:"Insert link", required:true });
if (!url) return;
$("#compose-editor").focus();
document.execCommand("createLink", false, url);
});
$("#editor-font").addEventListener("change", (event) => {
if (!event.target.value) return;
$("#compose-editor").focus();
document.execCommand("fontName", false, event.target.value);
scheduleDraftSave();
});
$("#editor-size").addEventListener("change", (event) => {
if (!event.target.value) return;
$("#compose-editor").focus();
document.execCommand("fontSize", false, event.target.value);
scheduleDraftSave();
});
$("#editor-color").addEventListener("input", (event) => {
$("#compose-editor").focus();
document.execCommand("foreColor", false, event.target.value);
scheduleDraftSave();
});
}
function bindPaneResizer(selector, preferenceKey, minPx, maxPx) {
const handle = $(selector);
if (!handle) return;
handle.addEventListener("pointerdown", (event) => {
if (innerWidth <= 820 || event.button !== 0) return;
event.preventDefault();
const scaleFactor = Number(getComputedStyle(root).getPropertyValue("--ui-scale-factor")) || 1;
const startX = event.clientX;
const startRendered = Number(state.prefs[preferenceKey] || minPx) * scaleFactor;
handle.setPointerCapture?.(event.pointerId);
document.body.classList.add("resizing-pane");
const move = (moveEvent) => {
const rendered = Math.max(minPx * scaleFactor, Math.min(maxPx * scaleFactor, startRendered + (moveEvent.clientX - startX)));
const base = Math.round(rendered / scaleFactor);
state.prefs[preferenceKey] = base;
const cssName = preferenceKey === "folderPaneWidth" ? "--folder-pane-width" : "--message-pane-width";
root.style.setProperty(cssName, `${Math.round(rendered)}px`);
handle.setAttribute("aria-valuenow", String(base));
};
const finish = () => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", finish);
handle.removeEventListener("pointercancel", finish);
document.body.classList.remove("resizing-pane");
savePreference(preferenceKey, state.prefs[preferenceKey]);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", finish);
handle.addEventListener("pointercancel", finish);
});
}
function bindEvents() {
bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420);
bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760);
bindRecipientAutocomplete("#compose-to");
bindRecipientAutocomplete("#compose-cc");
bindRecipientAutocomplete("#compose-bcc");
$("#login-form").addEventListener("submit", login);
$("#logout-btn").addEventListener("click", logout);
$("#compose-btn").addEventListener("click", () => openCompose("new"));
$("#compose-fullscreen").addEventListener("click", toggleComposeFullscreen);
$("#compose-form").addEventListener("submit", sendMessage);
$("#compose-attach").addEventListener("click", () => $("#compose-files").click());
$("#compose-files").addEventListener("change", async (event) => { await uploadComposeFiles(event.target.files); event.target.value = ""; });
$("#editor-image").addEventListener("click", () => { state.composeInsertRange = captureEditorRange(); $("#compose-images").click(); });
$("#editor-image-edit").addEventListener("click", () => openInlineImageEditor());
$("#compose-images").addEventListener("change", async (event) => { await uploadInlineImage(event.target.files, state.composeInsertRange); state.composeInsertRange = null; event.target.value = ""; });
$("#image-edit-form").addEventListener("submit", applyInlineImageEditor);
$("#image-edit-reset").addEventListener("click", resetImageCropControls);
["#image-crop-top", "#image-crop-right", "#image-crop-bottom", "#image-crop-left"].forEach((selector) => $(selector).addEventListener("input", drawInlineImageCropPreview));
$("#image-edit-width").addEventListener("input", updateImageEditOutputs);
$("#image-edit-dialog").addEventListener("close", () => { state.imageEditorBitmap?.close?.(); state.imageEditorBitmap = null; state.imageEditorBlob = null; });
$("#compose-recover").addEventListener("click", recoverDraft);
$("#compose-mode").addEventListener("change", async (event) => {
const editor = $("#compose-editor");
const previous = editor.dataset.mode || "html";
const next = event.target.value;
const richContent = previous === "html" && next !== "html" && Boolean(editor.querySelector("img,a,strong,b,em,i,u,s,ul,ol,li,blockquote,[style]"));
if (richContent && !await appConfirm("Switching away from HTML can hide or discard formatting when the message is sent. Continue?", { title:"Change compose format", confirmLabel:"Switch format" })) {
event.target.value = previous;
return;
}
setComposeMode(next, true);
insertIdentitySignature(editor);
scheduleDraftSave();
});
$("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); });
["#compose-to","#compose-cc","#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => {
state.pgpRecipientStatus = [];
updateComposePgpUi();
scheduleDraftSave();
}));
$("#compose-subject").addEventListener("input", scheduleDraftSave);
["#compose-priority", "#compose-mdn", "#compose-pgp-mode", "#compose-pgp-inline", "#compose-pgp-attach-key", "#compose-pgp-attach-recipient-keys"].forEach((selector) => $(selector).addEventListener("change", async () => {
if (selector === "#compose-pgp-attach-recipient-keys" && $(selector).checked) await checkComposePgpRecipients(false, composePgpVisibleRecipients());
updateComposePgpUi();
scheduleDraftSave();
}));
$("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); });
$("#compose-editor").addEventListener("click", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]");
setSelectedInlineImage(image || null);
});
$("#compose-editor").addEventListener("dblclick", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]");
if (image) { event.preventDefault(); openInlineImageEditor(image); }
});
$("#compose-editor").addEventListener("paste", async (event) => {
const images = [...(event.clipboardData?.items || [])]
.filter((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type))
.map((item) => item.getAsFile())
.filter(Boolean);
if (!images.length) return;
event.preventDefault();
if ($("#compose-mode").value !== "html") return toast("Switch to HTML mode to paste inline images", "warning");
const range = captureEditorRange();
await uploadInlineImage(images, range);
});
$("#compose-editor").addEventListener("dragover", (event) => { if ([...event.dataTransfer.types].includes("Files")) event.preventDefault(); });
$("#compose-editor").addEventListener("drop", async (event) => {
const files = [...event.dataTransfer.files];
if (!files.length) return;
event.preventDefault();
const allImages = files.every((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type));
if ($("#compose-mode").value === "html" && allImages) await uploadInlineImage(files, editorRangeFromPoint(event.clientX, event.clientY));
else await uploadComposeFiles(files);
});
$("#reply-btn").addEventListener("click", () => openCompose("reply"));
$("#reply-all-btn").addEventListener("click", () => openCompose("reply-all"));
$("#forward-btn").addEventListener("click", () => openCompose("forward"));
$("#delete-btn").addEventListener("click", deleteCurrent);
$("#unread-btn").addEventListener("click", toggleUnread);
$("#flag-btn").addEventListener("click", toggleFlagged);
$("#archive-btn").addEventListener("click", archiveCurrent);
$("#spam-btn").addEventListener("click", junkCurrent);
$("#move-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await moveCurrentTo(target); });
$("#copy-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await copyCurrentTo(target); });
$("#bulk-select-all").addEventListener("change", (event) => { const source = state.searchAllResults || state.messages; for (const message of source) { const key = messageKey(message); event.target.checked ? state.selectedMessages.add(key) : state.selectedMessages.delete(key); } renderMessages(); });
$("#bulk-read").addEventListener("click", () => performBulk("read"));
$("#bulk-unread").addEventListener("click", () => performBulk("unread"));
$("#bulk-flag").addEventListener("click", () => performBulk("flag"));
$("#bulk-unflag").addEventListener("click", () => performBulk("unflag"));
$("#bulk-archive").addEventListener("click", () => performBulk("archive"));
$("#bulk-select-search").addEventListener("click", selectAllSearchResults);
$("#bulk-junk").addEventListener("click", () => performBulk("junk"));
$("#bulk-delete").addEventListener("click", () => performBulk("delete"));
$("#bulk-move-select").addEventListener("change", async (event) => { const target=event.target.value; event.target.value=""; if (target) await performBulk("move", target); });
$("#bulk-copy-select").addEventListener("change", async (event) => { const target=event.target.value; event.target.value=""; if (target) await performBulk("copy", target); });
$("#bulk-tag-select").addEventListener("change", async (event) => { const tagId=event.target.value; event.target.value=""; if (tagId) await bulkAddTag(tagId); });
$("#select-mode-btn").addEventListener("click", () => setSelectionMode(!state.selectionMode));
$("#selection-done").addEventListener("click", () => setSelectionMode(false));
$("#refresh-btn").addEventListener("click", () => loadFolders(false));
$("#message-sort").addEventListener("change", async (event) => {
const value = event.target.value;
state.mailPrefs.default_sort = value;
$("#mail-pref-sort").value = value;
try {
state.mailPrefs = await request("/settings/mailbox", { method:"PUT", body:JSON.stringify({ default_sort:value }) });
state.page = 0;
await loadMessages(true);
toast("Message sort updated", "success");
} catch (error) { toast(error.message, "error"); }
});
$("#load-more").addEventListener("click", async () => { state.page += 1; await loadMessages(false); });
$("#message-list").addEventListener("scroll", scheduleMessageListStateSave, { passive:true });
window.addEventListener("offline", () => setConnectivityState(false));
window.addEventListener("online", async () => {
setConnectivityState(true);
disconnectRealtime();
connectRealtime();
if (state.accounts.length) {
await refreshFolderListOnly();
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop:state.searchContext.scrollTop });
else await loadMessages(true);
}
});
$("#search-form").addEventListener("submit", searchMail);
$("#advanced-search-btn").addEventListener("click", openAdvancedSearch);
$("#advanced-search-form").addEventListener("submit", submitAdvancedSearch);
$("#search-reset-btn").addEventListener("click", resetAdvancedSearch);
$("#search-save-btn").addEventListener("click", saveAdvancedSearch);
$("#contacts-btn").addEventListener("click", openContacts);
$("#contact-group-filter").addEventListener("change", async () => { renderContactGroupFilter(); await loadContacts($("#contact-search").value.trim()); });
$("#contact-group-new").addEventListener("click", createContactGroup);
$("#contact-group-rename").addEventListener("click", renameContactGroup);
$("#contact-group-delete").addEventListener("click", deleteContactGroup);
$("#tag-add-btn").addEventListener("click", () => openTagDialog());
$("#tag-form").addEventListener("submit", saveTag);
$("#tag-delete").addEventListener("click", deleteTag);
$("#calendar-btn").addEventListener("click", openCalendar);
$("#settings-btn").addEventListener("click", () => openSettings("appearance"));
$("#shortcuts-open").addEventListener("click", () => $("#shortcuts-dialog").showModal());
$("#account-add").addEventListener("click", () => { $("#add-account-error").classList.add("hidden"); $("#add-account-dialog").showModal(); setTimeout(() => $("#add-account-email").focus(), 20); });
$("#account-revoke-others").addEventListener("click", revokeOtherSessions);
$("#add-account-form").addEventListener("submit", addBrowserAccount);
$("#mobile-folders").addEventListener("click", () => $("#folder-pane").classList.toggle("open"));
$("#reader-back").addEventListener("click", closeReader);
$("#reader-prev").addEventListener("click", () => navigateMessage(-1));
$("#reader-next").addEventListener("click", () => navigateMessage(1));
$("#thread-toggle").addEventListener("click", () => {
state.threadExpanded = !state.threadExpanded;
localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded));
if (state.currentDetail) renderThread(state.currentDetail);
});
$("#folder-manage-btn").addEventListener("click", openFolderDialog);
$("#create-folder-btn").addEventListener("click", createFolder);
$("#rename-folder-btn").addEventListener("click", renameFolder);
$("#delete-folder-btn").addEventListener("click", deleteFolder);
$("#subscribe-folder-btn").addEventListener("click", toggleCurrentFolderSubscription);
$("#headers-btn").addEventListener("click", showHeaders);
$("#source-btn").addEventListener("click", showMessageSource);
$("#quick-filter-btn").addEventListener("click", () => createFilterFromCurrentMessage("sender"));
$("#reader-from").addEventListener("click", (event) => { const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); });
$("#reader-from").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); } });
$("#print-btn").addEventListener("click", printCurrentMessage);
$("#pgp-reader-process").addEventListener("click", processCurrentPgpMessage);
$("#pgp-reader-discover").addEventListener("click", discoverCurrentPgpSigner);
$("#copy-headers-btn").addEventListener("click", copyHeaders);
$$(".close-dialog").forEach((button) => button.addEventListener("click", async () => {
const dialog = button.closest("dialog");
if (dialog?.id === "compose-dialog") await closeComposer();
else dialog?.close();
}));
$("#compose-dialog").addEventListener("cancel", async (event) => { event.preventDefault(); await closeComposer(); });
window.addEventListener("beforeunload", () => { if (state.composeDirty && draftHasContent()) storeLocalDraftSnapshot(); });
$("#compose-pgp-check").addEventListener("click", () => checkComposePgpRecipients(true));
$("#compose-pgp-discover").addEventListener("click", discoverComposePgpRecipients);
["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("change", () => { state.pgpRecipientStatus = []; updateComposePgpUi(); }));
$("#pgp-refresh").addEventListener("click", loadOpenPgp);
$("#pgp-pref-save").addEventListener("click", savePgpPreferences);
$("#pgp-generate").addEventListener("click", generatePgpKey);
$("#pgp-import").addEventListener("click", () => importPgpText());
$("#pgp-import-file").addEventListener("click", () => $("#pgp-import-file-input").click());
$("#pgp-import-file-input").addEventListener("change", async (event) => { const file = event.target.files?.[0]; if (file) await importPgpFile(file); event.target.value=""; });
$("#pgp-discover").addEventListener("click", () => discoverPgpEmail());
$$(".settings-tab").forEach((button) => button.addEventListener("click", () => { selectSettingsTab(button.dataset.tab); if (button.dataset.tab === "sieve") loadSieve(); if (button.dataset.tab === "identities") { loadIdentities(); loadOpenPgp(); } if (button.dataset.tab === "openpgp") loadOpenPgp(); if (button.dataset.tab === "notifications") loadNotificationPreferences(); if (button.dataset.tab === "mailbox") loadMailPreferences(); if (button.dataset.tab === "diagnostics") loadDiagnostics(); if (button.dataset.tab === "account") { renderAccountSettings(); loadActiveSessions(); } if (button.dataset.tab === "folders") { loadSpecialFolders(); loadQuota(); } }));
$("#pref-theme").addEventListener("change", (event) => savePreference("theme", event.target.value));
$("#pref-density").addEventListener("change", (event) => savePreference("density", event.target.value));
$("#pref-preview").addEventListener("change", (event) => savePreference("preview", event.target.value));
$("#pref-scale").addEventListener("change", (event) => savePreference("scale", event.target.value));
$("#pref-language").addEventListener("change", (event) => savePreference("language", event.target.value));
$("#pref-color-preset").addEventListener("change", (event) => savePreference("colorPreset", event.target.value));
$("#pref-accent").addEventListener("input", (event) => { $("#pref-accent-value").textContent = event.target.value; savePreference("accentColor", event.target.value); });
[["#pref-background","#pref-background-value","backgroundColor"],["#pref-panel","#pref-panel-value","panelColor"],["#pref-text-color","#pref-text-color-value","textColor"]].forEach(([input,output,key]) => $(input).addEventListener("input", (event) => { $(output).textContent = event.target.value; savePreference(key, event.target.value); }));
$("#pref-font-family").addEventListener("change", (event) => savePreference("fontFamily", event.target.value));
[["#pref-font-size","fontSize"],["#pref-row-height","rowHeight"],["#pref-folder-width","folderPaneWidth"],["#pref-message-width","messagePaneWidth"],["#pref-radius","borderRadius"]].forEach(([selector,key]) => $(selector).addEventListener("input", (event) => savePreference(key, Number(event.target.value))));
$("#pref-contrast").addEventListener("change", (event) => savePreference("contrast", event.target.value));
$("#pref-date-format").addEventListener("change", (event) => { savePreference("dateFormat", event.target.value); renderMessages(); if (state.currentDetail) renderMessageDetail(state.currentDetail); });
$("#pref-time-format").addEventListener("change", (event) => { savePreference("timeFormat", event.target.value); renderMessages(); if (state.currentDetail) renderMessageDetail(state.currentDetail); });
$("#pref-avatars").addEventListener("change", (event) => savePreference("showAvatars", event.target.checked));
$("#pref-attachment-icons").addEventListener("change", (event) => savePreference("showAttachmentIcons", event.target.checked));
$("#pref-flags").addEventListener("change", (event) => savePreference("showFlags", event.target.checked));
$("#pref-reduce-motion").addEventListener("change", (event) => savePreference("reduceMotion", event.target.checked));
[["#pref-icon-size","iconSize"],["#pref-sidebar-height","sidebarItemHeight"],["#pref-composer-width","composerWidth"],["#pref-unread-weight","unreadFontWeight"]].forEach(([selector,key]) => $(selector).addEventListener("input", (event) => savePreference(key, Number(event.target.value))));
$("#pref-reader-width").addEventListener("change", (event) => savePreference("readerMaxWidth", Number(event.target.value)));
$("#pref-toolbar-density").addEventListener("change", (event) => savePreference("toolbarDensity", event.target.value));
$("#pref-selection-color").addEventListener("input", (event) => { $("#pref-selection-color-value").textContent = event.target.value; savePreference("selectionColor", event.target.value); });
$("#pref-unread-badge").addEventListener("change", (event) => savePreference("unreadBadgeStyle", event.target.value));
$("#pref-scrollbar-density").addEventListener("change", (event) => savePreference("scrollbarDensity", event.target.value));
$("#pref-compact-headers").addEventListener("change", (event) => savePreference("compactHeaders", event.target.checked));
$("#pref-show-borders").addEventListener("change", (event) => savePreference("showBorders", event.target.checked));
$("#pref-monochrome").addEventListener("change", (event) => savePreference("monochrome", event.target.checked));
[["folder","folderBackground","#101310"],["message","messageBackground","#181c19"],["reader","readerBackground","#181c19"]].forEach(([name,key,fallback]) => {
const enabled = $(`#pref-${name}-bg-enabled`); const color = $(`#pref-${name}-bg`);
enabled.addEventListener("change", () => savePreference(key, enabled.checked ? color.value : ""));
color.addEventListener("input", () => { if (enabled.checked) savePreference(key, color.value); });
});
$("#compose-pref-format").addEventListener("change", (event) => savePreference("composeFormat", event.target.value));
$("#compose-pref-autosave").addEventListener("change", (event) => savePreference("composeAutosaveMs", Number(event.target.value)));
$("#compose-pref-priority").addEventListener("change", (event) => savePreference("defaultPriority", event.target.value));
$("#compose-pref-mdn").addEventListener("change", (event) => savePreference("requestMdnDefault", event.target.checked));
$("#compose-pref-attachment-reminder").addEventListener("change", (event) => savePreference("attachmentReminder", event.target.checked));
$("#compose-pref-font").addEventListener("change", (event) => savePreference("composeFontFamily", event.target.value));
$("#compose-pref-reply-position").addEventListener("change", (event) => savePreference("replyPosition", event.target.value));
$("#compose-pref-quote-style").addEventListener("change", (event) => savePreference("quoteStyle", event.target.value));
$("#compose-pref-forward-mode").addEventListener("change", (event) => savePreference("forwardMode", event.target.value));
$("#compose-pref-spellcheck").addEventListener("change", (event) => savePreference("spellcheckEnabled", event.target.checked));
$("#compose-markdown-preview").addEventListener("click", previewMarkdown);
$$(`[data-appearance-preset]`).forEach((button) => button.addEventListener("click", () => applyAppearancePreset(button.dataset.appearancePreset)));
$("#pref-reset").addEventListener("click", resetAppearance);
$("#special-folders-save").addEventListener("click", saveSpecialFolders);
$("#special-folders-auto").addEventListener("click", autoDetectSpecialFolders);
$("#identity-form").addEventListener("submit", saveIdentity);
$("#identity-new").addEventListener("click", () => editIdentity(null));
$("#identity-delete").addEventListener("click", deleteIdentity);
$$("[data-signature-command]").forEach((button) => button.addEventListener("click", () => {
const editor = $("#identity-signature");
editor.focus();
document.execCommand(button.dataset.signatureCommand, false, null);
}));
$("#identity-signature-link").addEventListener("click", async () => {
const editor = $("#identity-signature");
const range = captureEditorRange(editor);
const url = await appPrompt("Insert signature link", { placeholder:"https://example.com", type:"url", confirmLabel:"Insert" });
if (!url) return;
editor.focus();
restoreEditorRange(range, editor);
document.execCommand("createLink", false, url);
});
$("#identity-signature-image-btn").addEventListener("click", () => { state.identitySignatureRange = captureEditorRange($("#identity-signature")); $("#identity-signature-image").click(); });
$("#identity-signature-image").addEventListener("change", async (event) => { const file = event.target.files?.[0]; await insertIdentitySignatureImage(file); event.target.value = ""; });
$("#identity-signature").addEventListener("paste", async (event) => {
const file = [...(event.clipboardData?.items || [])].find((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type))?.getAsFile();
if (!file) return;
event.preventDefault();
await insertIdentitySignatureImage(file, captureEditorRange($("#identity-signature")));
});
$("#contact-form").addEventListener("submit", saveContact);
$("#contact-new").addEventListener("click", () => editContact(null));
$("#contact-import").addEventListener("click", () => $("#contact-import-file").click());
$("#contact-import-file").addEventListener("change", (event) => importContactVcard(event.target.files));
$("#contact-export").addEventListener("click", downloadContactExport);
$("#contact-duplicates").addEventListener("click", showContactDuplicates);
$("#contact-photo-upload").addEventListener("click", () => $("#contact-photo-file").click());
$("#contact-photo-file").addEventListener("change", (event) => uploadContactPhoto(event.target.files));
$("#contact-photo-delete").addEventListener("click", deleteContactPhoto);
$("#contact-delete").addEventListener("click", deleteContact);
$("#contact-compose").addEventListener("click", composeToContact);
$("#contact-search").addEventListener("input", (event) => { clearTimeout(event.target._timer); event.target._timer = setTimeout(() => loadContacts(event.target.value.trim()), 180); });
$("#calendar-form").addEventListener("submit", saveCalendarEvent);
$("#calendar-import").addEventListener("click", () => $("#calendar-import-file").click());
$("#calendar-import-file").addEventListener("change", (event) => importCalendarIcs(event.target.files));
$("#calendar-export").addEventListener("click", exportCalendarIcs);
$("#calendar-recurrence").addEventListener("change", updateCalendarRecurrenceUi);
$("#calendar-new").addEventListener("click", () => editCalendarEvent(null));
$("#calendar-cancel").addEventListener("click", () => $("#calendar-form").classList.add("hidden"));
$("#calendar-delete").addEventListener("click", deleteCalendarEvent);
$("#calendar-view").addEventListener("change", async (event) => { state.calendarView = event.target.value; localStorage.setItem("rustbox.calendarView", state.calendarView); await loadCalendar(); });
$("#calendar-prev").addEventListener("click", async () => { shiftCalendarCursor(-1); await loadCalendar(); });
$("#calendar-next").addEventListener("click", async () => { shiftCalendarCursor(1); await loadCalendar(); });
$("#calendar-today").addEventListener("click", async () => { state.calendarCursor = new Date(); await loadCalendar(); });
$("#notify-save").addEventListener("click", saveNotificationPreferences);
$("#notify-permission").addEventListener("click", requestNotificationPermission);
$("#mail-pref-save").addEventListener("click", saveMailPreferences);
$("#mail-pref-reconcile-labels")?.addEventListener("click", reconcileImapLabels);
$("#diagnostics-refresh").addEventListener("click", loadDiagnostics);
$("#diagnostic-imap-reconnect").addEventListener("click", reconnectImap);
$("#diagnostic-search-rebuild").addEventListener("click", rebuildSearchIndex);
$("#remote-image-load").addEventListener("click", () => { if (state.currentDetail) renderMessageBody(state.currentDetail, true); });
const sieveBuilderFields = [
"#sieve-builder-name", "#sieve-builder-enabled", "#sieve-builder-stop",
"#sieve-builder-condition", "#sieve-builder-match", "#sieve-builder-header", "#sieve-builder-value", "#sieve-builder-negate",
"#sieve-builder-condition-2", "#sieve-builder-match-2", "#sieve-builder-header-2", "#sieve-builder-value-2", "#sieve-builder-negate-2", "#sieve-builder-logic",
"#sieve-builder-condition-3", "#sieve-builder-match-3", "#sieve-builder-header-3", "#sieve-builder-value-3", "#sieve-builder-negate-3", "#sieve-builder-logic-3",
"#sieve-builder-action", "#sieve-builder-target", "#sieve-builder-action-2", "#sieve-builder-target-2",
];
sieveBuilderFields.forEach((selector) => {
const node = $(selector);
if (!node) return;
node.addEventListener("input", updateSieveBuilder);
node.addEventListener("change", updateSieveBuilder);
});
$("#sieve-rule-new").addEventListener("click", openNewSieveRuleDialog);
$("#sieve-builder-add-condition").addEventListener("click", () => { $("#sieve-builder-extra-conditions").append(createSieveDynamicCondition()); updateSieveBuilder(); });
$("#sieve-builder-add-action").addEventListener("click", () => { $("#sieve-builder-extra-actions").append(createSieveDynamicAction()); updateSieveBuilder(); });
$("#sieve-vacation-start").addEventListener("change", updateSieveBuilder);
$("#sieve-vacation-end").addEventListener("change", updateSieveBuilder);
$("#sieve-builder-append").addEventListener("click", () => applySieveVisualRule(false));
$("#sieve-builder-replace").addEventListener("click", () => applySieveVisualRule(true));
$("#sieve-builder-clear").addEventListener("click", clearSieveRuleBuilder);
$("#sieve-editor").addEventListener("input", () => {
state.sieveEditingRuleId = null;
renderManagedSieveRules();
updateSieveBuilder();
});
$("#sieve-vacation-append").addEventListener("click", appendSieveVacation);
$("#sieve-backup").addEventListener("click", backupSieveScripts);
$("#sieve-restore").addEventListener("click", () => $("#sieve-restore-file").click());
$("#sieve-restore-file").addEventListener("change", (event) => restoreSieveBackup(event.target.files?.[0]));
$("#sieve-refresh").addEventListener("click", loadSieve);
$("#sieve-new").addEventListener("click", newSieveScript);
$("#sieve-check").addEventListener("click", validateSieve);
$("#sieve-save").addEventListener("click", saveSieve);
$("#sieve-activate").addEventListener("click", activateSieve);
$("#sieve-deactivate").addEventListener("click", deactivateSieve);
$("#sieve-delete").addEventListener("click", deleteSieve);
bindEditor();
document.addEventListener("click", (event) => { if (!event.target.closest("#context-menu")) hideContextMenu(); });
window.addEventListener("blur", hideContextMenu);
document.addEventListener("scroll", hideContextMenu, true);
document.addEventListener("keydown", (event) => {
const tag = document.activeElement?.tagName || "";
if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(tag) && !document.activeElement?.isContentEditable) {
event.preventDefault(); $("#search-input").focus();
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "n") { event.preventDefault(); openCompose("new"); }
if (event.key === "?" && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable) {
event.preventDefault();
if (!document.querySelector("dialog[open]")) $("#shortcuts-dialog").showModal();
}
const dialogOpen = Boolean(document.querySelector("dialog[open]"));
const keyboardIdle = !dialogOpen && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable;
if (keyboardIdle) {
const globalKey = event.key.toLowerCase();
if (globalKey === "c") { event.preventDefault(); openCompose("new"); return; }
if (globalKey === "v") { event.preventDefault(); setSelectionMode(!state.selectionMode); return; }
}
if (keyboardIdle && state.currentDetail) {
const key = event.key.toLowerCase();
if (key === "x") {
const current = state.messages.find((message) => message.uid === state.currentDetail.uid && (message.folder || state.folder) === state.currentDetail.folder);
if (current) { event.preventDefault(); setMessageSelected(current, !state.selectedMessages.has(messageKey(current))); }
}
else if (key === "j") { event.preventDefault(); navigateMessage(1); }
else if (key === "k") { event.preventDefault(); navigateMessage(-1); }
else if (key === "r") { event.preventDefault(); openCompose("reply"); }
else if (key === "a") { event.preventDefault(); openCompose("reply-all"); }
else if (key === "f") { event.preventDefault(); openCompose("forward"); }
else if (key === "e") { event.preventDefault(); archiveCurrent(); }
else if (key === "m") { event.preventDefault(); toggleUnread(); }
else if (key === "s") { event.preventDefault(); toggleFlagged(); }
else if (event.key === "!") { event.preventDefault(); junkCurrent(); }
else if (event.key === "Delete") { event.preventDefault(); deleteCurrent(); }
}
if (event.key === "Escape" && innerWidth <= 820) closeReader();
});
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (state.prefs.theme === "system") applyPreferences(); });
}
async function boot() {
applyPreferences();
bindEvents();
await loadAuthPolicy();
const authenticated = await loadAccounts();
if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadFolders(true); await loadSpecialFolders(); await loadTags(); await loadNotificationPreferences(); connectRealtime(); } else { await loadTranslations(state.prefs.language); }
}
boot();
})();