(() => { "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 ``; } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } 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(/]*>\s*]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, ""); text = text.replace(/<svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?<\/svg>/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))}${escapeHtml(folderLeafName(folder))}${folder.unread_count || folder.total_count || ""}`; 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 = `${escapeHtml(error.message)}`; } } 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 = `Loading messages…`; } $("#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 = `${escapeHtml(error.message)}`; $("#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 = `${state.searchContext ? "No matching messages." : "No messages in this folder."}`; 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 = `${escapeHtml(formatDate(message.date))}`; 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 = `${escapeHtml(error.message)}`; } } 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 = `${escapeHtml(item.from_name || item.from_address || "Unknown")} — ${escapeHtml(item.subject || "(no subject)")}${escapeHtml(formatDate(item.date))}`; 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")}${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB`; 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")}Download all as ZIP`; 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")}${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1,Math.ceil((attachment.size||0)/1024))} KB`; button.addEventListener("click", () => { try { const binary = atob(attachment.dataBase64 || ""); const bytes = new Uint8Array(binary.length); for (let i=0;iURL.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 = ``; 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 = `${csp}${safeHtml}`; 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", ""); } 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")}${escapeHtml(attachment.filename)} · ${Math.max(1, Math.ceil((attachment.size || 0)/1024))} KB`; 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(`${body}`); return token; }); html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label, rawUrl) => { let url = rawUrl.replaceAll("&", "&"); 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 `${label}`; }); html = html .replace(/\*\*([^*\n]+)\*\*/g, "$1") .replace(/__([^_\n]+)__/g, "$1") .replace(/~~([^~\n]+)~~/g, "$1") .replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/g, "$1$2") .replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?:;])/g, "$1$2"); 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(`${paragraph.map(markdownInlineToSafeHtml).join("")}`); 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(`${escapeHtml(codeLines.join("\n"))}`); 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(`${markdownInlineToSafeHtml(heading[2])}`); continue; } if (/^\s*(?:---+|\*\*\*+)\s*$/.test(line)) { flushParagraph(); closeList(); out.push(""); continue; } const quote = line.match(/^>\s?(.*)$/); if (quote) { flushParagraph(); closeList(); out.push(`${markdownInlineToSafeHtml(quote[1])}`); 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(`${markdownInlineToSafeHtml((unordered || ordered)[1])}`); continue; } paragraph.push(line); } if (inCode) out.push(`${escapeHtml(codeLines.join("\n"))}`); 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, "&").replace(/"/g, """); return `${html}`; } 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 = ``; 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 ? `${escapeHtml(detail.from_address || "")} · ${escapeHtml(formatDate(detail.date, true))}` : `On ${escapeHtml(formatDate(detail.date, true))}, ${escapeHtml(detail.from_address || "")} wrote:`; quote.innerHTML = `${attribution}${quoteTextAsHtml(detail.text || "")}`; } 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 = ""; 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 = `Forwarded messageFrom: ${escapeHtml(detail.from_address || "")}Date: ${escapeHtml(formatDate(detail.date, true))}Subject: ${escapeHtml(detail.subject || "")}${quoteTextAsHtml(detail.text || "")}`; } 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 = `Searching…`; 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) : `${escapeHtml(cleanPlainMessageText(detail.text || ""))}`; const frame = document.createElement("iframe"); frame.className = "print-frame"; frame.setAttribute("aria-hidden", "true"); const html = `${escapeHtml(detail.subject || "Message")}${escapeHtml(detail.subject || "(no subject)")}From${escapeHtml(detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address || "")}To${escapeHtml(to)}Date${escapeHtml(formatDate(detail.date, true))}${body}`; 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 = `${escapeHtml(contact.name || contact.email)}${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}`; 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 = `${escapeHtml(time)}${escapeHtml(event.title)}`; } else { button.innerHTML = `${escapeHtml(formatDate(event.start_time, true))}${escapeHtml(event.title)}${escapeHtml(event.location || "")}${escapeHtml(event.status || "")}`; } 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 { 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 = `${escapeHtml(key.userId || key.email || key.keyId)}${escapeHtml(key.email || "No email")} · ${key.hasSecret ? "private + public" : "public"} · ${escapeHtml(key.source || "import")}`; 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 = `${escapeHtml(identity.display_name || identity.email)}${escapeHtml(identity.email)}${identity.is_default ? " · default" : ""}`; 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, ``); } 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 `${escapeHtml(label)}${supported ? "yes" : "no"}`; } 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 = `When: ${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 = `Then: ${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 = `JoinANDORConditionFrom addressFrom domainTo / Cc addressFrom headerTo headerSubjectList-IDCustom headerMatchcontainsis exactlywildcard matchesHeader nameValueNOT / negateRemove`; 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 = `ActionMove to folderCopy to folderRedirectRedirect and keep copyDiscardMark readAdd IMAP flagSet IMAP flagsKeepFolderRemove`; 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 = `When: ${escapeHtml(built.conditionSummary)}Then: ${escapeHtml(built.actionSummary || "No action")}`; 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 = `${escapeHtml(script.name)}${script.active ? "ACTIVE" : ""}`; 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(); })();
${body}
${paragraph.map(markdownInlineToSafeHtml).join("")}
${escapeHtml(codeLines.join("\n"))}
${markdownInlineToSafeHtml(quote[1])}
${attribution}${quoteTextAsHtml(detail.text || "")}
Forwarded messageFrom: ${escapeHtml(detail.from_address || "")}Date: ${escapeHtml(formatDate(detail.date, true))}Subject: ${escapeHtml(detail.subject || "")}${quoteTextAsHtml(detail.text || "")}
${escapeHtml(cleanPlainMessageText(detail.text || ""))}