Files
webmail/stores/settings-store.ts
T

2088 lines
94 KiB
TypeScript

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<string, unknown> {
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<typeof setTimeout> | 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<string, unknown>;
}
// --- 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<string, string>;
};
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<string, unknown> | 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<string, string>;
};
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<void> {
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<string>(['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<string>(['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<string>([
'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:<id>
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<string, KeywordColor> = {
// 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<string, string>;
// 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<string, string[]>;
// 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<string, string>;
// 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<string, string[]>;
// Folders
folderIcons: Record<string, string>; // mailboxId -> icon name
folderCountModes: Record<string, FolderCountMode>; // 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;
// Ask for confirmation when sending a message with unfilled placeholders
placeholderWarningEnabled: 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<DebugCategory, boolean>;
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: <K extends keyof SettingsState>(
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<Omit<KeywordDefinition, 'id'>>) => 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<Omit<SidebarApp, 'id'>>) => void;
removeSidebarApp: (id: string) => void;
reorderSidebarApps: (apps: SidebarApp[]) => void;
// Settings sync
enableSync: (username: string, serverUrl: string) => void;
flushSync: () => Promise<void>;
disableSync: () => void;
loadFromServer: (username: string, serverUrl: string) => Promise<boolean>;
/**
* 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<string, string>,
// 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<string, string[]>,
preferredIdentityIds: {} as Record<string, string>,
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<string, string[]>,
// Folders
folderIcons: {} as Record<string, string>,
folderCountModes: {} as Record<string, FolderCountMode>,
// 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,
placeholderWarningEnabled: 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<DebugCategory, boolean>,
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<string, unknown>)[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, unknown>): 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<string, unknown>,
policy: Partial<Pick<SettingsPolicy, 'defaults' | 'restrictions'>>,
): Record<string, unknown> {
const explicit = new Set(Array.isArray(state.explicitSettings) ? (state.explicitSettings as string[]) : []);
const defaults = isPlainRecord(policy.defaults) ? policy.defaults : {};
const restrictions: Record<string, SettingRestriction> = isPlainRecord(policy.restrictions) ? policy.restrictions : {};
const restrictionFor = (key: string) => (hasOwn(restrictions, key) ? restrictions[key] : undefined);
const patch: Record<string, unknown> = {};
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<SettingsState>()(
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<string, unknown> = { ...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<SettingsState>);
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<string, unknown>,
usePolicyStore.getState().policy,
);
if (Object.keys(patch).length === 0) return false;
set(patch as Partial<SettingsState>);
return true;
};
const applySetting = <K extends keyof SettingsState>(key: K, value: SettingsState[K]) => {
set({ [key]: value } as Partial<SettingsState>);
// 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,
placeholderWarningEnabled: state.placeholderWarningEnabled,
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<string, unknown>) => {
set(patch as Partial<SettingsState>);
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<string, unknown>)[key];
const merged: Record<string, unknown> = isPlainRecord(existing) ? { ...existing } : {};
const incoming = settings[key] as Record<string, unknown>;
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 <email>" 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 <email>" 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 <email>" 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<Omit<KeywordDefinition, 'id'>>) => {
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<Omit<SidebarApp, 'id'>>) => {
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<string, unknown>;
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<accountId, string[]>`. 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<string, string>;
const DEFAULT_DENSITY: Record<Density, DensityVars> = {
'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<Density, DensityVars> = {
'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 <html>; 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);
}