Files
RustBox/src/web/js/20-preferences-auth.js
T
Mateusz Gruszczyński 53f4812adc
RustBox CI / rustbox (push) Canceled after 0s
v0.2.26
2026-11-05 14:03:43 +01:00

1345 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}