function clockMinutes(value, fallback) { const match = /^(\d{2}):(\d{2})$/.exec(String(value || "")); return match ? Number(match[1]) * 60 + Number(match[2]) : fallback; } // "schedule": light between theme_light_from and theme_dark_from (local time), // dark otherwise; the window may wrap past midnight. function scheduledTheme(prefs = state.prefs, now = new Date()) { const lightFrom = clockMinutes(prefs.themeLightFrom, 7 * 60); const darkFrom = clockMinutes(prefs.themeDarkFrom, 19 * 60); const minute = now.getHours() * 60 + now.getMinutes(); if (lightFrom === darkFrom) return "light"; const light = lightFrom < darkFrom ? minute >= lightFrom && minute < darkFrom : minute >= lightFrom || minute < darkFrom; return light ? "light" : "dark"; } function resolveTheme(theme) { if (theme === "schedule") return scheduledTheme(); if (theme !== "system") return theme; return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; } const preferenceMap = { theme: "theme", interfaceSkin: "interface_skin", presetScaleUnlocked: "preset_scale_unlocked", 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", adaptiveEmailColors: "adaptive_email_colors", skinDepth: "skin_depth", skinGradients: "skin_gradients", themeLightFrom: "theme_light_from", themeDarkFrom: "theme_dark_from", accountColor: "account_color", }; const preferenceTimers = new Map(); let presetPromotionTimer = null; let presetPromotionInFlight = false; let presetPromotionNeedsAnotherSave = false; let presetPromotionActive = false; const temporarilyDisabledPanePreferences = new Set(); const interfaceStyleProfiles = { "roundcube-classic": { label: "Roundcube Classic", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 44, folderPaneWidth: 224, messagePaneWidth: 410, borderRadius: 0, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 24, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#cc3333", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, "roundcube-larry": { label: "Roundcube Larry", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 48, folderPaneWidth: 230, messagePaneWidth: 420, borderRadius: 3, contrast: "normal", iconSize: 17, toolbarDensity: "normal", sidebarItemHeight: 28, readerMaxWidth: 0, composerWidth: 920, selectionColor: "#c7e3ef", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, "roundcube-elastic": { label: "Roundcube Elastic", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 52, folderPaneWidth: 238, messagePaneWidth: 440, borderRadius: 4, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 35, readerMaxWidth: 0, composerWidth: 940, selectionColor: "#ebf8ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, clean: { label: "Clean", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 58, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 8, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#dce9ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, gmail: { label: "Gmail", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 248, messagePaneWidth: 440, borderRadius: 10, contrast: "soft", iconSize: 19, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 960, selectionColor: "#d3e3fd", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, proton: { label: "Proton", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 242, messagePaneWidth: 430, borderRadius: 9, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#e7e1ff", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, zoho: { label: "Zoho", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 420, borderRadius: 6, contrast: "normal", iconSize: 18, toolbarDensity: "compact", sidebarItemHeight: 30, readerMaxWidth: 0, composerWidth: 920, selectionColor: "#e1ebff", unreadFontWeight: 710, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, outlook: { label: "Outlook", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 50, folderPaneWidth: 232, messagePaneWidth: 420, borderRadius: 4, contrast: "normal", iconSize: 18, toolbarDensity: "compact", sidebarItemHeight: 30, readerMaxWidth: 0, composerWidth: 940, selectionColor: "#deecf9", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, minimal: { label: "Minimal", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 230, messagePaneWidth: 430, borderRadius: 12, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 860, composerWidth: 900, selectionColor: "#ececec", unreadFontWeight: 680, unreadBadgeStyle: "outline", compactHeaders: false, showBorders: false, scrollbarDensity: "hidden", monochrome: true }, }, graphite: { label: "Graphite", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 50, folderPaneWidth: 220, messagePaneWidth: 400, borderRadius: 2, contrast: "normal", iconSize: 17, toolbarDensity: "compact", sidebarItemHeight: 27, readerMaxWidth: 0, composerWidth: 880, selectionColor: "#32373c", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, fastmail: { label: "Fastmail", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 240, messagePaneWidth: 430, borderRadius: 7, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#d9e8ff", unreadFontWeight: 710, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, "apple-mail": { label: "Apple Mail", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 54, folderPaneWidth: 238, messagePaneWidth: 430, borderRadius: 9, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 920, composerWidth: 920, selectionColor: "#dcebff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, yahoo: { label: "Yahoo", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 244, messagePaneWidth: 435, borderRadius: 10, contrast: "normal", iconSize: 19, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 960, composerWidth: 950, selectionColor: "#ede3ff", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, hey: { label: "HEY", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "humanist", fontSize: 14, rowHeight: 62, folderPaneWidth: 250, messagePaneWidth: 450, borderRadius: 12, contrast: "normal", iconSize: 19, toolbarDensity: "spacious", sidebarItemHeight: 36, readerMaxWidth: 900, composerWidth: 930, selectionColor: "#fff0a8", unreadFontWeight: 760, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, nord: { label: "Nord", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 232, messagePaneWidth: 420, borderRadius: 6, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 30, readerMaxWidth: 940, composerWidth: 920, selectionColor: "#d8dee9", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, "high-contrast": { label: "High contrast", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 240, messagePaneWidth: 440, borderRadius: 2, contrast: "high", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 960, selectionColor: "#ffe500", unreadFontWeight: 800, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false }, }, github: { label: "GitHub", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 6, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 30, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#ddf4ff", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, gitlab: { label: "GitLab", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 238, messagePaneWidth: 430, borderRadius: 7, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 30, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#eee7ff", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, discord: { label: "Discord", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 246, messagePaneWidth: 440, borderRadius: 8, contrast: "normal", iconSize: 19, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 950, selectionColor: "#e0e3ff", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, slack: { label: "Slack", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 244, messagePaneWidth: 435, borderRadius: 8, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 960, composerWidth: 950, selectionColor: "#f3e6f5", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, reddit: { label: "Reddit", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 9, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 31, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#fff0e8", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, notion: { label: "Notion", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 58, folderPaneWidth: 238, messagePaneWidth: 430, borderRadius: 5, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 900, composerWidth: 920, selectionColor: "#efefef", unreadFontWeight: 700, unreadBadgeStyle: "outline", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, dracula: { label: "Dracula", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 54, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 8, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 31, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#44475a", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, catppuccin: { label: "Catppuccin", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 58, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 10, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#3a3350", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, solarized: { label: "Solarized", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 4, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 29, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#0d4a5a", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, gruvbox: { label: "Gruvbox", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 50, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 2, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 28, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#504945", unreadFontWeight: 760, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, "tokyo-night": { label: "Tokyo Night", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 8, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#2e3c64", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, "rose-pine": { label: "Rosé Pine", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 58, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 10, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 900, composerWidth: 940, selectionColor: "#403d52", unreadFontWeight: 720, unreadBadgeStyle: "outline", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, everforest: { label: "Everforest", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 8, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#425047", unreadFontWeight: 720, unreadBadgeStyle: "outline", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, synthwave: { label: "Synthwave", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 6, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#44306b", unreadFontWeight: 760, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, paper: { label: "Paper", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "humanist", fontSize: 14, rowHeight: 60, folderPaneWidth: 232, messagePaneWidth: 420, borderRadius: 3, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 760, composerWidth: 940, selectionColor: "#42372a", unreadFontWeight: 700, unreadBadgeStyle: "outline", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, terminal: { label: "Terminal", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "mono", fontSize: 12, rowHeight: 46, folderPaneWidth: 230, messagePaneWidth: 440, borderRadius: 0, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 26, readerMaxWidth: 0, composerWidth: 940, selectionColor: "#123d1a", unreadFontWeight: 760, unreadBadgeStyle: "solid", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false }, }, aqua: { label: "Aqua Glass", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 58, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 10, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#1e3a5f", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, linear: { label: "Linear", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 46, folderPaneWidth: 232, messagePaneWidth: 440, borderRadius: 6, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 28, readerMaxWidth: 860, composerWidth: 940, selectionColor: "#1d1f33", unreadFontWeight: 640, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, material: { label: "Material You", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 256, messagePaneWidth: 440, borderRadius: 12, contrast: "normal", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 40, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#4a4458", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, retro: { label: "Retro 95", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 44, folderPaneWidth: 220, messagePaneWidth: 420, borderRadius: 0, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 24, readerMaxWidth: 0, composerWidth: 880, selectionColor: "#000080", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "normal", monochrome: false }, }, winxp: { label: "Windows XP", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 48, folderPaneWidth: 232, messagePaneWidth: 430, borderRadius: 3, contrast: "normal", iconSize: 16, toolbarDensity: "normal", sidebarItemHeight: 26, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#c1d2ee", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false }, }, osx: { label: "Mac OS X", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 50, folderPaneWidth: 220, messagePaneWidth: 430, borderRadius: 5, contrast: "normal", iconSize: 16, toolbarDensity: "normal", sidebarItemHeight: 24, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#c5d8f4", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false }, }, ios6: { label: "iOS 6", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 62, folderPaneWidth: 250, messagePaneWidth: 440, borderRadius: 5, contrast: "normal", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 40, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#cfe0f7", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false }, }, win7: { label: "Windows 7", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 3, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 28, readerMaxWidth: 960, composerWidth: 900, selectionColor: "#cce8ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false }, }, "frutiger-aero": { label: "Frutiger Aero", preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 244, messagePaneWidth: 440, borderRadius: 12, contrast: "normal", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 36, readerMaxWidth: 900, composerWidth: 940, selectionColor: "#c9ecff", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false }, }, beos: { label: "BeOS", preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 46, folderPaneWidth: 224, messagePaneWidth: 430, borderRadius: 2, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 26, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#d0dcf2", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "normal", monochrome: false }, }, }; function resolvedInterfaceSkin(prefs = state.prefs) { return prefs.interfaceSkin === "default" ? (root.dataset.defaultInterfaceSkin || "rustbox") : (prefs.interfaceSkin || root.dataset.defaultInterfaceSkin || "rustbox"); } function activeInterfaceStyle(prefs = state.prefs) { return interfaceStyleProfiles[resolvedInterfaceSkin(prefs)] || null; } const skinDepths = ["colors", "standard", "immersive"]; const presetColorPreferenceKeys = new Set([ "colorPreset", "accentColor", "backgroundColor", "panelColor", "textColor", "selectionColor", "folderBackground", "messageBackground", "readerBackground", ]); function resolvedSkinDepth(prefs = state.prefs) { return skinDepths.includes(prefs.skinDepth) ? prefs.skinDepth : "standard"; } // Style depth: "colors" = palette only; "standard" adds the layout profile; "immersive" adds the structural tokens. function layoutInterfaceStyle(prefs = state.prefs) { return resolvedSkinDepth(prefs) === "colors" ? null : activeInterfaceStyle(prefs); } const localizedTextNodes = new Map(); const localizedAttributes = new Map(); function translateScopedUiText(locale) { for (const [node, original] of localizedTextNodes.entries()) { if (node.isConnected) node.nodeValue = original; else localizedTextNodes.delete(node); } for (const [node, originals] of localizedAttributes.entries()) { if (!node.isConnected) { localizedAttributes.delete(node); continue; } for (const [attribute, original] of Object.entries(originals)) node.setAttribute(attribute, original); } if (locale !== "pl") return; const map = state.translations._text || {}; if (!map || typeof map !== "object") return; $$('[data-i18n-scope]').forEach((scope) => { const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { const parent = node.parentElement; if (!parent || parent.closest("script,style,pre,code,[data-i18n-skip]")) continue; if (!localizedTextNodes.has(node)) localizedTextNodes.set(node, node.nodeValue); const original = localizedTextNodes.get(node) || ""; const normalized = original.trim().replace(/\s+/g, " "); const translated = map[normalized]; if (!translated) continue; const leading = original.match(/^\s*/)?.[0] || ""; const trailing = original.match(/\s*$/)?.[0] || ""; node.nodeValue = `${leading}${translated}${trailing}`; } const nodes = [scope, ...scope.querySelectorAll("[title],[aria-label],[placeholder],[data-placeholder],optgroup[label]")]; for (const node of nodes) { const originals = localizedAttributes.get(node) || {}; for (const attribute of ["title", "aria-label", "placeholder", "data-placeholder", "label"]) { if (!node.hasAttribute(attribute)) continue; if (!(attribute in originals)) originals[attribute] = node.getAttribute(attribute); const translated = map[originals[attribute]]; if (translated) node.setAttribute(attribute, translated); } localizedAttributes.set(node, originals); } }); } 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]); }); translateScopedUiText(locale); } function tr(key, fallback = key) { return state.translations[key] || fallback; } function trText(text) { const source = String(text ?? ""); const map = state.translations?._text || {}; if (map[source]) return map[source]; if (!source || !Object.keys(map).length) return source; if (trText._map !== map) { trText._map = map; trText._templates = Object.entries(map) .filter(([template]) => /\{\w+\}/.test(template)) .map(([template, translated]) => { const names = []; const pattern = template .split(/(\{\w+\})/g) .filter(Boolean) .map((part) => { const match = /^\{(\w+)\}$/.exec(part); if (match) { names.push(match[1]); return "(.+?)"; } return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }) .join(""); return { regex: new RegExp(`^${pattern}$`, "s"), names, translated, staticLength: template.replace(/\{\w+\}/g, "").length, }; }) .sort((a, b) => b.staticLength - a.staticLength); } for (const entry of trText._templates || []) { const match = entry.regex.exec(source); if (!match) continue; let translated = entry.translated; entry.names.forEach((name, index) => { translated = translated.replaceAll(`{${name}}`, match[index + 1]); }); return translated; } return source; } function fmtText(template, values = {}) { return trText(template).replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? "")); } function applyPreferences() { const p = state.prefs; const paletteStyle = activeInterfaceStyle(p); const fixedStyle = layoutInterfaceStyle(p); const skinDepth = resolvedSkinDepth(p); const previousLook = `${root.dataset.theme}|${root.dataset.interfaceSkin}|${root.dataset.skinDepth}|${root.dataset.skinGradients}`; // The style fixes the layout unless editing is allowed. const visual = fixedStyle && !p.presetScaleUnlocked ? { ...p, ...fixedStyle.preferences } : p; root.dataset.theme = resolveTheme(p.theme); root.dataset.interfaceSkin = resolvedInterfaceSkin(p); root.dataset.skinDepth = skinDepth; root.dataset.skinGradients = String(Boolean(p.skinGradients)); if (/^#[0-9a-f]{6}$/i.test(p.accountColor || "")) { root.dataset.accountColor = "true"; root.style.setProperty("--account-color", p.accountColor); } else { delete root.dataset.accountColor; root.style.removeProperty("--account-color"); } const nextLook = `${root.dataset.theme}|${root.dataset.interfaceSkin}|${root.dataset.skinDepth}|${root.dataset.skinGradients}`; if (root.dataset.lookReady === "true" && previousLook !== nextLook && !p.reduceMotion) { root.classList.add("theme-transition"); clearTimeout(applyPreferences.transitionTimer); applyPreferences.transitionTimer = setTimeout(() => root.classList.remove("theme-transition"), 360); } root.dataset.lookReady = "true"; const scheduleRow = $("#pref-theme-schedule-row"); if (scheduleRow) scheduleRow.classList.toggle("hidden", p.theme !== "schedule"); root.dataset.density = visual.density; root.dataset.preview = visual.preview; root.dataset.scale = visual.scale; root.dataset.colorPreset = p.colorPreset; root.dataset.fontFamily = visual.fontFamily; root.dataset.contrast = visual.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 = visual.toolbarDensity; root.dataset.unreadBadge = visual.unreadBadgeStyle; root.dataset.compactHeaders = String(visual.compactHeaders); root.dataset.showBorders = String(visual.showBorders); root.dataset.scrollbarDensity = visual.scrollbarDensity; root.dataset.monochrome = String(visual.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 })[visual.scale] || 1; const densityFactor = visual.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(visual.fontSize || 13) * scaleFactor * 100) / 100}px`); root.style.setProperty("--row-height", `${Math.max(42, Math.round(Number(visual.rowHeight || 58) * scaleFactor))}px`); root.style.setProperty("--folder-pane-width", `${Math.max(170, Math.round(Number(visual.folderPaneWidth || 228) * scaleFactor))}px`); root.style.setProperty("--message-pane-width", `${Math.max(300, Math.round(Number(visual.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(visual.iconSize || 18) * scaleFactor))}px`); const toolbarFactor = ({ compact: 0.88, normal: 1, spacious: 1.14 })[visual.toolbarDensity] || 1; root.style.setProperty("--toolbar-density-factor", String(toolbarFactor)); root.style.setProperty("--sidebar-item-height", `${Math.max(24, Math.round(Number(visual.sidebarItemHeight || 29) * scaleFactor))}px`); root.style.setProperty("--reader-max-width", Number(visual.readerMaxWidth) > 0 ? `${Number(visual.readerMaxWidth)}px` : "none"); root.style.setProperty("--composer-width", `${Math.max(640, Number(visual.composerWidth || 900))}px`); root.style.setProperty("--compose-font-family", composeFontCss(p.composeFontFamily)); const lockedRoundcubeSkin = Boolean(paletteStyle); if (lockedRoundcubeSkin) { ["--selected", "--folder-bg", "--message-bg", "--reader-bg"].forEach((name) => root.style.removeProperty(name)); } else { // The stored default (#28352e) was picked for the dark theme; treat it as // "automatic" so the light theme keeps its own readable selection colour. const selection = String(visual.selectionColor || "").toLowerCase(); if (!selection || selection === "#28352e") root.style.removeProperty("--selected"); else root.style.setProperty("--selected", visual.selectionColor); 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("--unread-font-weight", String(Number(visual.unreadFontWeight || 730))); 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", `${visual.borderRadius}px`); root.style.setProperty("--radius-panel", `${Math.min(visual.borderRadius + 1, 13)}px`); const presetCss = paletteStyle ? getComputedStyle(root) : null; const presetColor = (name, fallback) => presetCss ? presetCssHex(presetCss, name, fallback) : fallback; const displayedAccent = presetColor("--accent", p.accentColor); const displayedBackground = presetColor("--bg", p.backgroundColor); const displayedPanel = presetColor("--panel", p.panelColor); const displayedText = presetColor("--text", p.textColor); const displayedFolderBackground = presetColor("--folder-bg", p.folderBackground || "#101310"); const displayedMessageBackground = presetColor("--message-bg", p.messageBackground || "#181c19"); const displayedReaderBackground = presetColor("--reader-bg", p.readerBackground || "#181c19"); const values = { "#pref-theme": p.theme, "#pref-theme-light-from": p.themeLightFrom, "#pref-theme-dark-from": p.themeDarkFrom, "#pref-account-color": p.accountColor || "#4d75e6", "#pref-interface-skin": p.interfaceSkin, "#pref-skin-depth": skinDepth, "#pref-density": visual.density, "#pref-preview": visual.preview, "#pref-scale": visual.scale, "#pref-language": p.language, "#pref-color-preset": paletteStyle ? "custom" : p.colorPreset, "#pref-accent": displayedAccent, "#pref-background": displayedBackground, "#pref-panel": displayedPanel, "#pref-text-color": displayedText, "#pref-font-family": visual.fontFamily, "#pref-font-size": visual.fontSize, "#pref-row-height": visual.rowHeight, "#pref-folder-width": visual.folderPaneWidth, "#pref-message-width": visual.messagePaneWidth, "#pref-radius": visual.borderRadius, "#pref-contrast": visual.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": visual.iconSize, "#pref-toolbar-density": visual.toolbarDensity, "#pref-sidebar-height": visual.sidebarItemHeight, "#pref-reader-width": String(visual.readerMaxWidth), "#pref-composer-width": visual.composerWidth, "#pref-selection-color": visual.selectionColor, "#pref-unread-weight": visual.unreadFontWeight, "#pref-unread-badge": visual.unreadBadgeStyle, "#pref-scrollbar-density": visual.scrollbarDensity, }; Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; }); const checks = { "#pref-account-color-enabled": Boolean(p.accountColor), "#pref-preset-scale-unlocked": p.presetScaleUnlocked, "#pref-skin-gradients": p.skinGradients, "#pref-adaptive-email-colors": p.adaptiveEmailColors, "#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": visual.compactHeaders, "#pref-show-borders": visual.showBorders, "#pref-monochrome": visual.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": displayedAccent, "#pref-background-value": displayedBackground, "#pref-panel-value": displayedPanel, "#pref-text-color-value": displayedText, "#pref-font-size-value": `${visual.fontSize} px`, "#pref-row-height-value": `${visual.rowHeight} px`, "#pref-folder-width-value": `${visual.folderPaneWidth} px`, "#pref-message-width-value": `${visual.messagePaneWidth} px`, "#pref-radius-value": `${visual.borderRadius} px`, "#pref-icon-size-value": `${visual.iconSize} px`, "#pref-sidebar-height-value": `${visual.sidebarItemHeight} px`, "#pref-composer-width-value": `${visual.composerWidth} px`, "#pref-selection-color-value": visual.selectionColor, "#pref-unread-weight-value": String(visual.unreadFontWeight) }; Object.entries(outputs).forEach(([selector, value]) => { const node = $(selector); if (node) node.textContent = value; }); [["#pref-folder-bg", displayedFolderBackground], ["#pref-message-bg", displayedMessageBackground], ["#pref-reader-bg", displayedReaderBackground]].forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; }); const colorControls = [ "#pref-color-preset", "#pref-accent", "#pref-background", "#pref-panel", "#pref-text-color", "#pref-selection-color", "#pref-folder-bg-enabled", "#pref-folder-bg", "#pref-message-bg-enabled", "#pref-message-bg", "#pref-reader-bg-enabled", "#pref-reader-bg", ]; const layoutControls = [ "#pref-density", "#pref-preview", "#pref-font-family", "#pref-font-size", "#pref-row-height", "#pref-folder-width", "#pref-message-width", "#pref-radius", "#pref-contrast", "#pref-icon-size", "#pref-toolbar-density", "#pref-sidebar-height", "#pref-reader-width", "#pref-composer-width", "#pref-unread-weight", "#pref-unread-badge", "#pref-scrollbar-density", "#pref-compact-headers", "#pref-show-borders", "#pref-monochrome", "#pref-scale", ]; const paletteLocked = Boolean(paletteStyle) && !p.presetScaleUnlocked; const layoutLocked = Boolean(fixedStyle) && !p.presetScaleUnlocked; colorControls.forEach((selector) => { const node = $(selector); if (node) node.disabled = paletteLocked; }); layoutControls.forEach((selector) => { const node = $(selector); if (node) node.disabled = layoutLocked; }); const scaleUnlock = $("#pref-preset-scale-unlocked"); if (scaleUnlock) scaleUnlock.disabled = !paletteStyle; const scaleUnlockRow = $("#pref-preset-scale-unlocked-row"); if (scaleUnlockRow) scaleUnlockRow.classList.toggle("hidden", !paletteStyle); const depthNote = $("#pref-skin-depth-note"); if (depthNote) { depthNote.textContent = ({ colors: tr("skin_depth_colors_note", "Only the style's colors are used. Layout, spacing and typography follow your own settings below."), standard: tr("skin_depth_standard_note", "Colors plus the style's layout: density, row height, pane widths and type size."), immersive: tr("skin_depth_immersive_note", "Everything in Standard plus the style's shapes, typography, floating panes, app bar, selection markers and backdrop."), })[skinDepth]; } updateContrastWarning(Boolean(paletteStyle)); const skinNote = $("#pref-interface-skin-note"); if (skinNote) { const styleLabel = state.prefs.language === "pl" ? (state.translations._text?.[paletteStyle?.label] || paletteStyle?.label) : paletteStyle?.label; skinNote.textContent = paletteStyle ? `${styleLabel} — ${p.presetScaleUnlocked ? tr("edited_interface_style_note", "predefined interface style with your own layout. Changing a colour creates a RustBox custom copy.") : tr("fixed_interface_style_note", "predefined interface style. Allow editing below to adjust its layout and typography.")}` : tr("custom_interface_style_note", "RustBox is fully customizable: colors, spacing, panes, typography and advanced interface controls are available below."); } } let contrastProbe = null; function resolvedCssColor(expression) { if (!contrastProbe) { contrastProbe = document.createElement("span"); contrastProbe.setAttribute("aria-hidden", "true"); contrastProbe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none"; document.body.append(contrastProbe); } contrastProbe.style.color = ""; contrastProbe.style.color = expression; const value = getComputedStyle(contrastProbe).color; const rgb = value.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i); if (rgb) return rgb.slice(1, 4).map((part) => Number(part) / 255); const srgb = value.match(/color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i); return srgb ? srgb.slice(1, 4).map(Number) : null; } function contrastRatio(first, second) { const luminance = (rgb) => { const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const [a, b] = [luminance(first), luminance(second)].sort((x, y) => y - x); return (a + 0.05) / (b + 0.05); } // Live WCAG contrast hint for custom colours. function updateContrastWarning(usingPredefinedPalette) { const warning = $("#pref-contrast-warning"); if (!warning) return; if (usingPredefinedPalette || !document.body) { warning.classList.add("hidden"); return; } const color = (name) => resolvedCssColor(`var(${name})`); const checks = [ ["text", "panel", 4.5, "Text on panels"], ["text", "bg", 4.5, "Text on the background"], ["text", "selected", 4.5, "Text on the selection"], ["muted", "panel", 3, "Secondary text"], ["accent", "panel", 3, "Accent on panels"], ["accent-ink", "accent", 4.5, "Button text on the accent"], ]; const problems = []; for (const [fg, bg, minimum, label] of checks) { const a = color(`--${fg}`); const b = color(`--${bg}`); if (!a || !b) continue; const ratio = contrastRatio(a, b); if (ratio < minimum) problems.push(`${trText(label)} ${ratio.toFixed(1)}:1`); } warning.classList.toggle("hidden", problems.length === 0); warning.textContent = problems.length ? `${tr("contrast_warning", "Low contrast")}: ${problems.join(" · ")}` : ""; } function normalizeServerPreferences(data) { if (!data) return; state.prefs = { ...state.prefs, theme: data.theme || root.dataset.defaultTheme || "system", interfaceSkin: data.interface_skin || "default", skinDepth: skinDepths.includes(data.skin_depth) ? data.skin_depth : "standard", skinGradients: Boolean(data.skin_gradients), themeLightFrom: data.theme_light_from || "07:00", themeDarkFrom: data.theme_dark_from || "19:00", accountColor: data.account_color || "", presetScaleUnlocked: Boolean(data.preset_scale_unlocked), 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 || "", adaptiveEmailColors: Boolean(data.adaptive_email_colors), }; } 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); applyPreferences(); } 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 || 300); const values = { "#mail-pref-loading-mode": state.mailPrefs.message_loading_mode || "pagination", "#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); const compactWideRows = $("#pref-compact-wide-rows"); if (compactWideRows) compactWideRows.checked = state.mailPrefs.compact_wide_rows !== false; root.dataset.compactWideRows = state.mailPrefs.compact_wide_rows === false ? "false" : "true"; } catch (error) { console.warn("Could not load mailbox preferences", error); } } async function saveCompactWideRowsAppearance(enabled) { const previous = state.mailPrefs.compact_wide_rows !== false; root.dataset.compactWideRows = enabled ? "true" : "false"; try { state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ compact_wide_rows: Boolean(enabled) }), }); root.dataset.compactWideRows = state.mailPrefs.compact_wide_rows === false ? "false" : "true"; } catch (error) { const input = $("#pref-compact-wide-rows"); if (input) input.checked = previous; root.dataset.compactWideRows = previous ? "true" : "false"; toast(error.message, "error"); } } async function saveMailPreferences() { const payload = { message_loading_mode: $("#mail-pref-loading-mode").value, 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 || 300); root.dataset.compactWideRows = state.mailPrefs.compact_wide_rows === false ? "false" : "true"; 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 = trText("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; } } const presetEditablePreferenceKeys = new Set([ "colorPreset", "accentColor", "backgroundColor", "panelColor", "textColor", "density", "preview", "scale", "fontFamily", "fontSize", "rowHeight", "folderPaneWidth", "messagePaneWidth", "borderRadius", "contrast", "iconSize", "toolbarDensity", "sidebarItemHeight", "readerMaxWidth", "composerWidth", "selectionColor", "unreadFontWeight", "unreadBadgeStyle", "scrollbarDensity", "compactHeaders", "showBorders", "monochrome", "folderBackground", "messageBackground", "readerBackground", ]); function panePreferenceKeyFromValidationError(error) { const message = String(error?.message || ""); if (/Invalid\s+message_pane_width\s*:/i.test(message)) return "messagePaneWidth"; if (/Invalid\s+folder_pane_width\s*:/i.test(message)) return "folderPaneWidth"; return null; } function temporarilyDisablePanePreference(key, error = null) { if (!["folderPaneWidth", "messagePaneWidth"].includes(key)) return false; temporarilyDisabledPanePreferences.add(key); const handle = $(key === "folderPaneWidth" ? "#folder-resizer" : "#message-resizer"); if (handle) { handle.dataset.temporarilyDisabled = "true"; handle.setAttribute("aria-disabled", "true"); } if (error) console.warn(`Pane resize temporarily disabled for ${key}`, error); return true; } function restorePanePreference(key) { if (!temporarilyDisabledPanePreferences.delete(key)) return; const handle = $(key === "folderPaneWidth" ? "#folder-resizer" : key === "messagePaneWidth" ? "#message-resizer" : null); if (!handle) return; delete handle.dataset.temporarilyDisabled; handle.removeAttribute("aria-disabled"); } function suppressPaneWidthValidationError(error) { const key = panePreferenceKeyFromValidationError(error); return key ? temporarilyDisablePanePreference(key, error) : false; } function presetCssHex(styles, name, fallback) { const value = styles.getPropertyValue(name).trim(); if (/^#[0-9a-f]{6}$/i.test(value)) return value; const shortHex = value.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i); if (shortHex) return `#${shortHex.slice(1).map((part) => part + part).join("")}`; return fallback; } function customAppearancePayload(prefs) { const keys = [ "theme", "interfaceSkin", "skinDepth", "skinGradients", "presetScaleUnlocked", "colorPreset", "accentColor", "backgroundColor", "panelColor", "textColor", "density", "preview", "scale", "fontFamily", "fontSize", "rowHeight", "folderPaneWidth", "messagePaneWidth", "borderRadius", "contrast", "iconSize", "toolbarDensity", "sidebarItemHeight", "readerMaxWidth", "composerWidth", "selectionColor", "unreadFontWeight", "unreadBadgeStyle", "scrollbarDensity", "compactHeaders", "showBorders", "monochrome", "folderBackground", "messageBackground", "readerBackground", ]; return Object.fromEntries(keys.map((key) => [preferenceMap[key], prefs[key]]).filter(([field]) => field)); } function schedulePromotedAppearanceSave() { if (presetPromotionTimer) clearTimeout(presetPromotionTimer); if (presetPromotionInFlight) { presetPromotionNeedsAnotherSave = true; return; } presetPromotionTimer = setTimeout(async () => { presetPromotionTimer = null; presetPromotionInFlight = true; presetPromotionNeedsAnotherSave = false; try { await request("/settings/display", { method: "PUT", body: JSON.stringify(customAppearancePayload(state.prefs)) }); restorePanePreference("folderPaneWidth"); restorePanePreference("messagePaneWidth"); } catch (error) { if (!suppressPaneWidthValidationError(error)) toast(error.message, "error"); } finally { presetPromotionInFlight = false; if (presetPromotionNeedsAnotherSave) schedulePromotedAppearanceSave(); else presetPromotionActive = false; } }, 180); } const presetLayoutPreferenceKeys = [...presetEditablePreferenceKeys].filter((key) => !presetColorPreferenceKeys.has(key)); // Seeds the user's layout from the style so editing starts from what is on screen. async function setPresetEditing(enabled) { const layoutStyle = layoutInterfaceStyle(state.prefs); const payload = { preset_scale_unlocked: enabled }; if (enabled && layoutStyle) { for (const key of presetLayoutPreferenceKeys) { if (!(key in layoutStyle.preferences)) continue; state.prefs[key] = layoutStyle.preferences[key]; if (preferenceMap[key]) payload[preferenceMap[key]] = layoutStyle.preferences[key]; } } state.prefs.presetScaleUnlocked = enabled; applyPreferences(); try { await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }); } catch (error) { if (!suppressPaneWidthValidationError(error)) toast(error.message, "error"); } } // A colour change on a predefined style creates a RustBox custom copy of the current palette. async function promotePresetToCustom(key, value) { const paletteStyle = activeInterfaceStyle(state.prefs); if (!paletteStyle || !presetColorPreferenceKeys.has(key) || !state.prefs.presetScaleUnlocked) return false; const currentTheme = resolveTheme(state.prefs.theme); const pinTheme = state.prefs.theme !== currentTheme; const label = trText(paletteStyle.label); const message = `${trText("Colours of a predefined style cannot be changed in place.")}\n\n${trText("RustBox will switch to its custom style with a copy of the current colours, layout and typography of")} “${label}”.${pinTheme ? `\n${trText("The theme will be fixed to the current mode")} (${trText(currentTheme === "light" ? "Light" : "Dark")}), ${trText("because the copied colours belong to it.")}` : ""}\n\n${trText("Shapes, fonts, app bar and gradients specific to the style are not part of the copy.")}`; const confirmed = await appConfirm(message, { title: trText("Create a custom copy?"), confirmLabel: trText("Create custom copy") }); if (!confirmed) { applyPreferences(); return true; } const layoutStyle = layoutInterfaceStyle(state.prefs); const styles = getComputedStyle(root); state.prefs = { ...state.prefs, // With editing allowed the user's layout already holds the style's values // (seeded by setPresetEditing); without a fixed layout it is the user's own. ...(layoutStyle && !state.prefs.presetScaleUnlocked ? layoutStyle.preferences : {}), theme: currentTheme, interfaceSkin: "rustbox", presetScaleUnlocked: false, colorPreset: "custom", accentColor: presetCssHex(styles, "--accent", state.prefs.accentColor), backgroundColor: presetCssHex(styles, "--bg", state.prefs.backgroundColor), panelColor: presetCssHex(styles, "--panel", state.prefs.panelColor), textColor: presetCssHex(styles, "--text", state.prefs.textColor), selectionColor: presetCssHex(styles, "--selected", state.prefs.selectionColor), folderBackground: presetCssHex(styles, "--folder-bg", ""), messageBackground: presetCssHex(styles, "--message-bg", ""), readerBackground: presetCssHex(styles, "--reader-bg", ""), }; state.prefs[key] = value; if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom"; presetPromotionActive = true; applyPreferences(); schedulePromotedAppearanceSave(); return true; } function savePreference(key, value) { if (key === "presetScaleUnlocked") { setPresetEditing(Boolean(value)); return; } // Edited layout values belong to the style they were seeded from: a new // style starts locked again, and a new depth re-seeds from the same style. if (key === "interfaceSkin" && state.prefs.presetScaleUnlocked && value !== state.prefs.interfaceSkin) { state.prefs.presetScaleUnlocked = false; savePreference("presetScaleUnlocked", false); } if (key === "skinDepth" && state.prefs.presetScaleUnlocked && value !== state.prefs.skinDepth) { state.prefs.skinDepth = value; setPresetEditing(true); } if (activeInterfaceStyle(state.prefs) && state.prefs.presetScaleUnlocked && presetColorPreferenceKeys.has(key)) { promotePresetToCustom(key, value); return; } state.prefs[key] = value; if (presetPromotionActive && presetEditablePreferenceKeys.has(key)) { if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom"; applyPreferences(); schedulePromotedAppearanceSave(); return; } if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom"; applyPreferences(); if (key === "language") loadTranslations(value).then(applyPreferences); 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) }); restorePanePreference(key); } catch (error) { if (!suppressPaneWidthValidationError(error)) toast(error.message, "error"); } }, 180); preferenceTimers.set(key, timer); } async function resetAppearance() { const payload = { theme: root.dataset.defaultTheme || "system", interface_skin: "default", skin_depth: "standard", skin_gradients: false, theme_light_from: "07:00", theme_dark_from: "19:00", account_color: "", preset_scale_unlocked: false, 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: "", adaptive_email_colors: false }; try { normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) })); state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ compact_wide_rows: true }) }); const compactWideRows = $("#pref-compact-wide-rows"); if (compactWideRows) compactWideRows.checked = true; root.dataset.compactWideRows = "true"; applyPreferences(); toast("Appearance reset"); } 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() { root.dataset.theme = resolveTheme(root.dataset.defaultTheme || "system"); root.dataset.interfaceSkin = root.dataset.defaultInterfaceSkin || "rustbox"; root.dataset.colorPreset = "sage"; $("#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"); } function finishAuthenticatedStartup({ showLoginToast = false } = {}) { state.startupToastsMuted = false; armRealtimeNotifications(); if (showLoginToast) toast(tr("login_success", "Successfully logged in"), "success"); } function prepareAuthenticatedStartup() { state.startupToastsMuted = true; resetRealtimeNotificationGate(); } function settingsTabAvailable(name) { if (name === "openpgp") return state.authPolicy.pgpEnabled !== false; if (name === "sieve") return state.authPolicy.sieveEnabled !== false; if (name === "diagnostics") return state.authPolicy.diagnosticsEnabled !== false; if (name === "about") return state.authPolicy.aboutEnabled !== false; return true; } function applyFeatureAvailability() { for (const [name, enabled] of [ ["sieve", state.authPolicy.sieveEnabled !== false], ["diagnostics", state.authPolicy.diagnosticsEnabled !== false], ["about", state.authPolicy.aboutEnabled !== false], ]) { $(`.settings-tab[data-tab="${name}"]`)?.classList.toggle("hidden", !enabled); $(`.settings-panel[data-panel="${name}"]`)?.toggleAttribute("data-feature-disabled", !enabled); } $("#diagnostic-sieve-card")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false); $("#quick-filter-btn")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false); $("#account-add")?.classList.toggle("hidden", !multiAccountEnabled()); renderAccountSwitcher(); renderAccountNotificationSettings(); renderAccountSettings(); } async function loadAuthPolicy() { try { const policy = await request("/auth/policy", { method: "GET" }); state.authPolicy = { ...state.authPolicy, ...policy }; } catch (_) { state.authPolicy = { ...state.authPolicy, rememberMeEnabled: true }; } 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) return; input.disabled = !enabled; if (!enabled) input.checked = false; }); ["#login-remember-label", "#add-account-remember-label"].forEach((selector) => { const label = $(selector); if (label) label.textContent = trText("Keep me signed in"); }); } const NOTIFICATION_ACCOUNTS_STORAGE_KEY = "rustbox.notificationAccounts"; function multiAccountEnabled() { return state.authPolicy.multiAccountEnabled === true; } function syncNotificationAccountSelection() { let storedEmails = null; try { const raw = localStorage.getItem(NOTIFICATION_ACCOUNTS_STORAGE_KEY); if (raw !== null) { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) storedEmails = new Set(parsed.map((value) => String(value).trim().toLowerCase()).filter(Boolean)); } } catch (_) { } const selected = storedEmails === null ? state.accounts.map((account) => account.id) : state.accounts .filter((account) => storedEmails.has(String(account.email || "").trim().toLowerCase())) .map((account) => account.id); state.notificationAccountIds = new Set(selected); } function saveNotificationAccountSelection() { const selectedEmails = state.accounts .filter((account) => state.notificationAccountIds.has(account.id)) .map((account) => String(account.email || "").trim().toLowerCase()) .filter(Boolean); localStorage.setItem(NOTIFICATION_ACCOUNTS_STORAGE_KEY, JSON.stringify(selectedEmails)); } function notificationAccountEnabled(accountId) { if (!accountId) return false; if (!multiAccountEnabled()) return accountId === state.accountId; return state.notificationAccountIds.has(accountId); } function renderAccountSwitcher() { const switcher = $("#account-switcher"); const menu = $("#account-switcher-menu"); if (!switcher || !menu) return; const visible = multiAccountEnabled() && state.accounts.length > 1; if (!visible) { switcher.open = false; switcher.classList.add("hidden"); menu.replaceChildren(); return; } switcher.classList.remove("hidden"); menu.replaceChildren(); for (const account of state.accounts) { const button = el("button", account.id === state.accountId ? "active" : ""); button.type = "button"; button.setAttribute("role", "menuitem"); button.append( el("span", "", account.email || "Account"), el("span", "account-switcher-state", account.id === state.accountId ? "Current" : "Switch"), ); button.addEventListener("click", async () => { switcher.open = false; if (account.id !== state.accountId) await switchAccount(account.id); }); menu.append(button); } } function renderAccountNotificationSettings() { const section = $("#multi-account-notifications"); const list = $("#account-notification-list"); if (!section || !list) return; const visible = multiAccountEnabled() && state.accounts.length > 1; section.classList.toggle("hidden", !visible); list.replaceChildren(); if (!visible) return; for (const account of state.accounts) { const row = el("div", "account-settings-row account-notification-row"); const details = el("div", "account-session-details"); details.append( el("strong", "account-email", account.email || "Account"), el("span", "muted", account.id === state.accountId ? "Current account" : "Signed in account"), ); const label = el("label", "checkbox-row"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = state.notificationAccountIds.has(account.id); input.addEventListener("change", () => { if (input.checked) state.notificationAccountIds.add(account.id); else state.notificationAccountIds.delete(account.id); saveNotificationAccountSelection(); if (input.checked && typeof loadNotificationPreferencesForAccount === "function") void loadNotificationPreferencesForAccount(account.id); }); label.append(input, el("span", "", trText("Notify"))); row.append(details, label); list.append(row); } } async function loadSessionPolicy() { try { const policy = await request("/auth/session-policy", { method: "GET" }); state.authPolicy = { ...state.authPolicy, ...policy }; } catch (_) { state.authPolicy = { ...state.authPolicy, pgpEnabled: false, sieveEnabled: false, diagnosticsEnabled: false, aboutEnabled: false, multiAccountEnabled: false, }; } setOpenPgpAvailability(state.authPolicy.pgpEnabled !== false); applyFeatureAvailability(); } 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 = []; state.notificationAccountIds.clear(); state.notificationPrefsByAccount.clear(); 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; syncNotificationAccountSelection(); renderAccountSwitcher(); renderAccountNotificationSettings(); showApp(); return true; } catch (error) { console.error(error); showLogin(); return false; } } function renderAccountSettings() { const list = $("#account-settings-list"); if (!list) return; list.replaceChildren(); const accounts = multiAccountEnabled() ? state.accounts : state.accounts.filter((account) => account.id === state.accountId); if (!accounts.length) { list.append(el("div", "empty-list", trText("No active accounts."))); renderAccountSwitcher(); renderAccountNotificationSettings(); return; } for (const account of 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 || !multiAccountEnabled(); use.addEventListener("click", () => switchAccount(account.id)); row.append(email, use); if (multiAccountEnabled()) { const remove = el("button", "button quiet danger", trText("Remove")); remove.type = "button"; remove.addEventListener("click", () => removeBrowserAccount(account.id)); row.append(remove); } list.append(row); } renderAccountSwitcher(); renderAccountNotificationSettings(); } async function loadActiveSessions() { const list = $("#account-session-list"); if (!list) return; list.replaceChildren(el("div", "empty-list", trText("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", trText("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() { setFolderDrawerOpen(false); state.folder = "INBOX"; state.selected = null; state.currentDetail = null; state.selectedMessages.clear(); state.selectionMode = false; closeReader(); await loadSessionPolicy(); await loadPreferences(); await loadMailPreferences(); if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp(); else setOpenPgpAvailability(false); await loadIdentities(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); renderAccountSettings(); } async function switchAccount(accountId) { if (!multiAccountEnabled()) return; 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; renderAccountSwitcher(); renderAccountNotificationSettings(); try { await reloadActiveAccount(); } catch (error) { toast(error.message, "error"); } } async function removeBrowserAccount(accountId) { if (!multiAccountEnabled()) return; 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(); disconnectRealtime(); connectRealtime(); } catch (error) { toast(error.message, "error"); } } async function addBrowserAccount(event) { event.preventDefault(); if (!multiAccountEnabled()) return toast("Multiple accounts are disabled by the RustBox administrator", "warning"); 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); prepareAuthenticatedStartup(); 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 loadSessionPolicy(); await loadPreferences(); await loadMailPreferences(); if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp(); else setOpenPgpAvailability(false); await loadIdentities(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); finishAuthenticatedStartup({ showLoginToast: true }); } catch (error) { errorBox.textContent = error.message; errorBox.classList.remove("hidden"); } finally { setBusy(submit, false); } } async function logout() { disconnectRealtime(); prepareAuthenticatedStartup(); if (state.calendarReminderTimer) { clearInterval(state.calendarReminderTimer); state.calendarReminderTimer = null; } try { await request("/auth/logout", { method: "POST", body: "{}" }); } catch (_) { } state.accountId = null; state.accounts = []; state.notificationAccountIds.clear(); state.notificationPrefsByAccount.clear(); localStorage.removeItem("rustbox.activeAccount"); showLogin(); }