import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { useThemeStore } from './theme-store'; import { useLocaleStore } from './locale-store'; import { usePolicyStore } from './policy-store'; import type { SettingsPolicy, SettingRestriction } from '@/lib/admin/types'; import type { EmailTemplate } from '@/lib/template-types'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '@/lib/notification-mutes'; import { apiFetch } from '@/lib/browser-navigation'; import { writePushPreferences } from '@/lib/push-preferences'; import { settingsHooks } from '@/lib/plugin-hooks'; import { generateAccountId } from '@/lib/account-utils'; import { subscriptionOwnerFor } from '@/lib/calendar-subscription-sync'; import type { FolderCountMode } from '@/lib/folder-counts'; import { orderForMailbox, sanitizeSortLevels, type MessageListOrderScope, type SortLevel } from '@/lib/message-list-order'; import { DEFAULT_SUB_ADDRESS_DELIMITER, isValidSubAddressDelimiter, } from '@/lib/sub-addressing'; // Use console directly to avoid circular dependency with lib/debug.ts // (debug.ts imports useSettingsStore for debugMode check) const syncLog = (...args: unknown[]) => console.log('[SETTINGS_SYNC]', ...args); const syncWarn = (...args: unknown[]) => console.warn('[SETTINGS_SYNC]', ...args); const syncError = (...args: unknown[]) => console.error('[SETTINGS_SYNC]', ...args); /** True for a non-null, non-array plain object (the allMailFolderIds map shape). */ function isPlainRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } // Settings sync state (module-level, not persisted) let syncEnabled = false; let syncUsername: string | null = null; let syncServerUrl: string | null = null; let syncTimeout: ReturnType | null = null; let pendingSync: SettingsSyncJob | null = null; let isLoadingFromServer = false; // Set by loadFromServer when this browser holds templates (or deletions) the // server blob lacks; enableSync then pushes once instead of waiting for the // next unrelated settings change. let pushTemplatesAfterLoad = false; // Wired up in the window-only init block below; null during SSR. let requestSync: (() => void) | null = null; // Account whose settings could not be loaded while this browser still held // another account's. enableSync leaves sync off for it, or the next change // would push the other account's settings under its name (#1185). let blockedSyncAccountId: string | null = null; // settings-storage is one store for the whole browser. This key names the // account whose server copy it currently mirrors, so a different account // signing in can tell the local settings are not its own (#1185). Unset in // browsers that never synced, where the local settings are the user's own // device settings and the first sign-in adopts them. const SETTINGS_OWNER_KEY = 'settings-sync-owner'; function readSettingsOwner(): string | null { try { return typeof window === 'undefined' ? null : window.localStorage.getItem(SETTINGS_OWNER_KEY); } catch { return null; } } function writeSettingsOwner(accountId: string | null): void { try { if (typeof window === 'undefined') return; if (accountId) window.localStorage.setItem(SETTINGS_OWNER_KEY, accountId); else window.localStorage.removeItem(SETTINGS_OWNER_KEY); } catch { /* storage unavailable */ } } const SYNC_DEBOUNCE_MS = 2000; interface SettingsSyncJob { username: string; serverUrl: string; settings: Record; } // --- Template sync bridge --------------------------------------------------- // Templates ride along in the synced settings blob (#825), but template-store // cannot be imported statically from here: it reaches this module through // lib/utils -> lib/debug, and the resulting import cycle leaves one side's // bindings uninitialized depending on which module loads first. Instead, // template-store registers itself here during its own module init. interface TemplateSyncBridge { getSyncedState: () => { templates: EmailTemplate[]; deletedTemplateIds: Record; }; applySyncedState: ( templates: unknown, deletedTemplateIds: unknown, opts: { merge: boolean } ) => void; } let templateSyncBridge: TemplateSyncBridge | null = null; // Wired up in the window-only init block below; null during SSR. let onTemplateStoreChange: (() => void) | null = null; export function registerTemplateSyncBridge( bridge: TemplateSyncBridge, subscribe: (listener: () => void) => void ): void { templateSyncBridge = bridge; subscribe(() => onTemplateStoreChange?.()); } /** Order-independent fingerprint of a template list plus its tombstones. */ function templateStateKey(templates: unknown, deletedTemplateIds: unknown): string { const list = Array.isArray(templates) ? templates .filter((t): t is { id: string; updatedAt?: unknown } => typeof t === 'object' && t !== null && typeof (t as { id?: unknown }).id === 'string') .map((t) => `${t.id}@${String(t.updatedAt)}`) .sort() : []; const tombstones = isPlainRecord(deletedTemplateIds) ? Object.keys(deletedTemplateIds).sort() : []; return JSON.stringify([list, tombstones]); } /** * Whether the local template state differs from what a server blob carries, * so the blob needs a push. False when there is nothing local to push. */ function localTemplatesAhead(serverSettings: Record | null): boolean { const local = templateSyncBridge?.getSyncedState(); if (!local) return false; if (local.templates.length === 0 && Object.keys(local.deletedTemplateIds).length === 0) return false; if (!serverSettings) return true; return templateStateKey(local.templates, local.deletedTemplateIds) !== templateStateKey(serverSettings.templates, serverSettings.deletedTemplateIds); } // --- Calendar subscription sync bridge --------------------------------------- // iCal subscriptions ride along in the synced settings blob to avoid turning // into writable/lost calendars on browser cache clear or new devices. interface CalendarSubscriptionSyncBridge { getSyncedState: () => { icalSubscriptions: Array<{ owner?: string }>; deletedSubscriptionIds: Record; }; applySyncedState: ( subscriptions: unknown, deletedSubscriptionIds: unknown, opts: { merge: boolean; owner?: string } ) => void; } let calendarSubscriptionSyncBridge: CalendarSubscriptionSyncBridge | null = null; let onCalendarSubscriptionStoreChange: (() => void) | null = null; export function registerCalendarSubscriptionSyncBridge( bridge: CalendarSubscriptionSyncBridge, subscribe: (listener: () => void) => void ): void { calendarSubscriptionSyncBridge = bridge; subscribe(() => onCalendarSubscriptionStoreChange?.()); } async function syncSettingsJob(job: SettingsSyncJob, retries = 1): Promise { syncLog('Syncing settings to server for', job.username); const res = await apiFetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(job), }); if (res.status === 404) { syncWarn('Settings sync endpoint returned 404, disabling sync'); syncEnabled = false; } else if (res.status === 403) { syncWarn('Settings sync rejected (identity mismatch), disabling sync'); syncEnabled = false; } else if (res.status >= 500 && retries > 0) { const body = await res.json().catch(() => ({})); syncWarn('Settings sync got server error:', body.error || `status ${res.status}`, '- retrying...'); await new Promise((resolve) => setTimeout(resolve, 2000)); return syncSettingsJob(job, retries - 1); } else if (!res.ok) { const body = await res.json().catch(() => ({})); syncError('Settings sync failed:', body.error || `status ${res.status}`); } else { syncLog('Settings synced to server successfully'); void settingsHooks.onSettingsSync.emit({ direction: 'pushed' }); } } /** * Keys the app writes for its own bookkeeping rather than because the user * changed a preference. Plugins are neither asked nor told about these: a * plugin vetoing "the welcome banner was dismissed" would leave the banner * stuck, and reporting it as a preference change is noise. * * How the line is drawn: a key belongs here when only the app writes it (a * dismissed banner, a finished tour) and Settings shows no control for it. * Everything else set through updateSetting is a user preference. When the * app itself writes a user-facing key (a one-time migration, a default * resolved on first connect), the caller passes `{ internal: true }`. * Imports, resets and server loads have their own hooks (onSettingsImport, * onSettingsReset, onSettingsSync) and do not fire per-key ones. */ const BOOKKEEPING_SETTING_KEYS = new Set(['onboardingCompleted', 'tourCompleted']); /** * Runs a user's change to one preference through the plugin hooks: asks * onBeforeSettingChange first (false keeps the old value), applies it, then * tells onSettingChange. With no interceptor registered the change applies * synchronously, so inputs bound to the store keep behaving as before. */ function changePreference(key: string, value: unknown, previous: unknown, apply: () => void): void { const commit = () => { apply(); void settingsHooks.onSettingChange.emit({ key, value, previous }); }; if (settingsHooks.onBeforeSettingChange.size === 0) { commit(); return; } void settingsHooks.onBeforeSettingChange.intercept({ key, value, previous }).then((allowed) => { if (allowed) commit(); }); } function toKeywordViews(keywords: KeywordDefinition[]): { id: string; name: string; color: string }[] { return keywords.map((k) => ({ id: k.id, name: k.label, color: k.color })); } export type FontSize = 'small' | 'medium' | 'large'; export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; /** * Which interface layout drives the shell's geometry. `default` is the app's * own layout; `mountain-view` is an optional alternative arrangement of the * chrome (search-first top bar, flat 256px navigation rail, pill Compose, * docked compose window) that keeps the theme's icons, type and palette. */ export type InterfaceLayout = 'default' | 'mountain-view'; // Mountain View's inbox: Gmail's "Default" or "Unread first" (lib/unread-first.ts). export type MvInboxType = 'default' | 'unread-first'; export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent'; export type ReplyMode = 'reply' | 'replyAll'; export type SignaturePosition = 'above_quote' | 'below_quote'; export type ReplyIdentityMatch = 'exact' | 'domain'; /** How to handle an incoming Disposition-Notification-To (read-receipt) request. */ export type ReadReceiptResponse = 'ask' | 'always' | 'never'; export type MentionAddsRecipientTo = 'to' | 'cc' | 'off'; export type DateFormat = 'smart' | 'relative' | 'full'; /** * Regional ordering of numeric dates, independent of the `DateFormat` style. * - `auto` — follow the UI language (today's behaviour). * - `iso` — ISO 8601, `YYYY-MM-DD`. * - `en-GB` — Day/Month/Year (`DD/MM/YYYY`). * - `en-US` — Month/Day/Year (`MM/DD/YYYY`). */ export type DateLocale = 'auto' | 'iso' | 'en-GB' | 'en-US'; export type TimeFormat = '12h' | '24h'; export type FirstDayOfWeek = 0 | 1 | 6; // 0 = Sunday, 1 = Monday, 6 = Saturday /** IANA zone id that overrides the browser's, or 'auto' to follow the browser (#755). */ export type TimeZoneSetting = 'auto' | (string & {}); export type ExternalContentPolicy = 'ask' | 'block' | 'allow'; export type MailAttachmentAction = 'preview' | 'download'; export type AttachmentPosition = 'beside-sender' | 'below-header'; export type ToolbarPosition = 'top' | 'below-subject'; export type ArchiveMode = 'single' | 'year' | 'month'; export type MailLayout = 'split' | 'focus' | 'horizontal'; /** * The mail layout the mail view actually uses. * * Mountain View shows the list at full width and opens a conversation in its * place, which is the `focus` layout. Rather than writing `focus` into the * user's saved `mailLayout`, the layout in force is derived: `focus` under * Mountain View, the saved value otherwise. Switching back to the default * layout therefore restores whatever the user had chosen. * * Read the layout through this (or `useEffectiveMailLayout`) wherever it * decides what is drawn; read `mailLayout` itself only where the saved * preference is being shown or edited. */ export function effectiveMailLayout(mailLayout: MailLayout, interfaceLayout: InterfaceLayout): MailLayout { return interfaceLayout === 'mountain-view' ? 'focus' : mailLayout; } /** * Spacing around a message body in the reader. * - 'auto' : add a gutter unless the email paints its own full-bleed background * - 'always': always add the gutter * - 'edge' : never add one (render edge-to-edge) */ export type MessageSpacing = 'auto' | 'always' | 'edge'; /** Font used to render text/plain email bodies. */ export type PlainTextFont = 'mono' | 'sans'; export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s'; export type SendDelaySeconds = 0 | 10 | 30 | 60; export type ProtocolOpenMode = 'active-session' | 'new-tab'; /** * Settings that must never round-trip through the cross-device sync API. * Decided per device and kept only in the local zustand-persist storage - * a value already stored on the server (from a prior build) is ignored on * import. */ const DEVICE_LOCAL_SETTING_KEYS = new Set(['proInterface']); /** * Settings that shape views spanning every logged-in account. They are synced * with the active account's settings like everything else, but once a device * holds several logins they are that device's choice: switching to another * account must not turn the merged mailbox off just because that account's * stored settings predate it. A first login on a device still takes them from * the server. */ const CROSS_ACCOUNT_VIEW_KEYS = new Set([ 'enableUnifiedMailbox', 'includeGroupInUnified', 'unifiedCrossAccount', 'enableCrossUnreadView', 'enableCrossStarredView', 'enableCrossAllView', ]); export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam'; /** Action fired by a mobile list-row swipe. 'none' disables that direction. */ export type SwipeAction = 'none' | 'archive' | 'delete' | 'markRead' | 'star' | 'spam'; export type HoverActionsMode = 'inline' | 'floating'; export type HoverActionsCorner = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; export const ALL_HOVER_ACTIONS: { id: HoverAction; labelKey: string }[] = [ { id: 'delete', labelKey: 'delete' }, { id: 'star', labelKey: 'star' }, { id: 'markRead', labelKey: 'mark_read' }, { id: 'archive', labelKey: 'archive' }, { id: 'tag', labelKey: 'tag' }, { id: 'spam', labelKey: 'spam' }, ]; export type DebugCategory = 'jmap' | 'calendar' | 'tasks' | 'auth' | 'filters' | 'email' | 'push' | 'contacts'; export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [ { id: 'jmap', labelKey: 'jmap' }, { id: 'calendar', labelKey: 'calendar' }, { id: 'tasks', labelKey: 'tasks' }, { id: 'auth', labelKey: 'auth' }, { id: 'filters', labelKey: 'filters' }, { id: 'email', labelKey: 'email' }, { id: 'push', labelKey: 'push' }, { id: 'contacts', labelKey: 'contacts' }, ]; /** Whether a tag shows in the sidebar always, only when it has unread mail, or never. */ export type KeywordVisibility = 'show' | 'hide' | 'unread'; export interface KeywordDefinition { id: string; // Used as JMAP keyword suffix: $label: label: string; // Display name color: string; // Key from KEYWORD_PALETTE visibility?: KeywordVisibility; // Absent on tags stored before this was configurable addedBy?: string; // Id of the plugin that added it with keywords.add; only that plugin may update or remove it } /** Resolves the sidebar visibility of a tag, defaulting to always shown. */ export function getKeywordVisibility(keyword: KeywordDefinition): KeywordVisibility { return keyword.visibility ?? 'show'; } export interface SidebarApp { id: string; name: string; url: string; icon: string; // Lucide icon name (e.g. 'Globe', 'Rss') openMode: 'tab' | 'inline'; // Open in new tab or embed inline showOnMobile: boolean; } export interface KeywordColor { /** Solid swatch: the dot form and the settings swatches. */ dot: string; /** The same solid colour as `dot`, for glyphs that take a text colour. */ icon: string; /** Lozenge background. */ fill: string; /** Lozenge border. */ border: string; /** Lozenge text. */ text: string; /** Full-row wash when `tintListRowsByTag` is on. */ rowTint: string; } /** * Tag colours, written out literally. * * Tailwind v4 scans this file, but only for classes that appear verbatim - * a composed `bg-${hue}-500` would compile to nothing. Every shade a tag can * take therefore has to be spelled out, which is why this map is long. * * Three shades per hue: the middle one keeps the bare hue name, so a tag * saved before the lighter and darker rows existed still resolves. */ export const KEYWORD_PALETTE: Record = { // light 'red-light': { dot: 'bg-red-300', icon: 'text-red-300', fill: 'bg-red-300/10', border: 'border-red-300/30', text: 'text-red-600 dark:text-red-200', rowTint: 'bg-red-50/60 dark:bg-red-950/20' }, 'orange-light': { dot: 'bg-orange-300', icon: 'text-orange-300', fill: 'bg-orange-300/10', border: 'border-orange-300/30', text: 'text-orange-600 dark:text-orange-200', rowTint: 'bg-orange-50/60 dark:bg-orange-950/20' }, 'amber-light': { dot: 'bg-amber-300', icon: 'text-amber-300', fill: 'bg-amber-300/10', border: 'border-amber-300/30', text: 'text-amber-600 dark:text-amber-200', rowTint: 'bg-amber-50/60 dark:bg-amber-950/20' }, 'yellow-light': { dot: 'bg-yellow-300', icon: 'text-yellow-300', fill: 'bg-yellow-300/10', border: 'border-yellow-300/30', text: 'text-yellow-600 dark:text-yellow-200', rowTint: 'bg-yellow-50/60 dark:bg-yellow-950/20' }, 'lime-light': { dot: 'bg-lime-300', icon: 'text-lime-300', fill: 'bg-lime-300/10', border: 'border-lime-300/30', text: 'text-lime-600 dark:text-lime-200', rowTint: 'bg-lime-50/60 dark:bg-lime-950/20' }, 'green-light': { dot: 'bg-green-300', icon: 'text-green-300', fill: 'bg-green-300/10', border: 'border-green-300/30', text: 'text-green-600 dark:text-green-200', rowTint: 'bg-green-50/60 dark:bg-green-950/20' }, 'teal-light': { dot: 'bg-teal-300', icon: 'text-teal-300', fill: 'bg-teal-300/10', border: 'border-teal-300/30', text: 'text-teal-600 dark:text-teal-200', rowTint: 'bg-teal-50/60 dark:bg-teal-950/20' }, 'cyan-light': { dot: 'bg-cyan-300', icon: 'text-cyan-300', fill: 'bg-cyan-300/10', border: 'border-cyan-300/30', text: 'text-cyan-600 dark:text-cyan-200', rowTint: 'bg-cyan-50/60 dark:bg-cyan-950/20' }, 'blue-light': { dot: 'bg-blue-300', icon: 'text-blue-300', fill: 'bg-blue-300/10', border: 'border-blue-300/30', text: 'text-blue-600 dark:text-blue-200', rowTint: 'bg-blue-50/60 dark:bg-blue-950/20' }, 'indigo-light': { dot: 'bg-indigo-300', icon: 'text-indigo-300', fill: 'bg-indigo-300/10', border: 'border-indigo-300/30', text: 'text-indigo-600 dark:text-indigo-200', rowTint: 'bg-indigo-50/60 dark:bg-indigo-950/20' }, 'purple-light': { dot: 'bg-purple-300', icon: 'text-purple-300', fill: 'bg-purple-300/10', border: 'border-purple-300/30', text: 'text-purple-600 dark:text-purple-200', rowTint: 'bg-purple-50/60 dark:bg-purple-950/20' }, 'pink-light': { dot: 'bg-pink-300', icon: 'text-pink-300', fill: 'bg-pink-300/10', border: 'border-pink-300/30', text: 'text-pink-600 dark:text-pink-200', rowTint: 'bg-pink-50/60 dark:bg-pink-950/20' }, 'gray-light': { dot: 'bg-gray-300', icon: 'text-gray-300', fill: 'bg-gray-300/10', border: 'border-gray-300/30', text: 'text-gray-600 dark:text-gray-200', rowTint: 'bg-gray-50/60 dark:bg-gray-950/20' }, // base red: { dot: 'bg-red-500', icon: 'text-red-500', fill: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-700 dark:text-red-300', rowTint: 'bg-red-50 dark:bg-red-950/30' }, orange: { dot: 'bg-orange-500', icon: 'text-orange-500', fill: 'bg-orange-500/10', border: 'border-orange-500/30', text: 'text-orange-700 dark:text-orange-300', rowTint: 'bg-orange-50 dark:bg-orange-950/30' }, amber: { dot: 'bg-amber-500', icon: 'text-amber-500', fill: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-700 dark:text-amber-300', rowTint: 'bg-amber-50 dark:bg-amber-950/30' }, yellow: { dot: 'bg-yellow-500', icon: 'text-yellow-500', fill: 'bg-yellow-500/10', border: 'border-yellow-500/30', text: 'text-yellow-700 dark:text-yellow-300', rowTint: 'bg-yellow-50 dark:bg-yellow-950/30' }, lime: { dot: 'bg-lime-500', icon: 'text-lime-500', fill: 'bg-lime-500/10', border: 'border-lime-500/30', text: 'text-lime-700 dark:text-lime-300', rowTint: 'bg-lime-50 dark:bg-lime-950/30' }, green: { dot: 'bg-green-500', icon: 'text-green-500', fill: 'bg-green-500/10', border: 'border-green-500/30', text: 'text-green-700 dark:text-green-300', rowTint: 'bg-green-50 dark:bg-green-950/30' }, teal: { dot: 'bg-teal-500', icon: 'text-teal-500', fill: 'bg-teal-500/10', border: 'border-teal-500/30', text: 'text-teal-700 dark:text-teal-300', rowTint: 'bg-teal-50 dark:bg-teal-950/30' }, cyan: { dot: 'bg-cyan-500', icon: 'text-cyan-500', fill: 'bg-cyan-500/10', border: 'border-cyan-500/30', text: 'text-cyan-700 dark:text-cyan-300', rowTint: 'bg-cyan-50 dark:bg-cyan-950/30' }, blue: { dot: 'bg-blue-500', icon: 'text-blue-500', fill: 'bg-blue-500/10', border: 'border-blue-500/30', text: 'text-blue-700 dark:text-blue-300', rowTint: 'bg-blue-50 dark:bg-blue-950/30' }, indigo: { dot: 'bg-indigo-500', icon: 'text-indigo-500', fill: 'bg-indigo-500/10', border: 'border-indigo-500/30', text: 'text-indigo-700 dark:text-indigo-300', rowTint: 'bg-indigo-50 dark:bg-indigo-950/30' }, purple: { dot: 'bg-purple-500', icon: 'text-purple-500', fill: 'bg-purple-500/10', border: 'border-purple-500/30', text: 'text-purple-700 dark:text-purple-300', rowTint: 'bg-purple-50 dark:bg-purple-950/30' }, pink: { dot: 'bg-pink-500', icon: 'text-pink-500', fill: 'bg-pink-500/10', border: 'border-pink-500/30', text: 'text-pink-700 dark:text-pink-300', rowTint: 'bg-pink-50 dark:bg-pink-950/30' }, gray: { dot: 'bg-gray-500', icon: 'text-gray-500', fill: 'bg-gray-500/10', border: 'border-gray-500/30', text: 'text-gray-700 dark:text-gray-300', rowTint: 'bg-gray-50 dark:bg-gray-950/30' }, // dark 'red-dark': { dot: 'bg-red-700', icon: 'text-red-700', fill: 'bg-red-700/10', border: 'border-red-700/30', text: 'text-red-800 dark:text-red-400', rowTint: 'bg-red-100 dark:bg-red-950/50' }, 'orange-dark': { dot: 'bg-orange-700', icon: 'text-orange-700', fill: 'bg-orange-700/10', border: 'border-orange-700/30', text: 'text-orange-800 dark:text-orange-400', rowTint: 'bg-orange-100 dark:bg-orange-950/50' }, 'amber-dark': { dot: 'bg-amber-700', icon: 'text-amber-700', fill: 'bg-amber-700/10', border: 'border-amber-700/30', text: 'text-amber-800 dark:text-amber-400', rowTint: 'bg-amber-100 dark:bg-amber-950/50' }, 'yellow-dark': { dot: 'bg-yellow-700', icon: 'text-yellow-700', fill: 'bg-yellow-700/10', border: 'border-yellow-700/30', text: 'text-yellow-800 dark:text-yellow-400', rowTint: 'bg-yellow-100 dark:bg-yellow-950/50' }, 'lime-dark': { dot: 'bg-lime-700', icon: 'text-lime-700', fill: 'bg-lime-700/10', border: 'border-lime-700/30', text: 'text-lime-800 dark:text-lime-400', rowTint: 'bg-lime-100 dark:bg-lime-950/50' }, 'green-dark': { dot: 'bg-green-700', icon: 'text-green-700', fill: 'bg-green-700/10', border: 'border-green-700/30', text: 'text-green-800 dark:text-green-400', rowTint: 'bg-green-100 dark:bg-green-950/50' }, 'teal-dark': { dot: 'bg-teal-700', icon: 'text-teal-700', fill: 'bg-teal-700/10', border: 'border-teal-700/30', text: 'text-teal-800 dark:text-teal-400', rowTint: 'bg-teal-100 dark:bg-teal-950/50' }, 'cyan-dark': { dot: 'bg-cyan-700', icon: 'text-cyan-700', fill: 'bg-cyan-700/10', border: 'border-cyan-700/30', text: 'text-cyan-800 dark:text-cyan-400', rowTint: 'bg-cyan-100 dark:bg-cyan-950/50' }, 'blue-dark': { dot: 'bg-blue-700', icon: 'text-blue-700', fill: 'bg-blue-700/10', border: 'border-blue-700/30', text: 'text-blue-800 dark:text-blue-400', rowTint: 'bg-blue-100 dark:bg-blue-950/50' }, 'indigo-dark': { dot: 'bg-indigo-700', icon: 'text-indigo-700', fill: 'bg-indigo-700/10', border: 'border-indigo-700/30', text: 'text-indigo-800 dark:text-indigo-400', rowTint: 'bg-indigo-100 dark:bg-indigo-950/50' }, 'purple-dark': { dot: 'bg-purple-700', icon: 'text-purple-700', fill: 'bg-purple-700/10', border: 'border-purple-700/30', text: 'text-purple-800 dark:text-purple-400', rowTint: 'bg-purple-100 dark:bg-purple-950/50' }, 'pink-dark': { dot: 'bg-pink-700', icon: 'text-pink-700', fill: 'bg-pink-700/10', border: 'border-pink-700/30', text: 'text-pink-800 dark:text-pink-400', rowTint: 'bg-pink-100 dark:bg-pink-950/50' }, 'gray-dark': { dot: 'bg-gray-700', icon: 'text-gray-700', fill: 'bg-gray-700/10', border: 'border-gray-700/30', text: 'text-gray-800 dark:text-gray-400', rowTint: 'bg-gray-100 dark:bg-gray-950/50' }, } as const; /** Palette laid out as the settings picker shows it: lighter, base, darker. */ export const KEYWORD_PALETTE_ROWS: string[][] = [ ['red-light', 'orange-light', 'amber-light', 'yellow-light', 'lime-light', 'green-light', 'teal-light', 'cyan-light', 'blue-light', 'indigo-light', 'purple-light', 'pink-light', 'gray-light'], ['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray'], ['red-dark', 'orange-dark', 'amber-dark', 'yellow-dark', 'lime-dark', 'green-dark', 'teal-dark', 'cyan-dark', 'blue-dark', 'indigo-dark', 'purple-dark', 'pink-dark', 'gray-dark'], ]; /** The colour a tag falls back to when its definition is gone. */ export const FALLBACK_KEYWORD_COLOR = 'gray'; export const DEFAULT_KEYWORDS: KeywordDefinition[] = [ { id: 'red', label: 'Red', color: 'red' }, { id: 'orange', label: 'Orange', color: 'orange' }, { id: 'yellow', label: 'Yellow', color: 'yellow' }, { id: 'green', label: 'Green', color: 'green' }, { id: 'blue', label: 'Blue', color: 'blue' }, { id: 'purple', label: 'Purple', color: 'purple' }, { id: 'pink', label: 'Pink', color: 'pink' }, ]; export const DEV_KEYWORDS: KeywordDefinition[] = [ { id: 'work', label: 'Work', color: 'blue' }, { id: 'work/clients', label: 'Clients', color: 'teal' }, { id: 'work/clients/acme', label: 'Acme', color: 'green' }, { id: 'personal', label: 'Personal', color: 'purple' }, { id: 'personal/finance', label: 'Finance', color: 'amber' }, { id: 'receipts', label: 'Receipts', color: 'gray' }, ]; const USING_MOCK_SERVER = process.env.NEXT_PUBLIC_DEV_MOCK_JMAP === 'true'; interface SettingsState { // Appearance fontSize: FontSize; density: Density; interfaceLayout: InterfaceLayout; mvInboxType: MvInboxType; // The folded sections of the "Unread first" inbox; per device. mvUnreadSectionCollapsed: boolean; mvEverythingElseCollapsed: boolean; animationsEnabled: boolean; // Message-list ordering (#718): prioritised sort levels mapped onto the JMAP // Email/query sort; empty = chronological. Scope: Inbox only or every folder. messageListOrder: SortLevel[]; messageListOrderScope: MessageListOrderScope; // Language & Region dateFormat: DateFormat; dateLocale: DateLocale; timeFormat: TimeFormat; firstDayOfWeek: FirstDayOfWeek; timeZone: TimeZoneSetting; // Email Behavior markAsReadDelay: number; // milliseconds (0 = instant, -1 = never) deleteAction: DeleteAction; permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash confirmBulkDelete: boolean; // Ask before moving several selected emails to Trash (permanent deletes always ask) returnToListAfterAction: boolean; // After delete / mark-unread in an open message, return to the list instead of opening the next message clearSearchOnFolderChange: boolean; // Reset the search query + advanced filters when switching folders, instead of re-running the search in the newly selected folder (#553 keeps it applied when this is off) showPreview: boolean; showVerificationCodes: boolean; // Offer the one-time code of a sign-in mail as a copy chip in the list and the reader mailLayout: MailLayout; listColumns: boolean; // One-line list rows as a table: From, Subject, Date and Size columns under a header (#262) emailsPerPage: number; externalContentPolicy: ExternalContentPolicy; messageSpacing: MessageSpacing; // Gutter around the message body in the reader plainTextFont: PlainTextFont; // Font for text/plain email bodies in the reader mailAttachmentAction: MailAttachmentAction; attachmentPosition: AttachmentPosition; emailAlwaysLightMode: boolean; // Always render email content in light mode archiveMode: ArchiveMode; // How to organize archived emails: single folder, by year, or by year+month hoverActions: HoverAction[]; // Quick actions shown on hover in mail list hoverActionsMode: HoverActionsMode; // Display mode: inline (current) or floating corner hoverActionsCorner: HoverActionsCorner; // Corner for floating mode swipeRightAction: SwipeAction; // Mobile: action when a list row is swiped right swipeLeftAction: SwipeAction; // Mobile: action when a list row is swiped left // Composer autoSaveDraftInterval: number; // milliseconds sendConfirmation: boolean; quickReplyEnabled: boolean; defaultReplyMode: ReplyMode; autoSelectReplyIdentity: boolean; replyIdentityMatch: ReplyIdentityMatch; // With autoSelectReplyIdentity on: 'exact' = configured identities only, 'domain' = also same-domain catch-all addresses (rewrites From) #1000 plainTextMode: boolean; // Send plain text only (no rich text editor) rtlEditingSupport: boolean; // Show a per-paragraph LTR/RTL direction control in the composer (Gmail-style) subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@") sendDelaySeconds: SendDelaySeconds; signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter requestReadReceiptDefault: boolean; // Pre-check "request read receipt" in the composer readReceiptResponse: ReadReceiptResponse; // How to respond to incoming read-receipt requests // Privacy & Security sessionTimeout: number; // minutes (0 = never) trustedSenders: string[]; // Email addresses that can load external content // Store trusted senders in a dedicated JMAP address book so they sync across // devices. `null` means "not yet decided": resolved on first connect to // `true` when the server supports contacts, otherwise left off. Once the user // toggles it, it holds a concrete boolean and is never auto-changed again. trustedSendersAddressBook: boolean | null; // Filters expandedFilterView: boolean; // Calendar showTimeInMonthView: boolean; showWeekNumbers: boolean; /** Scroll continuously through months/weeks/days (#759) instead of one period at a time. */ calendarFreeScroll: boolean; calendarHoverPreview: CalendarHoverPreview; /** Draw only calendarDayStartHour..calendarDayEndHour in the day and week views (#1164). */ calendarLimitHours: boolean; calendarDayStartHour: number; calendarDayEndHour: number; /** Leave the days missing from calendarWorkingDays out of the week view (#1164). */ calendarHideNonWorkingDays: boolean; /** Weekdays as `Date.getDay` numbers (0 = Sunday). */ calendarWorkingDays: number[]; // Calendar Tasks enableCalendarTasks: boolean; showTasksOnCalendar: boolean; // Contact Birthday Calendar showBirthdayCalendar: boolean; birthdayCalendarColor: string; // Per-viewer color overrides for shared calendars, keyed by // sharedCalendarColorKey(). Lets each recipient recolor calendars shared // with them without changing the owner's color (see #345). sharedCalendarColors: Record; // Contacts Display groupContactsByLetter: boolean; // Sort (and group) the contact list by surname instead of given name so // family members sit together (#963). sortContactsByLastName: boolean; // Email Notifications emailNotificationsEnabled: boolean; emailNotificationSound: boolean; notificationSoundChoice: NotificationSoundChoice; /** Web Push only fires for mail that lands in the Inbox; Sieve-filed mail stays silent. */ pushNotifyInboxOnly: boolean; /** Accounts and folders that never notify (#1169); see lib/notification-mutes. */ notificationMutes: NotificationMutes; /** Chosen Web Push relay URL. Empty = the admin-configured default. */ pushRelayUrl: string; // Protocol Handlers protocolOpenMode: ProtocolOpenMode; // Calendar Notifications calendarNotificationsEnabled: boolean; calendarNotificationSound: boolean; calendarInvitationParsingEnabled: boolean; // Layout toolbarPosition: ToolbarPosition; showToolbarLabels: boolean; hideAccountSwitcher: boolean; showRailAccountList: boolean; proInterface: boolean; // Settings marked `advanced` (power-user and integrator rows) stay hidden // until this is on. Synced like the rest: it is a preference, not a device. showAdvancedSettings: boolean; // Unified Mailbox enableUnifiedMailbox: boolean; // Include shared/delegated folders in the unified mailbox. Default true: the // account-bounded unified view is defined by spanning the account's own folders // plus every shared folder it can access. includeGroupInUnified: boolean; // When true, the unified mailbox merges across every logged-in account // (cross-account). When false (default for new installs) it stays within the // active account boundary (own + shared folders). Gated by the admin // `unifiedCrossAccountEnabled` feature. unifiedCrossAccount: boolean; // Unified Mailbox entries, each gated per-view by the admin policy. These show // the All mail / Unread / Starred lists, scoped by `unifiedCrossAccount` and // narrowed by the `allMailFolderIds` folder selection. enableCrossUnreadView: boolean; enableCrossStarredView: boolean; enableCrossAllView: boolean; // Per-account folder selection narrowing the unified All mail / Unread / // Starred lists, keyed by AccountEntry.id. A missing entry = "not configured" // -> defaults to inbox + custom folders; an explicit [] = "no own folders". allMailFolderIds: Record; // Per-account default sender identity, keyed by AccountEntry.id -> JMAP // Identity id. Synced (and exported) so the chosen default survives clearing // site data and follows the user across browsers/devices (issue #507). Kept // per account because JMAP identity ids are account-scoped and would collide. preferredIdentityIds: Record; // Email Display disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation senderFavicons: boolean; showAvatarsInJunk: boolean; // Show profile images/favicons in the junk folder faviconUnreadBadge: boolean; // Badge the browser-tab icon with the inbox unread count // Sidebar colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.) tintListRowsByTag: boolean; // Tint mail-list rows by the first tag color tintListRowsByAccount: boolean; // In the unified view, tint rows by account colour instead of showing the account dot showAccountDot: boolean; // In the unified view, show the account colour dot next to the sender showFolderTotalCount: boolean; // Show total message count next to folders/tags (alongside unread) // Mail folders of shared (group/delegated) JMAP accounts in the sidebar. // Turning it off hides every shared account's folders; their calendars, // contacts and files stay. (#1228) showSharedMailAccounts: boolean; // Shared accounts whose mail folders are hidden one by one, keyed by // AccountEntry.id -> JMAP accountIds, since those ids are per server. hiddenSharedMailAccounts: Record; // Folders folderIcons: Record; // mailboxId -> icon name folderCountModes: Record; // mailboxId -> counter override (#982) // Keywords (labels/tags) emailKeywords: KeywordDefinition[]; nestedTags: boolean; // Treat "/" in a tag id as a parent/child separator // Attachment Reminder attachmentReminderEnabled: boolean; attachmentReminderKeywords: string[]; // Ask for confirmation when sending a message with an empty subject emptySubjectWarningEnabled: boolean; // "@" in the message body offers the recipients and inserts a first name recipientMentionsEnabled: boolean; // Where an "@" puts a picked contact who isn't a recipient yet; 'off' // offers the recipients only mentionAddsRecipientTo: MentionAddsRecipientTo; // Hide inline images (images referenced by cid in the HTML body) from the // attachment list shown above the message body. hideInlineImageAttachments: boolean; // Render image attachments as thumbnail cards (preview the actual image // contents inside the chip) instead of generic file icons. attachmentImagePreviewsEnabled: boolean; // Sidebar Apps sidebarApps: SidebarApp[]; keepAppsLoaded: boolean; // Onboarding onboardingCompleted: boolean; // Welcome banner dismissed tourCompleted: boolean; // Interactive tour completed showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device // Downloads emailDownloadTemplate: string; attachmentDownloadTemplate: string; bundleDownloadTemplate: string; filenameSpaceReplacement: 'keep' | 'underscore' | 'dash'; filenameLowercase: boolean; filenameStripDiacritics: boolean; filenameCollapseSeparators: boolean; postExportAction: 'keep' | 'archive' | 'trash'; // Advanced debugMode: boolean; debugCategories: Record; settingsSyncDisabled: boolean; /** * Keys the user changed themselves (#1248). An admin policy default * (`policy.defaults`) only fills in settings that are not listed here, so a * choice the user made survives the operator setting or changing a default. * Synced with the rest of the settings. */ explicitSettings: string[]; // Actions /** * Changes one preference. Plugins may stop the change (onBeforeSettingChange) * and are told about it afterwards (onSettingChange). Pass `internal: true` * for writes the app makes on its own; those skip the plugin hooks. */ updateSetting: ( key: K, value: SettingsState[K], opts?: { internal?: boolean } ) => void; resetToDefaults: () => void; /** * Applies the admin policy (#1248): `policy.defaults` to every setting the * user has not chosen, and a locked restriction's value to its setting * whatever the user chose. Runs when the policy loads and after every * import, reset and server load. */ applyAdminPolicy: () => void; /** `forSync` builds the snapshot pushed to the server; it is not a user export. */ exportSettings: (opts?: { forSync?: boolean; subscriptionOwner?: string }) => string; importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean; subscriptionOwner?: string }) => boolean; // Folder icons setFolderIcon: (mailboxId: string, icon: string) => void; removeFolderIcon: (mailboxId: string) => void; // Shared-calendar color overrides setSharedCalendarColor: (key: string, color: string) => void; removeSharedCalendarColor: (key: string) => void; // Trusted senders addTrustedSender: (email: string) => void; removeTrustedSender: (email: string) => void; isSenderTrusted: (email: string) => boolean; // Keywords addKeyword: (keyword: KeywordDefinition) => void; updateKeyword: (id: string, updates: Partial>) => void; renameKeyword: (oldId: string, newKeyword: KeywordDefinition) => void; removeKeyword: (id: string) => void; reorderKeywords: (keywords: KeywordDefinition[]) => void; getKeywordById: (id: string) => KeywordDefinition | undefined; // Sidebar Apps addSidebarApp: (app: SidebarApp) => void; updateSidebarApp: (id: string, updates: Partial>) => void; removeSidebarApp: (id: string) => void; reorderSidebarApps: (apps: SidebarApp[]) => void; // Settings sync enableSync: (username: string, serverUrl: string) => void; flushSync: () => Promise; disableSync: () => void; loadFromServer: (username: string, serverUrl: string) => Promise; /** * Called on a full sign-out: drops the settings and templates this browser * mirrored from an account's server copy, so the next person to sign in * does not start from them (#1185). Device-only settings are kept. */ forgetSyncedSettings: () => void; } const DEFAULT_SETTINGS = { // Appearance fontSize: 'medium' as FontSize, density: 'regular' as Density, interfaceLayout: 'default' as InterfaceLayout, mvInboxType: 'unread-first' as MvInboxType, mvUnreadSectionCollapsed: false, mvEverythingElseCollapsed: false, animationsEnabled: true, messageListOrder: [] as SortLevel[], messageListOrderScope: 'inbox' as MessageListOrderScope, // Language & Region dateFormat: 'smart' as DateFormat, dateLocale: 'auto' as DateLocale, timeFormat: '24h' as TimeFormat, firstDayOfWeek: 1 as FirstDayOfWeek, // Monday timeZone: 'auto' as TimeZoneSetting, // Email Behavior markAsReadDelay: 0, // Instant deleteAction: 'trash' as DeleteAction, permanentlyDeleteJunk: false, confirmBulkDelete: true, returnToListAfterAction: true, clearSearchOnFolderChange: false, showPreview: true, showVerificationCodes: true, mailLayout: 'split' as MailLayout, listColumns: false, emailsPerPage: 50, externalContentPolicy: 'ask' as ExternalContentPolicy, messageSpacing: 'auto' as MessageSpacing, plainTextFont: 'sans' as PlainTextFont, mailAttachmentAction: 'preview' as MailAttachmentAction, attachmentPosition: 'beside-sender' as AttachmentPosition, emailAlwaysLightMode: false, archiveMode: 'single' as ArchiveMode, hoverActions: ['delete', 'star', 'markRead', 'archive'] as HoverAction[], hoverActionsMode: 'inline' as HoverActionsMode, hoverActionsCorner: 'top-right' as HoverActionsCorner, swipeRightAction: 'archive' as SwipeAction, swipeLeftAction: 'delete' as SwipeAction, // Composer autoSaveDraftInterval: 60000, // 1 minute sendConfirmation: false, quickReplyEnabled: true, defaultReplyMode: 'reply' as ReplyMode, autoSelectReplyIdentity: false, replyIdentityMatch: 'domain' as ReplyIdentityMatch, plainTextMode: false, rtlEditingSupport: false, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, sendDelaySeconds: 0 as SendDelaySeconds, signaturePosition: 'below_quote' as SignaturePosition, signatureSeparatorEnabled: true, requestReadReceiptDefault: false, readReceiptResponse: 'ask' as ReadReceiptResponse, // Privacy & Security sessionTimeout: 0, // Never trustedSenders: [] as string[], trustedSendersAddressBook: null as boolean | null, // Filters expandedFilterView: false, // Calendar showTimeInMonthView: false, showWeekNumbers: false, calendarFreeScroll: true, calendarHoverPreview: 'delay-500ms' as CalendarHoverPreview, calendarLimitHours: true, calendarDayStartHour: 8, calendarDayEndHour: 20, calendarHideNonWorkingDays: false, calendarWorkingDays: [1, 2, 3, 4, 5] as number[], // Calendar Tasks enableCalendarTasks: false, showTasksOnCalendar: true, // Contact Birthday Calendar showBirthdayCalendar: false, birthdayCalendarColor: '#eab308', sharedCalendarColors: {} as Record, // Contacts Display groupContactsByLetter: true, sortContactsByLastName: false, // Email Notifications emailNotificationsEnabled: true, emailNotificationSound: true, notificationSoundChoice: 'default' as NotificationSoundChoice, pushNotifyInboxOnly: false, notificationMutes: EMPTY_NOTIFICATION_MUTES, pushRelayUrl: '', // Protocol Handlers protocolOpenMode: 'new-tab' as ProtocolOpenMode, // Calendar Notifications calendarNotificationsEnabled: true, calendarNotificationSound: true, calendarInvitationParsingEnabled: true, // Layout toolbarPosition: 'top' as ToolbarPosition, showToolbarLabels: true, hideAccountSwitcher: false, showRailAccountList: false, proInterface: false, showAdvancedSettings: false, // Unified Mailbox enableUnifiedMailbox: false, includeGroupInUnified: true, unifiedCrossAccount: false, allMailFolderIds: {} as Record, preferredIdentityIds: {} as Record, enableCrossUnreadView: false, enableCrossStarredView: false, enableCrossAllView: false, // Email Display disableThreading: false, senderFavicons: true, showAvatarsInJunk: false, faviconUnreadBadge: true, // Sidebar colorfulSidebarIcons: true, tintListRowsByTag: true, tintListRowsByAccount: false, showAccountDot: true, showFolderTotalCount: true, showSharedMailAccounts: true, hiddenSharedMailAccounts: {} as Record, // Folders folderIcons: {} as Record, folderCountModes: {} as Record, // Keywords emailKeywords: USING_MOCK_SERVER ? DEV_KEYWORDS : DEFAULT_KEYWORDS, nestedTags: USING_MOCK_SERVER, // Attachment Reminder attachmentReminderEnabled: true, attachmentReminderKeywords: [ // English 'attached', 'attachment', 'attachments', 'see attached', 'find attached', 'please find attached', // German 'angehängt', 'anhang', 'anbei', 'im anhang', // French 'ci-joint', 'pièce jointe', // Spanish 'adjunto', 'adjunta', 'en adjunto', // Italian 'allegato', 'in allegato', // Dutch 'bijgevoegd', 'bijlage', // Portuguese 'em anexo', 'anexo', // Polish 'w załączniku', // Russian 'во вложении', // Japanese '添付', // Chinese '附件', // Korean '첨부', // Latvian 'pielikumā', ] as string[], emptySubjectWarningEnabled: true, recipientMentionsEnabled: true, mentionAddsRecipientTo: 'to' as MentionAddsRecipientTo, hideInlineImageAttachments: true, attachmentImagePreviewsEnabled: true, // Sidebar Apps sidebarApps: [] as SidebarApp[], keepAppsLoaded: false, // Onboarding onboardingCompleted: false, tourCompleted: false, showOnboardingOnNewDevices: false, // Downloads emailDownloadTemplate: '{date} ({from}-{to}) {subject}', attachmentDownloadTemplate: '{filename}', bundleDownloadTemplate: 'emails-{count}', filenameSpaceReplacement: 'keep' as 'keep' | 'underscore' | 'dash', filenameLowercase: false, filenameStripDiacritics: false, filenameCollapseSeparators: true, postExportAction: 'keep' as 'keep' | 'archive' | 'trash', // Advanced debugMode: false, debugCategories: { jmap: true, calendar: true, tasks: true, auth: true, filters: true, email: true, push: true, } as Record, settingsSyncDisabled: false, explicitSettings: [] as string[], }; const hasOwn = (obj: object, key: string) => Object.prototype.hasOwnProperty.call(obj, key); const builtInDefault = (key: string) => (DEFAULT_SETTINGS as Record)[key]; /** * Settings an admin policy may set (#1248): plain values (boolean, number, * string). Lists and maps (tags, folder icons, per-account maps) stay the * user's own. */ function isPolicySettingKey(key: string): boolean { if (key === 'explicitSettings' || !hasOwn(DEFAULT_SETTINGS, key)) return false; const type = typeof builtInDefault(key); return type === 'boolean' || type === 'number' || type === 'string'; } /** * policy.json is written by hand, so a value only applies when it has the * build default's type, passes the checks an import makes, and stays within * the restriction's allowed values and bounds. */ function isAllowedPolicyValue(key: string, value: unknown, restriction?: SettingRestriction): boolean { if (!isPolicySettingKey(key) || typeof value !== typeof builtInDefault(key)) return false; if (typeof value === 'number' && !Number.isFinite(value)) return false; if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(value as number)) return false; if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(value as string)) return false; if (key === 'messageListOrderScope' && value !== 'inbox' && value !== 'all') return false; if (restriction && typeof restriction === 'object') { if (Array.isArray(restriction.allowedValues) && !restriction.allowedValues.includes(value)) return false; if (typeof value === 'number') { if (typeof restriction.min === 'number' && value < restriction.min) return false; if (typeof restriction.max === 'number' && value > restriction.max) return false; } } return true; } /** * Local state and server blobs from before #1248 carry no explicitSettings. * There, a value that differs from the build default can only have come from * the user, so it counts as their choice. One that matches it is taken as * never chosen and picks up the operator's default. */ export function legacyExplicitSettings(source: Record): string[] { return Object.keys(source).filter( (key) => isPolicySettingKey(key) && source[key] !== builtInDefault(key), ); } /** * What the admin policy changes in `state` (#1248): each `policy.defaults` * value for a setting the user has not chosen, then each locked * restriction's value (`value`, or the default when it has none) whatever * the user chose. Exported for tests. */ export function adminPolicyPatch( state: Record, policy: Partial>, ): Record { const explicit = new Set(Array.isArray(state.explicitSettings) ? (state.explicitSettings as string[]) : []); const defaults = isPlainRecord(policy.defaults) ? policy.defaults : {}; const restrictions: Record = isPlainRecord(policy.restrictions) ? policy.restrictions : {}; const restrictionFor = (key: string) => (hasOwn(restrictions, key) ? restrictions[key] : undefined); const patch: Record = {}; for (const [key, value] of Object.entries(defaults)) { if (explicit.has(key) || !isAllowedPolicyValue(key, value, restrictionFor(key))) continue; if (state[key] !== value) patch[key] = value; } for (const [key, restriction] of Object.entries(restrictions)) { if (!isPlainRecord(restriction) || restriction.locked !== true) continue; const value = hasOwn(restriction, 'value') ? restriction.value : hasOwn(defaults, key) ? defaults[key] : undefined; if (!isAllowedPolicyValue(key, value, restriction)) continue; if (state[key] !== value) patch[key] = value; else delete patch[key]; } return patch; } export const useSettingsStore = create()( persist( (set, get) => { /** * Back to defaults for everything an account's server copy holds: * settings (device-only ones kept) and templates. Never pushed, since * sync may still point at the account the state came from. */ const resetSyncedState = (opts?: { keepCrossAccountViews?: boolean }) => { const defaults: Record = { ...DEFAULT_SETTINGS }; for (const key of DEVICE_LOCAL_SETTING_KEYS) delete defaults[key]; if (opts?.keepCrossAccountViews) for (const key of CROSS_ACCOUNT_VIEW_KEYS) delete defaults[key]; // Settings left as they are keep their "chosen by the user" mark. defaults.explicitSettings = get().explicitSettings.filter((key) => !hasOwn(defaults, key)); const wasLoading = isLoadingFromServer; isLoadingFromServer = true; try { set(defaults as Partial); templateSyncBridge?.applySyncedState([], {}, { merge: false }); // A new account starts from this instance's defaults (#1248). applyAdminPolicyPatch(); } finally { isLoadingFromServer = wasLoading; } applyVisualSettings(); }; const applyVisualSettings = () => { const { fontSize, density, interfaceLayout, animationsEnabled } = get(); applyFontSize(fontSize); applyDensity(density, interfaceLayout); applyInterfaceLayout(interfaceLayout, density); applyAnimations(animationsEnabled); }; /** Sets what the admin policy changes; true when anything changed. */ const applyAdminPolicyPatch = (): boolean => { const patch = adminPolicyPatch( get() as unknown as Record, usePolicyStore.getState().policy, ); if (Object.keys(patch).length === 0) return false; set(patch as Partial); return true; }; const applySetting = (key: K, value: SettingsState[K]) => { set({ [key]: value } as Partial); // Apply font size to document root if (key === 'fontSize') { applyFontSize(value as FontSize); } // Apply density to document root if (key === 'density') { applyDensity(value as Density, get().interfaceLayout); } // Swap the shell geometry. Mountain View's list-then-conversation // layout is not written into `mailLayout`: see effectiveMailLayout(). if (key === 'interfaceLayout') { applyInterfaceLayout(value as InterfaceLayout, get().density); } // Apply animations to document root if (key === 'animationsEnabled') { applyAnimations(value as boolean); } }; const buildExport = (opts?: { subscriptionOwner?: string }): string => { const state = get(); const templateSync = templateSyncBridge?.getSyncedState(); const subscriptionSync = calendarSubscriptionSyncBridge?.getSyncedState(); const settings = { fontSize: state.fontSize, density: state.density, interfaceLayout: state.interfaceLayout, mvInboxType: state.mvInboxType, animationsEnabled: state.animationsEnabled, messageListOrder: state.messageListOrder, messageListOrderScope: state.messageListOrderScope, dateFormat: state.dateFormat, dateLocale: state.dateLocale, timeFormat: state.timeFormat, firstDayOfWeek: state.firstDayOfWeek, timeZone: state.timeZone, markAsReadDelay: state.markAsReadDelay, deleteAction: state.deleteAction, returnToListAfterAction: state.returnToListAfterAction, confirmBulkDelete: state.confirmBulkDelete, clearSearchOnFolderChange: state.clearSearchOnFolderChange, showPreview: state.showPreview, showVerificationCodes: state.showVerificationCodes, mailLayout: state.mailLayout, listColumns: state.listColumns, emailsPerPage: state.emailsPerPage, externalContentPolicy: state.externalContentPolicy, messageSpacing: state.messageSpacing, plainTextFont: state.plainTextFont, mailAttachmentAction: state.mailAttachmentAction, attachmentPosition: state.attachmentPosition, archiveMode: state.archiveMode, hoverActions: state.hoverActions, hoverActionsMode: state.hoverActionsMode, hoverActionsCorner: state.hoverActionsCorner, swipeRightAction: state.swipeRightAction, swipeLeftAction: state.swipeLeftAction, disableThreading: state.disableThreading, trustedSenders: state.trustedSenders, autoSaveDraftInterval: state.autoSaveDraftInterval, sendConfirmation: state.sendConfirmation, quickReplyEnabled: state.quickReplyEnabled, defaultReplyMode: state.defaultReplyMode, autoSelectReplyIdentity: state.autoSelectReplyIdentity, replyIdentityMatch: state.replyIdentityMatch, plainTextMode: state.plainTextMode, rtlEditingSupport: state.rtlEditingSupport, subAddressDelimiter: state.subAddressDelimiter, sendDelaySeconds: state.sendDelaySeconds, signaturePosition: state.signaturePosition, signatureSeparatorEnabled: state.signatureSeparatorEnabled, requestReadReceiptDefault: state.requestReadReceiptDefault, readReceiptResponse: state.readReceiptResponse, sessionTimeout: state.sessionTimeout, emailNotificationsEnabled: state.emailNotificationsEnabled, emailNotificationSound: state.emailNotificationSound, notificationSoundChoice: state.notificationSoundChoice, pushNotifyInboxOnly: state.pushNotifyInboxOnly, notificationMutes: state.notificationMutes, pushRelayUrl: state.pushRelayUrl, protocolOpenMode: state.protocolOpenMode, calendarNotificationsEnabled: state.calendarNotificationsEnabled, calendarNotificationSound: state.calendarNotificationSound, calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled, enableCalendarTasks: state.enableCalendarTasks, showTasksOnCalendar: state.showTasksOnCalendar, showBirthdayCalendar: state.showBirthdayCalendar, birthdayCalendarColor: state.birthdayCalendarColor, sharedCalendarColors: state.sharedCalendarColors, groupContactsByLetter: state.groupContactsByLetter, sortContactsByLastName: state.sortContactsByLastName, expandedFilterView: state.expandedFilterView, showTimeInMonthView: state.showTimeInMonthView, showWeekNumbers: state.showWeekNumbers, calendarFreeScroll: state.calendarFreeScroll, calendarHoverPreview: state.calendarHoverPreview, calendarLimitHours: state.calendarLimitHours, calendarDayStartHour: state.calendarDayStartHour, calendarDayEndHour: state.calendarDayEndHour, calendarHideNonWorkingDays: state.calendarHideNonWorkingDays, calendarWorkingDays: state.calendarWorkingDays, toolbarPosition: state.toolbarPosition, hideAccountSwitcher: state.hideAccountSwitcher, showRailAccountList: state.showRailAccountList, showAdvancedSettings: state.showAdvancedSettings, // proInterface is intentionally omitted - it's a per-device choice // (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced. enableUnifiedMailbox: state.enableUnifiedMailbox, includeGroupInUnified: state.includeGroupInUnified, unifiedCrossAccount: state.unifiedCrossAccount, allMailFolderIds: state.allMailFolderIds, preferredIdentityIds: state.preferredIdentityIds, enableCrossUnreadView: state.enableCrossUnreadView, enableCrossStarredView: state.enableCrossStarredView, enableCrossAllView: state.enableCrossAllView, senderFavicons: state.senderFavicons, showAvatarsInJunk: state.showAvatarsInJunk, faviconUnreadBadge: state.faviconUnreadBadge, colorfulSidebarIcons: state.colorfulSidebarIcons, tintListRowsByTag: state.tintListRowsByTag, tintListRowsByAccount: state.tintListRowsByAccount, showAccountDot: state.showAccountDot, showFolderTotalCount: state.showFolderTotalCount, showSharedMailAccounts: state.showSharedMailAccounts, hiddenSharedMailAccounts: state.hiddenSharedMailAccounts, folderIcons: state.folderIcons, folderCountModes: state.folderCountModes, emailKeywords: state.emailKeywords, nestedTags: state.nestedTags, attachmentReminderEnabled: state.attachmentReminderEnabled, attachmentReminderKeywords: state.attachmentReminderKeywords, emptySubjectWarningEnabled: state.emptySubjectWarningEnabled, recipientMentionsEnabled: state.recipientMentionsEnabled, mentionAddsRecipientTo: state.mentionAddsRecipientTo, hideInlineImageAttachments: state.hideInlineImageAttachments, attachmentImagePreviewsEnabled: state.attachmentImagePreviewsEnabled, sidebarApps: state.sidebarApps, keepAppsLoaded: state.keepAppsLoaded, onboardingCompleted: state.onboardingCompleted, tourCompleted: state.tourCompleted, showOnboardingOnNewDevices: state.showOnboardingOnNewDevices, debugMode: state.debugMode, debugCategories: state.debugCategories, settingsSyncDisabled: state.settingsSyncDisabled, explicitSettings: state.explicitSettings, // Cross-store settings theme: useThemeStore.getState().theme, themeChosen: useThemeStore.getState().themeChosen, locale: useLocaleStore.getState().locale, // Omitted entirely (rather than emitted empty) if the bridge has // not registered, so an importing device leaves its templates alone. ...(templateSync ? { templates: templateSync.templates, deletedTemplateIds: templateSync.deletedTemplateIds, } : {}), // Omitted entirely if the bridge has not registered, so an importing // device leaves its calendar subscriptions alone. ...(subscriptionSync ? { // A server blob carries only its own login's subscriptions: // feed URLs are secrets, and another login's (or an unowned // one's) would reappear there after that login signs out. icalSubscriptions: opts?.subscriptionOwner ? subscriptionSync.icalSubscriptions.filter( (sub) => sub.owner === opts.subscriptionOwner, ) : subscriptionSync.icalSubscriptions, deletedSubscriptionIds: subscriptionSync.deletedSubscriptionIds, } : {}), }; return JSON.stringify(settings, null, 2); }; return { ...DEFAULT_SETTINGS, updateSetting: (key, value, opts) => { if (opts?.internal || BOOKKEEPING_SETTING_KEYS.has(key)) { applySetting(key, value); return; } changePreference(key, value, get()[key], () => { applySetting(key, value); // The user chose this value, so an admin default no longer replaces it. const { explicitSettings } = get(); if (hasOwn(DEFAULT_SETTINGS, key) && key !== 'explicitSettings' && !explicitSettings.includes(key)) { set({ explicitSettings: [...explicitSettings, key] }); } }); }, applyAdminPolicy: () => { if (applyAdminPolicyPatch()) applyVisualSettings(); }, resetToDefaults: () => { set(DEFAULT_SETTINGS); // The defaults of this instance are the operator's, then the build's. applyAdminPolicyPatch(); applyVisualSettings(); void settingsHooks.onSettingsReset.emit({}); }, exportSettings: (opts) => { const json = buildExport(opts); if (!opts?.forSync) { void settingsHooks.onSettingsExport.emit({ keys: Object.keys(JSON.parse(json)) }); } return json; }, importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean; subscriptionOwner?: string }) => { try { const settings = JSON.parse(json); // Validate settings if (typeof settings !== 'object' || settings === null) { return false; } if (typeof settings.protocolOpenMode !== 'string' && typeof settings.protocolMailtoOpenMode === 'string') { settings.protocolOpenMode = settings.protocolMailtoOpenMode; } // Apply settings, noting which keys were taken over for the // onSettingsImport hook. const imported: string[] = []; const put = (key: string, patch: Record) => { set(patch as Partial); imported.push(key); }; Object.keys(settings).forEach((key) => { if (key in DEFAULT_SETTINGS) { // Merged below, once it is known which settings were taken over. if (key === 'explicitSettings') { return; } if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) { return; } if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(settings[key])) { put(key, { sendDelaySeconds: 0 }); return; } // Validity of the zone id itself is checked at use time // (lib/timezone resolveTimeZone falls back to the browser zone), // so only reject non-strings here. if (key === 'timeZone' && typeof settings[key] !== 'string') { return; } // Ignore a legacy global allMailFolderIds (string[] | null) or any // non-record value - this build keys it per account. if (key === 'allMailFolderIds' && !isPlainRecord(settings[key])) { return; } // Per-account map (accountId -> identityId); ignore any legacy // global/non-record value rather than corrupting the map. if (key === 'preferredIdentityIds' && !isPlainRecord(settings[key])) { return; } if (key === 'hiddenSharedMailAccounts' && !isPlainRecord(settings[key])) { return; } if (key === 'notificationMutes') { put(key, { notificationMutes: normalizeNotificationMutes(settings[key]) }); return; } // The list order is sent to the server as a sort array, so an // unknown criterion from a newer/older client must never get // through (an unsupportedSort refusal empties the folder). if (key === 'messageListOrder') { put(key, { messageListOrder: sanitizeSortLevels(settings[key]) }); return; } if (key === 'messageListOrderScope' && settings[key] !== 'inbox' && settings[key] !== 'all') { return; } if (DEVICE_LOCAL_SETTING_KEYS.has(key)) { return; } if (opts?.keepCrossAccountViews && CROSS_ACCOUNT_VIEW_KEYS.has(key)) { return; } // Per-account maps (accountId -> value) live in every account's // synced settings blob, so a per-account server load must NOT // replace the whole map - each account's server is authoritative // only for its OWN entry. Merging preserves the other accounts' // local values; without this the last account to load clobbers // the map and the composer picks another account's default sender // (login-order dependent). File imports (no serverAccountId) // still replace wholesale. if (opts?.serverAccountId && (key === 'preferredIdentityIds' || key === 'allMailFolderIds' || key === 'hiddenSharedMailAccounts')) { const existing = (get() as unknown as Record)[key]; const merged: Record = isPlainRecord(existing) ? { ...existing } : {}; const incoming = settings[key] as Record; if (Object.prototype.hasOwnProperty.call(incoming, opts.serverAccountId)) { merged[opts.serverAccountId] = incoming[opts.serverAccountId]; } put(key, { [key]: merged }); return; } put(key, { [key]: settings[key] }); } }); // Which settings the user chose (#1248): for every setting taken // over from the blob, what the blob says; for the rest, what this // device knew. A blob from before explicitSettings existed counts a // value that differs from the build default as chosen. const takenOver = new Set(imported); const blobExplicit: string[] = Array.isArray(settings.explicitSettings) ? settings.explicitSettings.filter((key: unknown): key is string => typeof key === 'string') : legacyExplicitSettings(settings); set({ explicitSettings: [ ...new Set([ ...get().explicitSettings.filter((key) => !takenOver.has(key)), ...blobExplicit.filter((key) => takenOver.has(key)), ]), ], }); // A blob carries whatever defaults applied when it was written, so // the operator's current ones go over the settings nobody chose. applyAdminPolicyPatch(); applyVisualSettings(); // Apply cross-store settings if (settings.theme) { // A mode the user never chose leaves room for the operator's // default (#1280). Blobs from before themeChosen count any mode // but the built-in default as chosen. const themeChosen = typeof settings.themeChosen === 'boolean' ? settings.themeChosen : settings.theme !== 'system'; useThemeStore.getState().setTheme(settings.theme, { chosen: themeChosen }); } if (settings.locale) { useLocaleStore.getState().setLocale(settings.locale); } // Templates live in their own store but ride along in the synced // settings blob (#825). Server loads merge, because every account's // blob carries the full (account-agnostic) template list and a stale // blob must not clobber it; file imports replace wholesale like the // rest of the settings. Blobs from builds that predate template sync // have no `templates` key and leave the local store untouched. templateSyncBridge?.applySyncedState( settings.templates, settings.deletedTemplateIds, { merge: Boolean(opts?.serverAccountId) } ); calendarSubscriptionSyncBridge?.applySyncedState( settings.icalSubscriptions, settings.deletedSubscriptionIds, { merge: Boolean(opts?.serverAccountId), owner: opts?.subscriptionOwner } ); // A load from the server reports itself as a sync (loadFromServer); // only a file import the user picked is an import. if (!opts?.serverAccountId) { if (settings.theme) imported.push('theme'); if (settings.locale) imported.push('locale'); if (settings.templates !== undefined) imported.push('templates'); void settingsHooks.onSettingsImport.emit({ keys: imported }); } return true; } catch (error) { console.error('Failed to import settings:', error); return false; } }, // Folder icon methods // These map-valued preferences go through updateSetting so plugins see // them like any other preference change. setFolderIcon: (mailboxId: string, icon: string) => { get().updateSetting('folderIcons', { ...get().folderIcons, [mailboxId]: icon }); }, removeFolderIcon: (mailboxId: string) => { const { [mailboxId]: _, ...rest } = get().folderIcons; get().updateSetting('folderIcons', rest); }, // Shared-calendar color override methods setSharedCalendarColor: (key: string, color: string) => { get().updateSetting('sharedCalendarColors', { ...get().sharedCalendarColors, [key]: color }); }, removeSharedCalendarColor: (key: string) => { const { [key]: _, ...rest } = get().sharedCalendarColors; get().updateSetting('sharedCalendarColors', rest); }, // Trusted senders methods addTrustedSender: (email: string) => { // Parse "Name " format to extract just the email address const trimmed = email.trim(); const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); const current = get().trustedSenders; if (!current.includes(emailAddress)) { set({ trustedSenders: [...current, emailAddress] }); void settingsHooks.onTrustedSenderChange.emit({ email: emailAddress, trusted: true }); } }, removeTrustedSender: (email: string) => { // Parse "Name " format to extract just the email address const trimmed = email.trim(); const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); const current = get().trustedSenders; if (!current.includes(emailAddress)) return; set({ trustedSenders: current.filter(e => e !== emailAddress) }); void settingsHooks.onTrustedSenderChange.emit({ email: emailAddress, trusted: false }); }, isSenderTrusted: (email: string) => { // Parse "Name " format to extract just the email address const trimmed = email.trim(); const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); return get().trustedSenders.includes(emailAddress); }, // Keyword methods addKeyword: (keyword: KeywordDefinition) => { const current = get().emailKeywords; if (current.some(k => k.id === keyword.id)) return; set({ emailKeywords: [...current, keyword] }); }, updateKeyword: (id: string, updates: Partial>) => { set({ emailKeywords: get().emailKeywords.map(k => k.id === id ? { ...k, ...updates } : k ), }); }, renameKeyword: (oldId: string, newKeyword: KeywordDefinition) => { set({ emailKeywords: get().emailKeywords.map(k => k.id === oldId ? newKeyword : k ), }); }, removeKeyword: (id: string) => { set({ emailKeywords: get().emailKeywords.filter(k => k.id !== id) }); }, reorderKeywords: (keywords: KeywordDefinition[]) => { set({ emailKeywords: keywords }); }, getKeywordById: (id: string) => { return get().emailKeywords.find(k => k.id === id); }, // Sidebar Apps methods addSidebarApp: (app: SidebarApp) => { const current = get().sidebarApps; if (current.some(a => a.id === app.id)) return; get().updateSetting('sidebarApps', [...current, app]); }, updateSidebarApp: (id: string, updates: Partial>) => { get().updateSetting('sidebarApps', get().sidebarApps.map(a => a.id === id ? { ...a, ...updates } : a )); }, removeSidebarApp: (id: string) => { get().updateSetting('sidebarApps', get().sidebarApps.filter(a => a.id !== id)); }, reorderSidebarApps: (apps: SidebarApp[]) => { get().updateSetting('sidebarApps', apps); }, // Settings sync methods enableSync: (username: string, serverUrl: string) => { const accountId = generateAccountId(username, serverUrl); if (blockedSyncAccountId === accountId) { syncWarn('Settings sync stays off for', username, '- its settings could not be loaded'); return; } writeSettingsOwner(accountId); syncUsername = username; syncServerUrl = serverUrl; syncEnabled = true; syncLog('Settings sync enabled for', username); if (pushTemplatesAfterLoad) { pushTemplatesAfterLoad = false; if (!get().settingsSyncDisabled) requestSync?.(); } }, flushSync: async () => { if (syncTimeout) { clearTimeout(syncTimeout); syncTimeout = null; } const job = pendingSync; pendingSync = null; if (job) await syncSettingsJob(job); }, disableSync: () => { syncEnabled = false; syncUsername = null; syncServerUrl = null; if (syncTimeout) { clearTimeout(syncTimeout); syncTimeout = null; } pendingSync = null; syncLog('Settings sync disabled'); }, loadFromServer: async (username: string, serverUrl: string) => { const accountId = generateAccountId(username, serverUrl); const owner = readSettingsOwner(); const heldByOther = owner !== null && owner !== accountId; // The local settings belong to another account and this one's could // not be read: show defaults and keep sync off, rather than showing // the other account's settings or pushing them under this name. const failWithoutForeignState = () => { if (heldByOther) { resetSyncedState(); writeSettingsOwner(null); blockedSyncAccountId = accountId; } return false; }; blockedSyncAccountId = null; try { syncLog('Loading settings from server for', username); pushTemplatesAfterLoad = false; const res = await apiFetch('/api/settings', { headers: { 'x-settings-username': username, 'x-settings-server': serverUrl, }, }); if (!res.ok) { const body = await res.json().catch(() => ({})); syncLog('Settings fetch failed:', body.error || `status ${res.status}`); return failWithoutForeignState(); } const { settings } = await res.json(); // With several logins on this device, the views spanning them stay // as the device has them (see CROSS_ACCOUNT_VIEW_KEYS). const { useAccountStore } = await import('./account-store'); const keepCrossAccountViews = useAccountStore.getState().accounts.length > 1; if (!settings) { syncLog('No server settings found yet'); // A new account starts from defaults, not from whatever the // previous account left in this browser (#1185). if (heldByOther) resetSyncedState({ keepCrossAccountViews }); writeSettingsOwner(accountId); pushTemplatesAfterLoad = localTemplatesAhead(null); return false; } if (settings && typeof settings === 'object') { // Importing merges templates and leaves keys the blob lacks as // they are, so another account's state would carry over into // this one and be pushed back to its server copy. if (heldByOther) resetSyncedState({ keepCrossAccountViews }); isLoadingFromServer = true; // Merge (not replace) per-account maps for the account being loaded, // so multi-account logins don't clobber each other by login order. get().importSettings(JSON.stringify(settings), { serverAccountId: accountId, keepCrossAccountViews, subscriptionOwner: subscriptionOwnerFor(serverUrl, username), }); isLoadingFromServer = false; writeSettingsOwner(accountId); pushTemplatesAfterLoad = localTemplatesAhead(settings); syncLog('Settings loaded from server successfully'); void settingsHooks.onSettingsSync.emit({ direction: 'pulled' }); // The per-account preferred sender identity (#507) is re-applied by // applyPreferredIdentity() in auth-store, invoked from the // loadFromServer().finally() of every login / switch / restore path, // so no extra hook is needed here. return true; } return false; } catch (error) { syncError('Failed to load settings from server:', error); isLoadingFromServer = false; return failWithoutForeignState(); } }, forgetSyncedSettings: () => { // Never synced: the local settings are this device's own and exist // nowhere else. Synced but opted out: they were never uploaded either. if (readSettingsOwner() === null || get().settingsSyncDisabled) return; resetSyncedState(); writeSettingsOwner(null); }, }; }, { name: 'settings-storage', version: 8, migrate: migrateSettings, onRehydrateStorage: () => { return (state) => { if (state) { // Defensive: a legacy global array or any non-record value (e.g. // synced from an older client) is coerced to an empty map so // per-account consumers never see a non-record. if (!isPlainRecord(state.allMailFolderIds)) { state.allMailFolderIds = {}; } if (!isPlainRecord(state.preferredIdentityIds)) { state.preferredIdentityIds = {}; } if (!isPlainRecord(state.hiddenSharedMailAccounts)) { state.hiddenSharedMailAccounts = {}; } if (!Array.isArray(state.explicitSettings)) { state.explicitSettings = []; } state.notificationMutes = normalizeNotificationMutes(state.notificationMutes); state.messageListOrder = sanitizeSortLevels(state.messageListOrder); if (state.messageListOrderScope !== 'inbox' && state.messageListOrderScope !== 'all') { state.messageListOrderScope = 'inbox'; } applyFontSize(state.fontSize); applyDensity(state.density, state.interfaceLayout); applyInterfaceLayout(state.interfaceLayout, state.density); applyAnimations(state.animationsEnabled); } }; }, } ) ); /** * Versioned migration for persisted settings. Exported for tests. Mutates and * returns the persisted record so each bump only needs to handle its own delta. */ export function migrateSettings(persisted: unknown, version: number): SettingsState { const state = persisted as Record; if (version < 2 && state.listDensity) { state.density = state.listDensity; delete state.listDensity; } if (![0, 10, 30, 60].includes(state.sendDelaySeconds as number)) { state.sendDelaySeconds = 0; } if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') { state.protocolOpenMode = state.protocolMailtoOpenMode; } delete state.protocolMailtoOpenMode; // v4: `dateFormat` was repurposed from 'regional'|'iso'|'custom' to // 'smart'|'relative'|'full'. The old setting was never read anywhere, // so every persisted value maps to the new default. if (version < 4) { state.dateFormat = 'smart'; } // v5: allMailFolderIds went from a global `string[] | null` to a // per-account `Record`. The legacy global list // can't be attributed to a specific account here (the active account // isn't known at migrate time), so it's dropped - each account starts // "not configured" (defaults to all no-role folders). if (version < 5 || !isPlainRecord(state.allMailFolderIds)) { state.allMailFolderIds = {}; } // "All accounts" was reworked into the account-bounded "Unified // Mailbox". The standalone __all_mail__ view (`enableAllMailView`) was // folded into the unified "All mail" entry (`enableCrossAllView`), and a // `unifiedCrossAccount` toggle now governs whether the views span every // logged-in account. Existing users keep their current behaviour: // - if any cross view was on, they were already cross-account -> keep it on // - else if only standalone All Mail was on, enable the account-bounded // unified "All mail" entry (folder selection carries over via allMailFolderIds) // Guarded at <7 (not <6) so users who stopped at main's interim v6 // identity-map bump - which shipped without this rework - still receive it. if (version < 7) { const hadCross = !!(state.enableCrossUnreadView || state.enableCrossStarredView || state.enableCrossAllView); if (hadCross) { state.unifiedCrossAccount = true; } else if (state.enableAllMailView) { state.enableUnifiedMailbox = true; state.enableCrossAllView = true; state.unifiedCrossAccount = false; } delete state.enableAllMailView; if (typeof state.unifiedCrossAccount !== 'boolean') state.unifiedCrossAccount = false; // The reworked unified mailbox spans the account's own folders plus its // shared/group folders, so enable shared inclusion for every migrated // configuration (matches the new-install default). state.includeGroupInUnified = true; } // Per-account default-identity map (issue #507). Coerce any // missing/legacy value to an empty record. Guarded at <6 so users who // already received it via main's v6 bump keep their populated map. if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) { state.preferredIdentityIds = {}; } // v8: admin policy defaults (#1248) skip settings the user chose. // Earlier builds did not record that, so a setting that differs from // the build default is taken as chosen. if (version < 8 || !Array.isArray(state.explicitSettings)) { state.explicitSettings = legacyExplicitSettings(state); } return state as unknown as SettingsState; } // Helper functions to apply settings to DOM function applyFontSize(size: FontSize) { if (typeof document === 'undefined') return; const root = document.documentElement; const sizeMap = { small: '14px', medium: '16px', large: '18px', }; root.style.setProperty('--font-size-base', sizeMap[size]); } type DensityVars = Record; const DEFAULT_DENSITY: Record = { 'extra-compact': { '--list-item-height': 'auto', '--density-item-py': '2px', '--density-item-gap': '6px', '--density-header-py': '4px', '--density-card-p': '8px', '--density-sidebar-py': '0px', }, compact: { '--list-item-height': 'auto', '--density-item-py': '4px', '--density-item-gap': '8px', '--density-header-py': '6px', '--density-card-p': '10px', '--density-sidebar-py': '1px', }, regular: { '--list-item-height': '48px', '--density-item-py': '12px', '--density-item-gap': '12px', '--density-header-py': '12px', '--density-card-p': '16px', '--density-sidebar-py': '4px', }, comfortable: { '--list-item-height': '64px', '--density-item-py': '16px', '--density-item-gap': '16px', '--density-header-py': '16px', '--density-card-p': '20px', '--density-sidebar-py': '6px', }, }; /** * Mountain View's rows come in three rhythms - 32, 40 and 48 pixels - and the * app offers four densities. The four are laid over that scale: `compact`, * `regular` and `comfortable` land on the three exactly, and `extra-compact` * continues the series a step below, so every density still does something. * * Only the vertical rhythm is restated. Everything else a density touches - * gaps, card padding, the sidebar's own row padding - keeps its default value; * a navigation row is already pinned to 32px by the layout's own token. */ const MOUNTAIN_VIEW_DENSITY: Record = { 'extra-compact': { ...DEFAULT_DENSITY['extra-compact'], '--list-item-height': '28px', '--density-item-py': '4px' }, compact: { ...DEFAULT_DENSITY.compact, '--list-item-height': '32px', '--density-item-py': '6px' }, regular: { ...DEFAULT_DENSITY.regular, '--list-item-height': '40px', '--density-item-py': '10px' }, comfortable: { ...DEFAULT_DENSITY.comfortable, '--list-item-height': '48px', '--density-item-py': '14px' }, }; export function densityVarsFor(density: Density, layout: InterfaceLayout): DensityVars { return (layout === 'mountain-view' ? MOUNTAIN_VIEW_DENSITY : DEFAULT_DENSITY)[density]; } function applyDensity(density: Density, layout: InterfaceLayout) { if (typeof document === 'undefined') return; const root = document.documentElement; for (const [prop, val] of Object.entries(densityVarsFor(density, layout))) { root.style.setProperty(prop, val); } } /** * The interface layout is a single attribute on ; every geometry * override lives in the `[data-interface-layout="mountain-view"]` block in * globals.css, so nothing has to re-render for the shell to change shape. */ function applyInterfaceLayout(layout: InterfaceLayout, density: Density) { if (typeof document === 'undefined') return; document.documentElement.dataset.interfaceLayout = layout; // The row rhythm depends on the layout as much as on the density, and it is // written as an inline custom property, which no stylesheet rule could // override. Re-applying it here is what keeps the two in step. applyDensity(density, layout); } function applyAnimations(enabled: boolean) { if (typeof document === 'undefined') return; const root = document.documentElement; if (enabled) { root.style.removeProperty('--transition-duration'); delete root.dataset.animations; } else { root.style.setProperty('--transition-duration', '0s'); // The variable alone reached nothing that animates: the panes slide on // Tailwind transitions and the menus on keyframes. globals.css stops // them all under this attribute, as it does for prefers-reduced-motion. root.dataset.animations = 'off'; } } // Tag definitions change from the settings editor, from plugins (host-api // writes the store directly) and from imports and server loads. Watching the // list catches every path once instead of hooking each writer. useSettingsStore.subscribe((state, prev) => { if (state.emailKeywords === prev.emailKeywords) return; if (JSON.stringify(state.emailKeywords) === JSON.stringify(prev.emailKeywords)) return; void settingsHooks.onKeywordChange.emit({ keywords: toKeywordViews(state.emailKeywords) }); }); // Initialize settings on load if (typeof window !== 'undefined') { const store = useSettingsStore.getState(); applyFontSize(store.fontSize); applyDensity(store.density, store.interfaceLayout); applyInterfaceLayout(store.interfaceLayout, store.density); applyAnimations(store.animationsEnabled); // The service worker shows push notifications without this store, so it // reads the sound preference from its own Cache store (#1289). const pushSound = (s: SettingsState) => s.emailNotificationsEnabled && s.emailNotificationSound; let lastPushSound = pushSound(store); void writePushPreferences({ sound: lastPushSound }); useSettingsStore.subscribe((state) => { const sound = pushSound(state); if (sound === lastPushSound) return; lastPushSound = sound; void writePushPreferences({ sound }); }); // The policy arrives after this store has rehydrated (and again with the // full scope once signed in), so its defaults apply whenever it changes. usePolicyStore.subscribe((policyState, prev) => { if (policyState.policy === prev.policy) return; useSettingsStore.getState().applyAdminPolicy(); }); const triggerSync = () => { if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return; pendingSync = { username: syncUsername, serverUrl: syncServerUrl, settings: JSON.parse(useSettingsStore.getState().exportSettings({ forSync: true, subscriptionOwner: subscriptionOwnerFor(syncServerUrl, syncUsername), })), }; if (syncTimeout) clearTimeout(syncTimeout); syncTimeout = setTimeout(async () => { syncTimeout = null; const job = pendingSync; pendingSync = null; if (!job) return; try { await syncSettingsJob(job); } catch (error) { syncError('Settings sync error:', error); } }, SYNC_DEBOUNCE_MS); }; requestSync = triggerSync; // Auto-sync settings to server on any state change let prevSyncDisabled = useSettingsStore.getState().settingsSyncDisabled; useSettingsStore.subscribe(() => { const currentSyncDisabled = useSettingsStore.getState().settingsSyncDisabled; const syncToggleChanged = currentSyncDisabled !== prevSyncDisabled; prevSyncDisabled = currentSyncDisabled; // Skip sync if disabled, unless the toggle itself just changed if (currentSyncDisabled && !syncToggleChanged) return; triggerSync(); }); // Also sync when theme or locale changes useThemeStore.subscribe(triggerSync); useLocaleStore.subscribe(triggerSync); // And when templates change (they ride along in the synced blob, #825). // Unlike theme/locale this honors the user's sync opt-out toggle. The // subscription itself is made by template-store when it registers the // template sync bridge (see registerTemplateSyncBridge). onTemplateStoreChange = () => { if (useSettingsStore.getState().settingsSyncDisabled) return; triggerSync(); }; // And when iCal subscriptions change (they ride along in the synced blob). onCalendarSubscriptionStoreChange = () => { if (useSettingsStore.getState().settingsSyncDisabled) return; triggerSync(); }; // Ensure template-store and calendar-store are loaded (and bridges registered) even before // any UI component imports them, so the first sync push already carries them. import('./template-store').catch(() => {}); import('./calendar-store').catch(() => {}); } /** * The message-list order to request for a folder with the given role (#718): * the configured levels when they apply to every folder or this is the Inbox, * chronological otherwise. Pass null for views without a folder role (tag * views, search) - those only pick the order up under the "all folders" scope. */ export function getMessageListOrderFor(role: string | null | undefined): SortLevel[] { const { messageListOrder, messageListOrderScope } = useSettingsStore.getState(); return orderForMailbox(messageListOrder, messageListOrderScope, role); }